Compare commits

..
6 Commits
Author SHA1 Message Date
abrown38andClaude Opus 5.5 c17f5cd9f0 Release 0.1.0
release / release (push) Successful in 1m2s
Housecarl 0.1.0. Since rc.7: the boot ruleset fails closed (a fallback
keeps anything new from the internet out if it doesn't load), the
flowtable follows devices that appear later or reconnect, /healthz for
monitoring, renaming zones, sets and services with their references,
and an import that keeps a chain's head in order. make test (with nft
1.1.3) and make e2e (242 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:17:01 +01:00
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:16:46 +01:00
abrown38andClaude Opus 5.5 835a9ba2cc Release 0.1.0-rc.7
release / release (push) Successful in 1m3s
Housecarl 0.1.0's seventh release candidate. Since rc.6: statistics
over time (traffic per zone and device, drops, blocked sources, hits,
connections, quotas, the machine). make test (with nft 1.1.3) and make
e2e (188 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:32:07 +01:00
abrown38andClaude Opus 5.5 cf30715c5a Statistics: traffic, drops, devices and connections over time
internal/stats samples every minute what the kernel already counts: the
interfaces' counters (per zone too), Housecarl's table (hits per rule,
built-in and policy; drops by chain; quotas; per-device sets), the
connection tracking table, and housecarld's and the host's memory, CPU
and load. Logged drops feed top lists of sources and what they tried.
Aggregates go to a database of their own (stats.db; per minute 7 days,
per hour 90 days, per day 2 years), with the store and queries from
Jarl's.

Traffic per inside device is a setting (stats_per_device): counting sets
stats_up/down_v4/v6 and rules at the top of the forward chain, IPv6 per
/64, carried across reloads. Set names starting with meter_, conn_,
quota_ or stats_ are reserved (set_name_reserved).

GET /stats/{status,summary,timeline,top,hits,heatmap}, hc stats, a
Statistics page with Jarl's charts, two charts on the dashboard, and a
conntrack_full warning. e2e: 188 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:27:48 +01:00
abrown38andClaude Opus 5.5 6bb82e86f5 Release 0.1.0-rc.6
release / release (push) Successful in 1m0s
Housecarl 0.1.0's sixth release candidate. Since rc.5: the zone
matrix, what the rules do to new connections between zones. make test
(with nft 1.1.3) and make e2e (166 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:35:26 +01:00
abrown38andClaude Opus 5.5 b6f31c86a6 Zone matrix: what the rules do between zones
internal/matrix walks each pair's rules in order, as nftables would, for
new connections: zone → zone (forward), zone → the firewall (input) and
back (output), with the built-ins that act on new connections. A rule
for the whole pair decides it; one for part of its traffic (addresses,
services, times, rates, a quota) is an exception; the rest falls to the
policy. Rules whose addresses all lie in networks connected to other
zones are left out of a pair. GET /matrix (with ?changeset, pending
changes), hc matrix [FROM TO], and a Matrix page whose cells open their
rules.

e2e: 166 checks, among them the matrix of the maintainer's imported
ruleset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:35:26 +01:00
62 changed files with 5713 additions and 103 deletions

No files matched your search

+51 -1
View File
@@ -6,6 +6,8 @@ All notable changes to Housecarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [0.1.0] - 2026-10-19
The first version of Housecarl, a firewall manager for nftables, built The first version of Housecarl, a firewall manager for nftables, built
the way Jarl and Steward are. the way Jarl and Steward are.
@@ -66,10 +68,58 @@ the way Jarl and Steward are.
open. open.
- **At boot:** the confirmed ruleset is loaded by - **At boot:** the confirmed ruleset is loaded by
`housecarl-ruleset.service` before the network comes up, without `housecarl-ruleset.service` before the network comes up, without
housecarld. An empty configuration applies nothing. housecarld. An empty configuration applies nothing. It fails closed:
if the saved ruleset doesn't load, a fallback saved beside it
(`ruleset-fallback.nft`) is, until housecarld puts the real one back:
nothing new gets in from external zones, the inside keeps working
(masqueraded), and the unit is marked failed with a critical message.
`hc status`, the dashboard and `/healthz` say while the fallback is in
force; the warning `no_external_zone` says when no zone is external,
which would make the fallback close everything (docs/operations.md).
- **The flowtable and interfaces that come and go:** the boot ruleset
has no flowtable (nftables on Debian 13's kernel refuses one naming a
device that doesn't exist yet, such as `ppp0` before PPPoE is up, and
with it the whole file); housecarld watches the kernel's link messages
and applies the ruleset again when a flowtable device appears or comes
back as a new interface (PPPoE reconnecting). A guide to when the
flowtable helps, and what it costs (docs/flowtable.md).
- **`GET /healthz` for monitoring:** `ok`, `firewall` (applied,
unconfigured, fallback, missing), changes on probation and the
machine's warnings, with HTTP 503 unless the firewall is in force;
no login needed, read at most every five seconds.
- **Import keeps a chain's head in order:** once a would-be built-in at
the head of a chain has to stay a rule (an invalid drop the forward
chain needs after an exception), the built-ins after it stay rules
too, in their place, instead of moving ahead of it: an ICMPv6 accept
no longer jumps ahead of the invalid drop it came after.
- **Renaming a zone, set or service** changes the rules, NAT rules and
port forwards that name it, in the same change (one history entry, one
undo); before, a name in use couldn't change. Dynamic sets keep their
names (their tokens and programs go by them).
- VLAN sub-interfaces (`enp2s0.20`) in zones and the flowtable, tested
with tagged traffic (docs/rules.md).
- `hc settings set --flowtable none` (and `none` for the other lists)
empties the list.
- **Warnings about the machine:** while `nftables.service` is enabled - **Warnings about the machine:** while `nftables.service` is enabled
(it loads `/etc/nftables.conf` at boot, and flushes every table when (it loads `/etc/nftables.conf` at boot, and flushes every table when
stopped), and while the table an import came from still applies. stopped), and while the table an import came from still applies.
- **Statistics** (Traffic → Statistics, `hc stats`, `/api/v1/stats/...`):
traffic per interface and zone, drops by chain and when they happen,
hits per rule and the rules that matched nothing, the sources blocked
most and what they tried, connections against the kernel's limit, each
quota's use, and housecarld's and the host's memory and load, sampled
every minute into a database of their own (`stats.db`, kept 7 days per
minute, 90 days per hour, 2 years per day). On by default; traffic per
inside device is a setting (`--stats-per-device`) that adds counters
to the forward chain. Two charts on the dashboard; a warning when the
connection tracking table is nearly full (docs/stats.md).
- Set names starting with `meter_`, `conn_`, `quota_` or `stats_` are
refused (`set_name_reserved`): Housecarl's own sets use them, and a set
of yours named so could clash with them.
- **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 - **Connections and tracing:** the connections the kernel tracks, with
their NAT, and closing them so the rules check them again (Traffic → their NAT, and closing them so the rules check them again (Traffic →
Connections, `hc connections`); a rule option, "every packet", for Connections, `hc connections`); a rule option, "every packet", for
+11 -6
View File
@@ -37,7 +37,8 @@ table of its own.
* **The rules every firewall needs**, built in and each one switchable: * **The rules every firewall needs**, built in and each one switchable:
loopback, established connections, invalid packets, the ICMPv6 that loopback, established connections, invalid packets, the ICMPv6 that
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
DHCPv6 client, and a flowtable fast path. DHCPv6 client, and a flowtable fast path
([docs/flowtable.md](docs/flowtable.md)).
* **Changes you can trust.** Each change is validated as a whole, checked * **Changes you can trust.** Each change is validated as a whole, checked
by nftables itself, and applied in one transaction. As in Jarl, edits by nftables itself, and applied in one transaction. As in Jarl, edits
can be collected in a changeset, reviewed with the diff of the ruleset, can be collected in a changeset, reviewed with the diff of the ruleset,
@@ -48,13 +49,16 @@ table of its own.
ruleset into zones, sets, services, rules and port forwards, keeping the ruleset into zones, sets, services, rules and port forwards, keeping the
rules' order, and keeps what it can't translate as raw rules rules' order, and keeps what it can't translate as raw rules
([docs/import.md](docs/import.md)). ([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,
statistics over time (traffic per zone and device, drops, the sources
blocked most, connections; [docs/stats.md](docs/stats.md)), hits per rule, a live log of what was dropped and
why, the connections the firewall tracks (and closing them), a packet why, the connections the firewall tracks (and closing them), a packet
tracer that names the rule that dropped something, in any table, and tracer that names the rule that dropped something, in any table, and
the history of every change, field by field, with undo. the history of every change, field by field, with undo.
* **Its own table only.** Docker, Incus and libvirt keep theirs. The * **Its own table only.** Docker, Incus and libvirt keep theirs. The
confirmed ruleset is loaded at boot, before the network comes up, even confirmed ruleset is loaded at boot, before the network comes up, even
if housecarld doesn't start. if housecarld doesn't start; should it fail to load, a fallback keeps
anything new from the internet out until housecarld puts it back.
* **Sets other programs can fill.** A dynamic set's entries expire by * **Sets other programs can fill.** A dynamic set's entries expire by
themselves, and a token can be limited to one set: that is how themselves, and a token can be limited to one set: that is how
Steward's captive portal will let a guest in. Steward's captive portal will let a guest in.
@@ -100,7 +104,8 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
* [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins * [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins
* [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects * [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects
* [import.md](docs/import.md): moving from an existing nftables ruleset * [import.md](docs/import.md): moving from an existing nftables ruleset
* [operations.md](docs/operations.md): probation, the boot, hits and the log, backups, troubleshooting * [operations.md](docs/operations.md): probation, the boot and its fallback, hits and the log, monitoring, backups, troubleshooting
* [flowtable.md](docs/flowtable.md): the fast path for forwarded traffic, and when (not) to use it
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl * [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
* [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do * [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do
* [cli.md](docs/cli.md): the `hc` command line client * [cli.md](docs/cli.md): the `hc` command line client
@@ -113,11 +118,11 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.1.0 is in testing (0.1.0-rc.5): zones, address sets and Housecarl 0.1.0 is released: 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
at boot, hits and the drop log, nftables at boot with a fail-closed fallback, hits and the drop log, nftables
import, changesets reviewed with the ruleset's diff (as in Jarl), import, changesets reviewed with the ruleset's diff (as in Jarl),
bandwidth limits and quotas, a web UI, REST API and CLI. bandwidth limits and quotas, a web UI, REST API and CLI.
+1 -1
View File
@@ -1 +1 @@
0.1.0-rc.5 0.1.0
+16 -2
View File
@@ -125,6 +125,7 @@ func settingsCmd() *cobra.Command {
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6)) fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices)) fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation())) fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
fmt.Fprintf(w, "Traffic per device:\t%s\n", map[bool]string{true: "counted (statistics)", false: "not counted"}[s.StatsPerDevice])
if s.ManualConfirm { if s.ManualConfirm {
fmt.Fprintln(w, "Confirmation:\tby a person ('hc confirm', the web UI's banner)") fmt.Fprintln(w, "Confirmation:\tby a person ('hc confirm', the web UI's banner)")
} else { } else {
@@ -133,7 +134,7 @@ func settingsCmd() *cobra.Command {
}) })
}, },
}) })
var input, forward, output, logDrops, probation, autoConf string var input, forward, output, logDrops, probation, autoConf, perDevice string
var builtins, own, flowtable, spoof4, spoof6 []string var builtins, own, flowtable, spoof4, spoof6 []string
set := &cobra.Command{ set := &cobra.Command{
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs, Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
@@ -143,7 +144,8 @@ dhcpv6_client, antispoof, mss_clamp.
hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48 hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
hc settings set --forward-policy drop --log-drops on hc settings set --forward-policy drop --log-drops on
hc settings set --flowtable wan --flowtable eth1`, hc settings set --flowtable wan --flowtable eth1
hc settings set --stats-per-device on`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fl := cmd.Flags() fl := cmd.Flags()
p := patch{} p := patch{}
@@ -174,9 +176,20 @@ dhcpv6_client, antispoof, mss_clamp.
} }
for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} { for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} {
if fl.Changed(flag) { if fl.Changed(flag) {
// "none" (or "") empties the list.
if len(v) == 1 && (v[0] == "none" || v[0] == "") {
v = []string{}
}
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
} }
} }
if fl.Changed("stats-per-device") {
on, err := onOff("stats-per-device", perDevice)
if err != nil {
return err
}
p["stats_per_device"] = on
}
if fl.Changed("auto-confirm") { if fl.Changed("auto-confirm") {
on, err := onOff("auto-confirm", autoConf) on, err := onOff("auto-confirm", autoConf)
if err != nil { if err != nil {
@@ -205,6 +218,7 @@ dhcpv6_client, antispoof, mss_clamp.
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)") f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)") f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)") f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
f.StringVar(&perDevice, "stats-per-device", "", "count traffic per inside device, for the statistics' top devices: on or off")
f.StringVar(&autoConf, "auto-confirm", "", "on: the client that made a change confirms it once it reaches Housecarl again over a new connection; off: a person confirms") f.StringVar(&autoConf, "auto-confirm", "", "on: the client that made a change confirms it once it reaches Housecarl again over a new connection; off: a person confirms")
cmd.AddCommand(set) cmd.AddCommand(set)
return cmd return cmd
+1 -1
View File
@@ -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(), 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(), statsCmd(), interfaceCmd())
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd()) add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd()) add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root return root
+401
View File
@@ -0,0 +1,401 @@
package main
import (
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
// Statistics: the figures housecarld keeps over time (docs/stats.md). The
// types mirror internal/stats's JSON, so hc doesn't link the database.
type statEntry struct {
Key string `json:"key"`
Count int64 `json:"count"`
Name string `json:"name,omitempty"`
}
type statTraffic struct {
Name string `json:"name"`
Received int64 `json:"received"`
Sent int64 `json:"sent"`
}
type statSummary struct {
Since string `json:"since"`
From time.Time `json:"from"`
Zones []statTraffic `json:"zones"`
Interfaces []statTraffic `json:"interfaces"`
Drops int64 `json:"drops"`
DropsByChain []statEntry `json:"drops_by_chain"`
Rules []statEntry `json:"rules"`
PeakConnections int64 `json:"peak_connections"`
ConntrackMax int64 `json:"conntrack_max"`
BlockedSources []statEntry `json:"blocked_sources"`
BlockedPorts []statEntry `json:"blocked_ports"`
DevicesUp []statEntry `json:"devices_up"`
DevicesDown []statEntry `json:"devices_down"`
}
type statTimeline struct {
Step string `json:"step"`
Times []int64 `json:"times"`
Series []struct {
Key string `json:"key"`
Values []int64 `json:"values"`
} `json:"series"`
}
type statStatus struct {
Enabled bool `json:"enabled"`
Database string `json:"database"`
Size int64 `json:"size"`
LastSample *time.Time `json:"last_sample"`
SampleError string `json:"sample_error"`
FirstData *time.Time `json:"first_data"`
PerDevice bool `json:"per_device"`
Retention struct {
Minute, Hour, Day string
} `json:"retention"`
Latest *struct {
Conntrack int64 `json:"conntrack"`
ConntrackMax int64 `json:"conntrack_max"`
RSS int64 `json:"rss"`
CPUPermille int64 `json:"cpu_permille"`
MemUsed int64 `json:"mem_used"`
Load float64 `json:"load"`
} `json:"latest"`
}
func statsCmd() *cobra.Command {
var since string
var limit int
cmd := &cobra.Command{
Use: "stats", Short: "Traffic, drops, blocked sources and devices over time",
Long: `Sums up what the firewall did over a while (--since: 90m, 24h, 7d, 4w):
traffic per zone, packets dropped, the busiest rules, the most connections
at once, the sources blocked most and what they tried, and (with traffic
per device on) the devices that used the most.
hc stats --since 7d
hc stats traffic --since 24h
hc stats top devices --since 30d
hc stats rules --unused`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
s, err := get[statSummary]("/stats/summary", q("since", since, "limit", strconv.Itoa(limit)))
if err != nil {
return err
}
return output(s, func(w io.Writer) { printSummary(w, s) })
},
}
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d, 4w…")
cmd.Flags().IntVar(&limit, "limit", 5, "entries per top list")
cmd.AddCommand(statsStatusCmd(), statsTrafficCmd(), statsTopCmd(), statsRulesCmd(), statsConnectionsCmd())
return cmd
}
func printSummary(w io.Writer, s statSummary) {
fmt.Fprintf(w, "Since %s (%s)\n\n", s.From.Local().Format("2006-01-02 15:04"), s.Since)
if len(s.Zones) > 0 {
fmt.Fprintln(w, "ZONE\tRECEIVED\tSENT")
for _, t := range s.Zones {
fmt.Fprintf(w, "%s\t%s\t%s\n", t.Name, humanBytes(t.Received), humanBytes(t.Sent))
}
fmt.Fprintln(w)
}
var chains []string
for _, e := range s.DropsByChain {
chains = append(chains, fmt.Sprintf("%s %s", e.Key, humanCount(uint64(e.Count))))
}
drops := humanCount(uint64(s.Drops))
if len(chains) > 0 {
drops += " (" + strings.Join(chains, ", ") + ")"
}
fmt.Fprintf(w, "Dropped:\t%s packets\n", drops)
if s.ConntrackMax > 0 {
fmt.Fprintf(w, "Connections:\tat most %d at once (of %d)\n", s.PeakConnections, s.ConntrackMax)
}
list := func(title string, es []statEntry, unit func(int64) string) {
if len(es) == 0 {
return
}
fmt.Fprintf(w, "\n%s\n", title)
for _, e := range es {
name := e.Key
if e.Name != "" {
name = e.Name
}
fmt.Fprintf(w, " %s\t%s\n", name, unit(e.Count))
}
}
packets := func(n int64) string { return humanCount(uint64(n)) + " packets" }
list("Busiest rules:", s.Rules, packets)
list("Blocked most (logged drops):", s.BlockedSources, packets)
list("What they tried:", s.BlockedPorts, packets)
list("Devices, sent:", s.DevicesUp, humanBytes)
list("Devices, received:", s.DevicesDown, humanBytes)
}
func statsStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Short: "Whether statistics are collected, since when, and the database", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
s, err := get[statStatus]("/stats/status", nil)
if err != nil {
return err
}
return output(s, func(w io.Writer) {
if !s.Enabled {
fmt.Fprintln(w, "Statistics are off (stats.enabled in housecarl.yaml).")
return
}
fmt.Fprintf(w, "Collecting:\tyes\n")
if s.Database != "" {
fmt.Fprintf(w, "Database:\t%s (%s)\n", s.Database, humanBytes(s.Size))
} else {
fmt.Fprintf(w, "Database size:\t%s\n", humanBytes(s.Size))
}
if s.FirstData != nil {
fmt.Fprintf(w, "Since:\t%s\n", s.FirstData.Local().Format("2006-01-02 15:04"))
}
if s.LastSample != nil {
fmt.Fprintf(w, "Last sample:\t%s\n", s.LastSample.Local().Format("15:04:05"))
}
if s.SampleError != "" {
fmt.Fprintf(w, "Error:\t%s\n", s.SampleError)
}
fmt.Fprintf(w, "Kept:\tper minute %s, hourly %s, daily %s\n", s.Retention.Minute, s.Retention.Hour, s.Retention.Day)
fmt.Fprintf(w, "Per device:\t%s\n", map[bool]string{true: "on", false: "off (hc settings set --stats-per-device on)"}[s.PerDevice])
if l := s.Latest; l != nil {
if l.ConntrackMax > 0 {
fmt.Fprintf(w, "Connections now:\t%d of %d\n", l.Conntrack, l.ConntrackMax)
}
fmt.Fprintf(w, "housecarld:\t%s, CPU %.1f%%\n", humanBytes(l.RSS), float64(l.CPUPermille)/10)
fmt.Fprintf(w, "Host:\t%s in use, load %.2f\n", humanBytes(l.MemUsed), l.Load)
}
})
},
}
}
func statsTrafficCmd() *cobra.Command {
var since, step string
var ifaces bool
cmd := &cobra.Command{
Use: "traffic", Short: "Bytes received from and sent to each zone (or interface) over time", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
rx, tx := "zrx", "ztx"
if ifaces {
rx, tx = "rx", "tx"
}
tl, err := get[statTimeline]("/stats/timeline", q("since", since, "step", step, "keys", rx+":*,"+tx+":*"))
if err != nil {
return err
}
return output(tl, func(w io.Writer) { printTimeline(w, tl, rx, tx) })
},
}
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d…")
cmd.Flags().StringVar(&step, "step", "auto", "per 1m, 5m, 15m, 1h, 6h or 1d")
cmd.Flags().BoolVar(&ifaces, "interfaces", false, "per interface instead of per zone")
return cmd
}
// printTimeline writes a row per bucket: each name's received and sent.
func printTimeline(w io.Writer, tl statTimeline, rx, tx string) {
var names []string
vals := map[string][]int64{}
for _, s := range tl.Series {
vals[s.Key] = s.Values
if n, ok := strings.CutPrefix(s.Key, rx+":"); ok {
names = append(names, n)
}
}
for _, s := range tl.Series {
if n, ok := strings.CutPrefix(s.Key, tx+":"); ok && vals[rx+":"+n] == nil {
names = append(names, n)
}
}
if len(names) == 0 {
fmt.Fprintln(w, "no traffic recorded yet")
return
}
fmt.Fprint(w, "TIME")
for _, n := range names {
fmt.Fprintf(w, "\t%s ↓\t%s ↑", n, n)
}
fmt.Fprintln(w)
layout := "01-02 15:04"
if tl.Step == "1d" {
layout = "2006-01-02"
}
at := func(k string, i int) int64 {
if v := vals[k]; i < len(v) {
return v[i]
}
return 0
}
for i, t := range tl.Times {
fmt.Fprint(w, time.Unix(t, 0).Local().Format(layout))
for _, n := range names {
fmt.Fprintf(w, "\t%s\t%s", humanBytes(at(rx+":"+n, i)), humanBytes(at(tx+":"+n, i)))
}
fmt.Fprintln(w)
}
}
func statsTopCmd() *cobra.Command {
var since string
var limit int
cmd := &cobra.Command{
Use: "top devices|blocked|tried|quota RULE-ID", Short: "The largest: devices by traffic, sources blocked, what they tried, a quota's users",
Long: `Top lists over the last while:
devices inside addresses by traffic to and from external zones (needs
hc settings set --stats-per-device on)
blocked sources of logged drops (the policies' drops with 'log drops'
on, and rules that log)
tried what those drops tried: tcp/22, udp/53, icmp/echo-request
quota ID who used a per-address quota of rule ID`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
get1 := func(kind string) ([]statEntry, error) {
return get[[]statEntry]("/stats/top", q("kind", kind, "since", since, "limit", strconv.Itoa(limit)))
}
switch args[0] {
case "devices":
up, err := get1("device_up")
if err != nil {
return err
}
down, err := get1("device_down")
if err != nil {
return err
}
out := map[string][2]int64{}
var order []string
for _, l := range [][]statEntry{down, up} {
for _, e := range l {
if _, ok := out[e.Key]; !ok {
order = append(order, e.Key)
}
}
}
for _, e := range down {
v := out[e.Key]
v[0] = e.Count
out[e.Key] = v
}
for _, e := range up {
v := out[e.Key]
v[1] = e.Count
out[e.Key] = v
}
return output(map[string]any{"up": up, "down": down}, func(w io.Writer) {
fmt.Fprintln(w, "DEVICE\tRECEIVED\tSENT")
for _, k := range order {
fmt.Fprintf(w, "%s\t%s\t%s\n", k, humanBytes(out[k][0]), humanBytes(out[k][1]))
}
})
case "blocked", "tried":
kind := map[string]string{"blocked": "blocked_src", "tried": "blocked_port"}[args[0]]
es, err := get1(kind)
if err != nil {
return err
}
return output(es, func(w io.Writer) {
fmt.Fprintln(w, map[string]string{"blocked": "SOURCE", "tried": "WHAT"}[args[0]]+"\tPACKETS")
for _, e := range es {
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
}
})
case "quota":
if len(args) != 2 {
return fmt.Errorf("give the rule's ID: hc stats top quota 12")
}
es, err := get1("quota_r" + args[1])
if err != nil {
return err
}
return output(es, func(w io.Writer) {
fmt.Fprintln(w, "ADDRESS\tUSED")
for _, e := range es {
fmt.Fprintf(w, "%s\t%s\n", e.Key, humanBytes(e.Count))
}
})
}
return fmt.Errorf("unknown list %q: devices, blocked, tried or quota", args[0])
},
}
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d…")
cmd.Flags().IntVar(&limit, "limit", 20, "at most this many")
return cmd
}
func statsRulesCmd() *cobra.Command {
var since string
var unused bool
cmd := &cobra.Command{
Use: "rules", Short: "Packets each rule, built-in and policy counted; --unused: rules that matched nothing", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
es, err := get[[]statEntry]("/stats/hits", q("since", since))
if err != nil {
return err
}
if unused {
var keep []statEntry
for _, e := range es {
if e.Count == 0 && strings.HasPrefix(e.Key, "r") {
keep = append(keep, e)
}
}
es = keep
}
return output(es, func(w io.Writer) {
if unused && len(es) == 0 {
fmt.Fprintf(w, "every rule matched something in the last %s\n", since)
return
}
fmt.Fprintln(w, "WHAT\tPACKETS")
for _, e := range es {
fmt.Fprintf(w, "%s\t%d\n", orDash(e.Name), e.Count)
}
})
},
}
cmd.Flags().StringVar(&since, "since", "30d", "the last 24h, 7d, 30d…")
cmd.Flags().BoolVar(&unused, "unused", false, "only the rules that matched nothing")
return cmd
}
func statsConnectionsCmd() *cobra.Command {
var since, step string
cmd := &cobra.Command{
Use: "connections", Short: "How many connections the firewall tracked over time, against the limit", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
tl, err := get[statTimeline]("/stats/timeline", q("since", since, "step", step, "keys", "g:conntrack,g:conntrack_max"))
if err != nil {
return err
}
return output(tl, func(w io.Writer) {
fmt.Fprintln(w, "TIME\tCONNECTIONS\tLIMIT")
for i, t := range tl.Times {
n, m := tl.Series[0].Values[i], tl.Series[1].Values[i]
if n < 0 {
continue // no sample
}
fmt.Fprintf(w, "%s\t%d\t%d\n", time.Unix(t, 0).Local().Format("01-02 15:04"), n, m)
}
})
},
}
cmd.Flags().StringVar(&since, "since", "24h", "the last 90m, 24h, 7d…")
cmd.Flags().StringVar(&step, "step", "auto", "per 1m, 5m, 15m, 1h, 6h or 1d")
return cmd
}
+75
View File
@@ -206,3 +206,78 @@ func printPacket(w io.Writer, p api.TracePacket) {
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided) 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)
}
+54
View File
@@ -30,11 +30,13 @@ import (
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/hostif" "housecarl/internal/hostif"
"housecarl/internal/jarl" "housecarl/internal/jarl"
"housecarl/internal/linkwatch"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/nflog" "housecarl/internal/nflog"
"housecarl/internal/nft" "housecarl/internal/nft"
"housecarl/internal/render" "housecarl/internal/render"
"housecarl/internal/server" "housecarl/internal/server"
"housecarl/internal/stats"
"housecarl/internal/store" "housecarl/internal/store"
"housecarl/internal/trace" "housecarl/internal/trace"
"housecarl/internal/validate" "housecarl/internal/validate"
@@ -195,9 +197,28 @@ func serveCmd() *cobra.Command {
a.log.Error("saving the ruleset for the next boot failed", "err", err) a.log.Error("saving the ruleset for the next boot failed", "err", err)
} }
} }
// A flowtable device that appears later (ppp0 once PPPoE is up,
// or again after it reconnects) is added by applying again.
go func() {
err := linkwatch.Watch(ctx, func() {
host.Forget()
if !fw.FlowtableStale(eng.State()) {
return
}
if err := eng.ApplyCurrent(ctx); err != nil {
a.log.Error("applying the ruleset again for the flowtable's devices failed", "err", err)
return
}
a.log.Info("interfaces changed: applied the ruleset again, with the flowtable's devices that exist now")
})
if err != nil {
a.log.Warn("not watching the interfaces: a flowtable device that appears later is only added by the next change", "err", err)
}
}()
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket) go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
go srv.RunQuotas(ctx) go srv.RunQuotas(ctx)
srv.SetConntrack(conntrack.Client{}) srv.SetConntrack(conntrack.Client{})
statsDone := startStats(ctx, a, fw, srv)
// A trace left behind by a crash. // A trace left behind by a crash.
fw.DeleteTable(ctx, "inet", trace.Table) fw.DeleteTable(ctx, "inet", trace.Table)
hup := make(chan os.Signal, 1) hup := make(chan os.Signal, 1)
@@ -212,12 +233,45 @@ func serveCmd() *cobra.Command {
err = srv.Serve(ctx, func() { sdNotify("READY=1") }) err = srv.Serve(ctx, func() { sdNotify("READY=1") })
sdNotify("STOPPING=1") sdNotify("STOPPING=1")
srv.SaveQuotaUsage(context.Background()) srv.SaveQuotaUsage(context.Background())
<-statsDone
a.log.Info("stopped") a.log.Info("stopped")
return err return err
}, },
} }
} }
// startStats opens the statistics database and runs the collector until
// ctx ends; the channel closes once it wrote its last figures.
func startStats(ctx context.Context, a *app, fw *nft.Firewall, srv *server.Server) <-chan struct{} {
done := make(chan struct{})
c := a.cfg.Stats
if !c.Enabled {
close(done)
return done
}
st, err := stats.OpenStore(c.Database)
if err != nil {
a.log.Error("statistics are off: opening their database failed (deleting it is safe)", "err", err)
close(done)
return done
}
opts := stats.Options{TopN: c.TopN, RetainMinute: time.Duration(c.Retention.Minute), RetainHour: time.Duration(c.Retention.Hour), RetainDay: time.Duration(c.Retention.Day)}
// Tests sample more often than every minute.
if v := os.Getenv("HOUSECARL_STATS_INTERVAL"); v != "" {
opts.Interval, _ = time.ParseDuration(v)
}
src := stats.Sources{Table: fw.Table}
srv.StatsSources(&src)
col := stats.NewCollector(st, opts, src, a.log)
srv.SetStats(col)
go func() {
defer close(done)
defer st.Close()
col.Run(ctx)
}()
return done
}
func sdNotify(state string) { func sdNotify(state string) {
sock := os.Getenv("NOTIFY_SOCKET") sock := os.Getenv("NOTIFY_SOCKET")
if sock == "" { if sock == "" {
+6 -2
View File
@@ -1,5 +1,8 @@
# Housecarl: loads the last confirmed ruleset at boot, before the network # Housecarl: loads the last confirmed ruleset at boot, before the network
# comes up and whether or not housecarld starts. # comes up and whether or not housecarld starts. If it fails to load, the
# fallback housecarld saved beside it is loaded instead: nothing new gets
# in from external zones (docs/operations.md), and the unit is marked
# failed, until housecarld starts and puts the real ruleset back.
# Installed as /etc/systemd/system/housecarl-ruleset.service. # Installed as /etc/systemd/system/housecarl-ruleset.service.
[Unit] [Unit]
Description=Housecarl firewall ruleset Description=Housecarl firewall ruleset
@@ -15,7 +18,8 @@ ConditionPathExists=/var/lib/housecarl/ruleset.nft
[Service] [Service]
Type=oneshot Type=oneshot
RemainAfterExit=yes RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /var/lib/housecarl/ruleset.nft # "<2>": logged as critical.
ExecStart=/bin/sh -c '/usr/sbin/nft -f /var/lib/housecarl/ruleset.nft && exit 0; echo "<2>the saved ruleset failed to load: loading the fallback (nothing new gets in from external zones) until housecarld puts the real one back" >&2; /usr/sbin/nft -f /var/lib/housecarl/ruleset-fallback.nft; exit 1'
[Install] [Install]
WantedBy=sysinit.target WantedBy=sysinit.target
+10 -2
View File
@@ -74,9 +74,10 @@ Creating answers 201, other changes 200.
| | | | | |
|---|---| |---|---|
| `GET /healthz` | `{"ok": true, "version"}`, without authentication | | `GET /healthz` | `ok`, `version`, `firewall` (`applied`, `unconfigured`, `fallback`, `missing`), `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`, `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`, `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 /ruleset` | the nftables ruleset Housecarl builds, as text | | `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 /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` |
@@ -98,7 +99,8 @@ Creating answers 201, other changes 200.
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true, "builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false}, "ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."], "own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false} "flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false,
"stats_per_device": false}
``` ```
Lists replace the old ones. `probation_seconds` 0 is the default (120). Lists replace the old ones. `probation_seconds` 0 is the default (120).
@@ -144,6 +146,12 @@ A source NAT rule is a match (as a rule's, without the rule's other
fields) with `"to"` (empty: masquerade), a redirect a match with fields) with `"to"` (empty: masquerade), a redirect a match with
`"to_port"`. Times are seconds (`timeout`). `"to_port"`. Times are seconds (`timeout`).
### Statistics
`GET /stats/status`, `/stats/summary`, `/stats/timeline`, `/stats/top`,
`/stats/hits` and `/stats/heatmap` (`log:read`): see
[stats.md](stats.md#the-api).
### Changesets ### Changesets
Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)). Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)).
+4 -2
View File
@@ -138,6 +138,8 @@ by name.
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets | | `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)) | | `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)) | | `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) |
| `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)) |
| `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
@@ -146,7 +148,7 @@ by name.
|---|---| |---|---|
| `zone list`, `zone show ZONE` | | | `zone list`, `zone show ZONE` | |
| `zone add NAME -i IFACE... [--external] [--comment C]` | | | `zone add NAME -i IFACE... [--external] [--comment C]` | |
| `zone update ZONE [-i IFACE]... [--add IFACE] [--remove IFACE] [--external[=false]] [--name N] [--comment C]` | `-i` replaces the interfaces | | `zone update ZONE [-i IFACE]... [--add IFACE] [--remove IFACE] [--external[=false]] [--name N] [--comment C]` | `-i` replaces the interfaces; `--name` renames it, and the rules naming it follow |
| `zone delete ZONE` | refused while rules use it | | `zone delete ZONE` | refused while rules use it |
| `set list`, `set show SET` | | | `set list`, `set show SET` | |
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | | | `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
@@ -206,7 +208,7 @@ See [nat.md](nat.md).
| Command | | | Command | |
|---|---| |---|---|
| `settings show` | | | `settings show` | |
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m] [--auto-confirm on\|off]` | lists given replace the old ones | | `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m] [--auto-confirm on\|off] [--stats-per-device on\|off]` | lists given replace the old ones; `none` empties one (`--flowtable none`: off) |
### probation, history, import ### probation, history, import
+7 -2
View File
@@ -14,7 +14,7 @@ reads the file when it starts.
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` | | `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` |
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) | | `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge, and its token ([jarl.md](jarl.md)) | | `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge, and its token ([jarl.md](jarl.md)) |
| `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot | | `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot; its fallback is saved beside it as `ruleset-fallback.nft` ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)). Change both paths in the unit too |
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed | | `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
| `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off | | `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off |
| `database` | `/var/lib/housecarl/housecarl.db` | the SQLite database: configuration, history, users, audit log | | `database` | `/var/lib/housecarl/housecarl.db` | the SQLite database: configuration, history, users, audit log |
@@ -26,11 +26,16 @@ reads the file when it starts.
| `backup.dir` | `/var/backups/housecarl` | where `housecarld backup` writes | | `backup.dir` | `/var/backups/housecarl` | where `housecarld backup` writes |
| `backup.retention` | `30d` | backups older than this are deleted… | | `backup.retention` | `30d` | backups older than this are deleted… |
| `backup.keep_at_least` | `7` | …keeping at least this many | | `backup.keep_at_least` | `7` | …keeping at least this many |
| `stats.enabled` | `true` | keep traffic, drops, hits, blocked sources, connections and quotas over time ([stats.md](stats.md)) |
| `stats.database` | `/var/lib/housecarl/stats.db` | the statistics' own database; deleting it is safe |
| `stats.retention.minute`, `.hour`, `.day` | `7d`, `90d`, `730d` | how long per-minute, hourly and daily figures are kept |
| `stats.top_n` | `100` | entries kept per top list, per hour and per day |
Durations are written `15s`, `12h` or `30d`. Durations are written `15s`, `12h` or `30d`.
The probation's length is a firewall setting, not here: `hc settings set The probation's length is a firewall setting, not here: `hc settings set
--probation 5m`. --probation 5m`. So is counting traffic per device for the statistics:
`hc settings set --stats-per-device on`.
## HTTPS ## HTTPS
+13
View File
@@ -139,6 +139,19 @@ firewall is up even if housecarld can't start. An unconfirmed ruleset is
never saved there: a reboot during probation brings back the last never saved there: a reboot during probation brings back the last
confirmed one. confirmed one.
It fails closed. A fallback saved beside it (`ruleset-fallback.nft`,
`render.Fallback`) is loaded if it doesn't: no sets, no flowtable,
interfaces by name only, so there is little in it to fail. It keeps
anything new from the external zones out and lets the inside work,
which is as far as a ruleset can go without knowing the rules; a
fallback that let everything in would leave a router open on the
internet, and one that let nothing in would lock out the person who has
to fix it. The boot ruleset has no flowtable either: nftables refuses a
flowtable naming a device that doesn't exist yet (on Debian 13's
kernel), and before the network is up, `ppp0` doesn't. housecarld
watches the kernel's link messages (internal/linkwatch) and applies
again when a flowtable device appears, or comes back as a new interface.
## The drop log ## The drop log
Rules with logging on, and the policies (Settings), send packets to Rules with logging on, and the policies (Settings), send packets to
+181
View File
@@ -0,0 +1,181 @@
# The flowtable: a fast path for forwarded traffic
The flowtable is a shortcut inside the kernel for traffic the firewall
**forwards** (between the internet and your LAN, between zones). It
speeds up routing on a machine whose CPU can't keep up with its line.
It doesn't make the firewall any safer or less safe, but it changes what
some features can see, so read [when not to use it](#when-not-to-use-it)
before you turn it on.
It is off until you name its devices.
## What it does
Without a flowtable, every forwarded packet takes the whole way through
the kernel: the prerouting hooks, the routing decision, Housecarl's
forward chain (the built-ins, then your rules) and postrouting with NAT.
For an established connection almost all of that work only ends in the
`established` built-in accepting it again, packet after packet.
With a flowtable, Housecarl adds one built-in to the forward chain:
```
meta l4proto { tcp, udp } flow add @ft
```
A TCP or UDP connection takes the whole way for its first packets, as
always: your rules decide whether it is allowed at all. Once the kernel
has seen it established (and your rules let it through), it is added
to the flowtable. From then on its packets are caught as soon as they
arrive on one of the flowtable's devices (the *ingress* hook) and sent
straight out on the other side: NAT is still applied, the TTL still
counts down, but every chain in between is skipped.
```
first packets every packet after
in ─► prerouting ─► routing ─► forward ─► postrouting ─► out
│ ▲
└────────────── flowtable (ingress) ─────────────────────┘
```
It is the software flowtable, run by the CPU. Offloading to the network
card's hardware isn't supported ([design.md](design.md#not-in-01)).
## When to use it
Turn it on when **the router's CPU is what limits your speed**:
* a speed test through the router gets less than your line gives (or
less than the same test from the router itself), and
* during it, one CPU core is busy with network work: in `top`, press `1`
for every core, and look at `si` (softirq) near 100 % on a core.
Typical cases: a small or old machine (Atom, Celeron, a Raspberry Pi, an
old Core 2) on a fast line (500 Mbit/s and up), lots of small packets
(gaming, VoIP, many downloads at once), or a machine that is busy with
other work besides routing. The gain is CPU time per packet: often well
over half of the forwarding cost, which shows up as speed only when the
CPU was the limit, and otherwise as a cooler, quieter, more responsive
machine.
## When not to use it
**When the CPU keeps up anyway.** If the speed test already reaches your
line and `si` stays low, the flowtable buys you nothing you'd notice,
and costs you the figures below. Most modern x86 machines route a
gigabit line without it.
**When you count or limit traffic in the forward chain.** The fast path
skips the forward chain, so whatever must see *every* packet of a
connection only sees the first few. Housecarl warns about each:
| Feature | With the flowtable | Warning |
|---|---|---|
| quotas and byte-rate limits on forward rules | only count first packets: a device can go far past its allowance | `flowtable_traffic` |
| forward rules with "every packet" (`--every-packet`) | only see first packets | `flowtable_traffic` |
| statistics' traffic per device (`stats_per_device`) | only count first packets | `stats_flowtable` |
| hit counters of the `established` built-in (forward) | stop rising once a connection is offloaded | |
Rate limits by packets (`--limit 10/second`) and connection limits act
on new connections, so they work the same.
**When other tables must see forwarded traffic.** Offloaded packets skip
the forward hooks of *every* table, not only Housecarl's: Docker's,
Incus', libvirt's or a table of your own. Those normally only judge new
connections, which still pass through them, but a table that counts or
shapes established traffic in its forward chain won't see it.
What keeps working as before:
* **Your rules** decide every new connection: nothing gets offloaded
that your rules didn't let through. A rule you add later doesn't stop
a connection already open, but that is true without the flowtable too
(the `established` built-in accepts it).
* **NAT**: port forwards, hairpin NAT, masquerading and SNAT.
* **The input and output chains**: traffic to and from the firewall
itself never uses the flowtable.
* **Closing connections** (`hc connections close`, Traffic →
Connections) ends their fast path too: the kernel drops a flow with
its tracking entry.
* **Interface statistics** (traffic per zone and interface) come from the
interfaces' own counters, which count every packet.
* **Traffic shaping with `tc`** on the outgoing interface still applies.
* **The log** and **the packet tracer** see what goes through the rules:
new connections, as always. The tracer doesn't see the packets of an
offloaded connection; trace a new one.
* ICMP, and protocols other than TCP and UDP (GRE, ESP, SCTP), always
take the normal way.
## How to turn it on
Name the devices whose traffic should take the fast path, **both
sides**: the interface connections come in on and the one their replies
come in on. For a router that means the external interface and the
inside ones:
```sh
hc settings set --flowtable wan --flowtable enp2s0
hc settings # Flowtable: wan, enp2s0
```
In the web UI: Settings → Flowtable, add each device. The fast path
only catches packets arriving on a listed device, so a connection to or
from an unlisted one gains little or nothing: leave a device out only on
purpose (say, a WireGuard interface whose traffic you'd rather keep on
the normal way).
* Use **exact device names**, not wildcards (`incusbr*` is refused):
the kernel attaches the flowtable to each device by name.
* With **PPPoE**, name the PPP interface (`ppp0`, or the name your
`ppp@` unit gives it), the one in your external zone.
* `--flowtable` replaces the whole list; `hc settings set --flowtable
none` turns the flowtable off.
The change is applied (and on probation, if made over the network) like
any other.
### Devices that come and go
PPP and WireGuard interfaces don't exist until their service brings
them up, and PPPoE makes a new `ppp0` each time it reconnects. Housecarl
handles that:
* A device that doesn't exist yet is left out of the flowtable, with the
warning `flowtable_device_missing`, instead of failing the whole
ruleset.
* housecarld watches the kernel's interface changes: when a flowtable
device appears, or comes back as a new interface after a reconnect,
it applies the ruleset again with it, a couple of seconds later. The
journal says `interfaces changed: applied the ruleset again`.
Applying again keeps the counters, dynamic sets and quotas, as any
change does, and adds no history entry.
* The ruleset saved for boot has no flowtable at all: it loads before
the network is up, when `ppp0` and `wg0` can't exist yet, and
nftables on Debian 13's kernel refuses a flowtable naming a device
that doesn't exist (and with it the whole file). housecarld adds the
flowtable once it starts. Until then, traffic takes the normal way,
through the same rules.
## Checking it works
```sh
sudo nft list flowtables # the devices it is attached to
sudo conntrack -L | grep OFFLOAD # connections on the fast path
```
`conntrack` is in Debian's `conntrack` package. A long download through
the router should show up as `[OFFLOAD]`. Compare a speed test, and `si`
in `top`, before and after.
If nothing gets offloaded, check that both of the connection's devices
are listed, that `flowtable_device_missing` isn't in `hc status`, and
that the connection is TCP or UDP and established.
## Turning it off
```sh
hc settings set --flowtable none
```
Connections on the fast path go back to the normal way with their next
packet.
+5 -2
View File
@@ -50,8 +50,11 @@ Import a ruleset.
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
rule, antispoofing) becomes that built-in only when it sits where the rule, antispoofing) becomes that built-in only when it sits where the
built-in goes, at the head of every chain it covers. Otherwise it stays built-in goes, at the head of every chain it covers. Otherwise it stays
a rule where it was, so the ruleset does what it did. Built-ins your a rule where it was, so the ruleset does what it did. Since built-ins
ruleset doesn't have are turned off. come before every rule, once one at a chain's head stays a rule, the
ones after it there stay rules too, in order: an invalid drop that
stays a rule keeps the ICMPv6 accepts after it behind it, as they were.
Built-ins your ruleset doesn't have are turned off.
* **DNAT** rules become port forwards, TCP and UDP of the same port * **DNAT** rules become port forwards, TCP and UDP of the same port
merged into one. The forward-chain accepts that let them through merged into one. The forward-chain accepts that let them through
become part of the port forward, and a masquerade for inside sources become part of the port forward, and a masquerade for inside sources
+75 -2
View File
@@ -71,14 +71,63 @@ sudo hc history undo 42
A confirmed ruleset (or one changed through the socket) is saved to A confirmed ruleset (or one changed through the socket) is saved to
`/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service` `/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service`
loads before the network comes up. To see what would load: loads before the network comes up, so before PPPoE, WireGuard or DHCP
bring up their interfaces. To see what would load:
```sh ```sh
sudo cat /var/lib/housecarl/ruleset.nft sudo cat /var/lib/housecarl/ruleset.nft
sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
``` ```
Dynamic sets start empty after a reboot. Dynamic sets start empty after a reboot. The saved ruleset has no
flowtable, since its devices may not exist yet: housecarld adds it when
it starts, and whenever a flowtable device appears
([flowtable.md](flowtable.md#devices-that-come-and-go)).
### If the ruleset fails to load at boot
Should `ruleset.nft` fail to load (a damaged file, an nftables or kernel
upgrade that refuses something in it), the machine would otherwise boot
with no firewall at all until housecarld starts. Instead,
`housecarl-ruleset.service` loads a **fallback** that housecarld saves
beside it, `/var/lib/housecarl/ruleset-fallback.nft`, from the same
confirmed configuration:
* **Nothing new gets in from the external zones' interfaces**, to the
firewall or through it: no port forwards, no SSH or web UI from the
internet.
* What the firewall and the inside need to keep working is let in:
established connections, the ICMPv6 IPv6 needs (neighbour discovery,
router advertisements, packet too big), and DHCPv6 replies from
link-local addresses.
* **Everything else is let through**: the LAN, WireGuard and the
firewall itself are reachable from the inside, so you can log in and
fix it.
* IPv4 leaving an external zone is masqueraded (when any source NAT is
configured), so the inside still reaches the internet. MSS clamping
stays, if it is on.
PPPoE itself isn't affected: its discovery and session are Ethernet
frames, and the PPP link's negotiation happens before any IP, neither
of which an `inet` table sees.
The fallback only knows which interfaces face the internet from the
zones marked **external**. With none, it lets nothing but loopback
through, and the warning `no_external_zone` says so.
When the fallback loads, the unit is marked failed, and the journal says
why (as a critical message):
```sh
systemctl status housecarl-ruleset
journalctl -b -u housecarl-ruleset
```
housecarld puts the real ruleset back when it starts, and saves a fresh
`ruleset.nft`. If it can't apply it either, the fallback stays in force:
`hc status` and the dashboard say "NOT in force: the boot fallback is in
force", and `/healthz` answers 503 ([Monitoring](#monitoring)). Fix
what `hc status` or the journal reports, then restart housecarld.
Keep Debian's `nftables.service` disabled (`systemctl disable Keep Debian's `nftables.service` disabled (`systemctl disable
nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
@@ -224,6 +273,30 @@ database is kept next to it as `housecarl.db.before-restore-<time>`.
database when it starts, and an older one refuses a database a newer one database when it starts, and an older one refuses a database a newer one
has upgraded: restore a backup to go back. has upgraded: restore a backup to go back.
## Monitoring
`GET /healthz` needs no login, and answers within a few seconds of any
change (it reads the ruleset at most every five seconds, however often
it is asked):
```sh
$ curl -s https://firewall.example.net:8070/healthz
{"ok":true,"version":"0.1.0","firewall":"applied","probation":0,"warnings":[]}
```
| Field | |
|---|---|
| `ok` | `true` with HTTP 200 when the firewall is as it should be; `false` with HTTP **503** otherwise |
| `firewall` | `applied` (Housecarl's ruleset is in force), `unconfigured` (nothing configured yet, so nothing to apply), `fallback` (the [boot fallback](#if-the-ruleset-fails-to-load-at-boot) is in force), `missing` (no Housecarl table: flushed, or never applied) |
| `probation` | changes waiting to be confirmed, rolled back unless they are |
| `warnings` | codes of problems with the machine, as `hc status` shows them (`nftables_service`) |
For Checkmk, an HTTP check on the URL that expects status 200 (and,
if you like, the string `"firewall":"applied"`) covers housecarld being
down, the fallback and a missing ruleset. Reach it from where the input
rules let the monitor in (a rule for the Checkmk server's address and
port 8070).
## Checks ## Checks
`housecarld check` validates the configuration in the database against `housecarld check` validates the configuration in the database against
+73 -3
View File
@@ -21,6 +21,32 @@ A zone may name an interface that doesn't exist yet (a WireGuard
interface that comes up later): its rules match once it appears, and interface that comes up later): its rules match once it appears, and
`hc status` warns until then. `hc status` warns until then.
**VLANs.** A VLAN sub-interface is an interface like any other, by its
name: `enp2s0.20` (or whatever your network configuration calls it,
`vlan20`, `br0.20`). Tagged traffic arrives on the sub-interface, and
untagged traffic on the parent, so `enp2s0` alone doesn't cover
`enp2s0.20`. Give each VLAN its own zone to keep them apart:
```sh
hc zone add lan --interface enp2s0 # untagged
hc zone add iot --interface enp2s0.20
hc zone add guest --interface enp2s0.30
hc rule add forward --in iot --out wan
```
or one zone for the parent and all of its VLANs, when they should be
treated alike: `--interface enp2s0 --interface 'enp2s0.*'`. Interface
names are at most 15 characters (the kernel's limit). The flowtable
takes VLAN sub-interfaces too, by exact name
([flowtable.md](flowtable.md)).
**Renaming** a zone (`hc zone update dhcp --name lan`, or Edit on the
Zones page) changes every rule and NAT rule that names it, in the same
change: the history shows them together, and undoing it puts them all
back. Sets and services rename the same way, except dynamic sets: their
entries, and the API tokens and programs that fill them, go by their
name.
## Address sets ## Address sets
A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges, A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
@@ -183,7 +209,8 @@ first, marked "every packet"). So they can drop, reject or log, but not
accept: an accept there would let packets past every later rule. Let the accept: an accept there would let packets past every later rule. Let the
traffic in with a rule of its own. The flowtable's fast path skips the traffic in with a rule of its own. The flowtable's fast path skips the
forward chain for established connections, so with it on, forward forward chain for established connections, so with it on, forward
quotas and byte rates only count first packets (a warning says so). quotas and byte rates only count first packets (a warning says so;
[flowtable.md](flowtable.md)).
**Per-address limits group addresses**: each IPv4 address on its own, and **Per-address limits group addresses**: each IPv4 address on its own, and
IPv6 addresses by /64, since one device uses several (privacy addresses IPv6 addresses by /64, since one device uses several (privacy addresses
@@ -232,8 +259,51 @@ them. The antispoof lists can be changed with `--antispoof-v4` and
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
TCP and UDP connections forwarded between those devices skip the rules, TCP and UDP connections forwarded between those devices skip the rules,
which helps on a slow CPU. It needs exact device names. Counters of which helps when the CPU can't keep up with the line. It needs exact
forward rules then only count each connection's first packets. device names, and costs quotas, byte rates and traffic per device in the
forward chain their accuracy: see [flowtable.md](flowtable.md) for when
to use it, and when not.
## 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 ## The ruleset
+140
View File
@@ -0,0 +1,140 @@
# Statistics
Housecarl keeps what the firewall did over time:
- traffic per interface and zone, received and sent;
- packets dropped, by chain, and when drops happen (a weekday × hour
heatmap);
- hits per rule, built-in and policy, and the rules that matched nothing;
- the sources blocked most and what they tried (from the log);
- traffic per inside device (a setting, below);
- connections tracked, against the kernel's limit;
- what each quota counted;
- housecarld's and the machine's memory, CPU and load.
All of it is in the web UI (**Traffic → Statistics**, and two charts on
the dashboard), the CLI (`hc stats`) and the API (`/api/v1/stats/...`),
for anyone with `log:read`.
Statistics are **on by default**. They cost nothing on the packets'
path: housecarld reads counters the kernel keeps anyway, once a minute.
Only traffic per device adds to the ruleset, so it is a setting you turn
on. The figures are kept as aggregates in a database of their own,
`/var/lib/housecarl/stats.db`, separate from the configuration. Deleting
it is always safe (stop housecarl first), and backups leave it out.
## Settings
In `/etc/housecarl/housecarl.yaml` (restart housecarl after a change):
```yaml
stats:
enabled: true
database: /var/lib/housecarl/stats.db
retention:
minute: 7d # per-minute figures
hour: 90d # hourly figures and top lists
day: 730d # daily figures and top lists
top_n: 100 # entries kept per top list, per hour and day
```
Old data is removed once an hour. A range picks the finest data that
covers it: per minute up to 2 days, hourly up to 90 days, then daily.
## Traffic per device
```sh
hc settings set --stats-per-device on
```
(or **Settings → Statistics** in the web UI). This is a firewall setting,
so it goes through the usual review and probation. Housecarl then adds
four sets to its table, and four rules at the top of the forward chain
(shown as the built-in `stats`):
```
iifname != "wan" oifname "wan" update @stats_up_v4 { ip saddr }
iifname != "wan" oifname "wan" update @stats_up_v6 { ip6 saddr and ffff:ffff:ffff:ffff:: }
iifname "wan" oifname != "wan" update @stats_down_v4 { ip daddr }
iifname "wan" oifname != "wan" update @stats_down_v6 { ip6 daddr and ffff:ffff:ffff:ffff:: }
```
Each inside address talking to an external zone gets a counter: what it
sent out, and what came back to it. IPv6 addresses are counted per /64,
since one device uses many addresses. Counting is done in the kernel, a
set lookup per forwarded packet. An address quiet for an hour drops out
of the sets, and a reload keeps the counts.
With the flowtable on, established connections skip the forward chain,
so only their first packets are counted (warning `stats_flowtable`). Set
names starting with `stats_` (and `meter_`, `conn_`, `quota_`) are
Housecarl's own and can't be used for your sets.
## What is collected
Every minute housecarld samples its sources and stores what changed:
| Figure | Source |
|---|---|
| `rx:IF`, `tx:IF` (bytes), `rxp:IF`, `txp:IF` (packets) | the interfaces' counters (`/sys/class/net`); the loopback is left out |
| `zrx:ZONE`, `ztx:ZONE` | the same, summed per zone: received from and sent to the zone's interfaces |
| `hits:MARK`, `hitb:MARK` | each rule's (`r12`), built-in's (`b-established`), port forward's (`f3`) and policy's (`p-input`) counter, in packets and bytes |
| `drop:CHAIN`, `drops` | the packets counted by what drops them: rules that drop or reject, the drop policies, antispoofing (`early`) and invalid packets (`invalid`) |
| `quota:r12` | bytes counted against a rule's quota |
| `g:conntrack`, `g:conntrack_max` | connections tracked, and the limit (`nf_conntrack_max`) |
| `g:rss`, `g:cpu` | housecarld's memory (bytes) and CPU (tenths of a percent of one CPU) |
| `g:mem`, `g:load` | the host's memory in use, and its 1-minute load average × 100 |
`g:` figures are gauges: the API averages them over a bucket.
Top lists, per hour and per day, the `top_n` largest:
| List | |
|---|---|
| `blocked_src` | sources of logged drops |
| `blocked_port` | what they tried: `tcp/22`, `udp/53`, `icmp/echo-request` |
| `device_up`, `device_down` | bytes per inside address, sent and received (traffic per device) |
| `quota_r12` | a per-address quota's use, per address |
The blocked lists only see what is **logged**: the input and forward
policies' drops with `hc settings set --log-drops on`, and rules with
logging. The drop counts above see everything.
A counter that goes down (an interface that came back, a quota reset)
counts again from zero. What happens while housecarld is stopped isn't
counted.
## The CLI
```sh
hc stats --since 7d # summary: traffic per zone, drops, busiest rules, top lists
hc stats status # collecting? since when, the database, figures now
hc stats traffic --since 24h # per zone over time (--interfaces: per interface)
hc stats top devices --since 30d # devices by traffic (blocked, tried, quota RULE-ID)
hc stats rules --unused # rules that matched nothing in 30 days
hc stats connections --since 24h
```
Ranges are written `90m`, `24h`, `7d` or `4w`. `-o json` gives the API's
answer.
## The API
| Endpoint | |
|---|---|
| `GET /stats/status` | `enabled`, `database`, `size`, `first_data`, `last_sample`, `sample_error`, `per_device`, `retention`, `latest` (the newest gauges) |
| `GET /stats/summary?since=24h&limit=10` | `zones` and `interfaces` (`name`, `received`, `sent`), `drops`, `drops_by_chain`, `rules` (busiest, named), `peak_connections`, `conntrack_max`, `blocked_sources`, `blocked_ports`, `devices_up`, `devices_down` |
| `GET /stats/timeline?keys=zrx:*,drops&since=24h&step=auto` | `step`, `times` (bucket starts), `series` (`key`, `values`); `GROUP:*` means every key of a group; `step` is `auto`, `1m`, `5m`, `15m`, `1h`, `6h` or `1d`; a gauge without a sample is -1 |
| `GET /stats/top?kind=device_down&since=7d&limit=20` | a top list (`key`, `count`) |
| `GET /stats/hits?since=30d` | packets per rule, built-in and policy (`key`, `count`, `name`), with the enabled rules that counted none |
| `GET /stats/heatmap?key=drops&since=28d` | `avg[weekday][hour]` (Monday first, local time; -1 where there is no data) |
With statistics off, `/stats/status` says `enabled: false` and the others
answer 503.
## Warnings
When the connection tracking table is over 80% full, the status shows
the warning `conntrack_full`: once it is full, the kernel drops new
connections. Raise `net.netfilter.nf_conntrack_max`, or find what opens
so many on the Connections page.
+5 -1
View File
@@ -34,6 +34,8 @@ are unique per kind, ignoring case.
| `flowtable_device` | error | a flowtable device with a wildcard: name each device | | `flowtable_device` | error | a flowtable device with a wildcard: name each device |
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does | | `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
| `probation_range` | error | the probation isn't 10 to 3600 seconds | | `probation_range` | error | the probation isn't 10 to 3600 seconds |
| `stats_no_external` | warning | traffic per device is on, but no zone is external: it counts what goes to and comes from external zones |
| `stats_flowtable` | warning | traffic per device with the flowtable on: its fast path skips the forward chain, so only each connection's first packets are counted |
## Zones ## Zones
@@ -43,12 +45,13 @@ are unique per kind, ignoring case.
| `zone_interface` | error | not an interface name (up to 15 characters; a trailing `*` matches many) | | `zone_interface` | error | not an interface name (up to 15 characters; a trailing `*` matches many) |
| `zone_interface_taken` | error | the interface is in another zone already | | `zone_interface_taken` | error | the interface is in another zone already |
| `zone_interface_missing` | warning, *host* | the interface doesn't exist here (yet); its rules match once it does | | `zone_interface_missing` | warning, *host* | the interface doesn't exist here (yet); its rules match once it does |
| `no_external_zone` | warning | no zone is external: antispoofing, MSS clamping, the DHCPv6 client rule and port forwards without public addresses need one | | `no_external_zone` | warning | no zone is external: antispoofing, MSS clamping, the DHCPv6 client rule and port forwards without public addresses need one, and the boot fallback lets nothing but loopback through without one ([operations.md](operations.md#if-the-ruleset-fails-to-load-at-boot)) |
## Sets and services ## Sets and services
| Code | | When | | Code | | When |
|---|---|---| |---|---|---|
| `set_name_reserved` | error | a set name starting with `meter_`, `conn_`, `quota_` or `stats_`: Housecarl's own sets (rate limits, quotas, statistics) use those |
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range | | `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing | | `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
| `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time | | `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time |
@@ -127,4 +130,5 @@ configuration: the dashboard and `hc status` show them.
| Code | | When | | Code | | When |
|---|---|---| |---|---|---|
| `nftables_service` | warning, *host* | Debian's `nftables.service` is enabled: at boot it loads `/etc/nftables.conf`, and stopping or restarting it runs `nft flush ruleset`, deleting Housecarl's table too. Disable it with `systemctl disable nftables` (without `--now`) | | `nftables_service` | warning, *host* | Debian's `nftables.service` is enabled: at boot it loads `/etc/nftables.conf`, and stopping or restarting it runs `nft flush ruleset`, deleting Housecarl's table too. Disable it with `systemctl disable nftables` (without `--now`) |
| `conntrack_full` | warning, *host* | the connection tracking table is over 80% full; once it is full, the kernel drops new connections. Raise `net.netfilter.nf_conntrack_max`, or find what opens so many ([stats.md](stats.md#warnings)) |
| `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right | | `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right |
+35
View File
@@ -422,3 +422,38 @@ type TraceStatus struct {
Packets []TracePacket `json:"packets"` Packets []TracePacket `json:"packets"`
Error string `json:"error,omitempty"` 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"`
}
+35 -1
View File
@@ -39,6 +39,25 @@ type Config struct {
// Ruleset is where the confirmed ruleset is saved for the boot unit // Ruleset is where the confirmed ruleset is saved for the boot unit
// (housecarl-ruleset.service loads it before the network comes up). // (housecarl-ruleset.service loads it before the network comes up).
Ruleset string `yaml:"ruleset"` Ruleset string `yaml:"ruleset"`
// Stats keeps traffic, hits and drops over time (docs/stats.md).
Stats Stats `yaml:"stats"`
}
// Stats configures statistics: a database of their own, aggregates
// only, pruned by age.
type Stats struct {
Enabled bool `yaml:"enabled"`
Database string `yaml:"database"`
Retention StatsRetention `yaml:"retention"`
// TopN is how many entries the top lists keep per hour and day.
TopN int `yaml:"top_n"`
}
// StatsRetention is how long each resolution is kept.
type StatsRetention struct {
Minute Duration `yaml:"minute"`
Hour Duration `yaml:"hour"`
Day Duration `yaml:"day"`
} }
// TLS is the web UI's certificate: from files, or obtained through ACME // TLS is the web UI's certificate: from files, or obtained through ACME
@@ -183,7 +202,9 @@ func Default() *Config {
Log: Log{Level: "info", Format: "text"}, Log: Log{Level: "info", Format: "text"},
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"}, Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7}, Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}}, Stats: Stats{Enabled: true, Database: "/var/lib/housecarl/stats.db", TopN: 100,
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)}},
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
} }
} }
@@ -393,4 +414,17 @@ backup:
dir: /var/backups/housecarl dir: /var/backups/housecarl
retention: 30d retention: 30d
keep_at_least: 7 keep_at_least: 7
# Statistics (docs/stats.md): traffic per interface and zone, hits and
# drops, blocked sources, connections, quotas, kept over time in a database
# of their own (aggregates only). Deleting it is always safe. Traffic per
# device is a firewall setting: hc settings set --stats-per-device on.
stats:
enabled: true
database: /var/lib/housecarl/stats.db
retention:
minute: 7d # per-minute figures
hour: 90d # hourly figures and top lists
day: 730d # daily figures and top lists
top_n: 100 # entries kept per top list, per hour and day
` `
+6 -2
View File
@@ -244,12 +244,16 @@ func newProblems(before, after []validate.Problem) []validate.Problem {
return out return out
} }
// ApplyCurrent puts the current configuration in force (at start). // ApplyCurrent puts the current configuration in force again: at start,
// and when a flowtable device appears. It holds the engine's lock, so a
// change can't be applied in between.
func (e *Engine) ApplyCurrent(ctx context.Context) error { func (e *Engine) ApplyCurrent(ctx context.Context) error {
if e.Firewall == nil { if e.Firewall == nil {
return nil return nil
} }
return e.Firewall.Apply(ctx, e.State()) e.mu.Lock()
defer e.mu.Unlock()
return e.Firewall.Apply(ctx, e.state.Clone())
} }
// Revert undoes a history entry, if the objects it changed are still as // Revert undoes a history entry, if the objects it changed are still as
+8
View File
@@ -63,6 +63,14 @@ func (h *Host) Up(name string) bool {
return h.up[name] return h.up[name]
} }
// Forget drops what was read, so the next question reads the interfaces
// again (after the kernel said one appeared or went).
func (h *Host) Forget() {
h.mu.Lock()
h.at = time.Time{}
h.mu.Unlock()
}
// Exists returns the set of interface names (nil if they can't be read). // Exists returns the set of interface names (nil if they can't be read).
func (h *Host) Exists() map[string]bool { func (h *Host) Exists() map[string]bool {
h.mu.Lock() h.mu.Lock()
+64 -47
View File
@@ -940,8 +940,10 @@ func (im *importer) run() {
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5} order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] }) sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
im.st.Settings.Builtins = model.Builtins{} im.st.Settings.Builtins = model.Builtins{}
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later" found := map[string]map[string]string{} // builtin → chain → "prefix", "later" or "demoted"
var deferred []func() heads := map[string][]headRule{} // chain → the built-ins its rules start with, in order
var chains []string
rest := map[string][]*parsed{} // chain → the rules after its head
for _, b := range bases { for _, b := range bases {
rules := im.flatten(b.name, nil, 0) rules := im.flatten(b.name, nil, 0)
switch b.kind { switch b.kind {
@@ -954,9 +956,9 @@ func (im *importer) run() {
im.srcnat(p) im.srcnat(p)
} }
default: default:
chains = append(chains, b.kind)
prefix := true prefix := true
for _, p := range rules { for _, p := range rules {
p := p
if bi := im.builtinOf(b.kind, p); bi != "" { if bi := im.builtinOf(b.kind, p); bi != "" {
if found[bi] == nil { if found[bi] == nil {
found[bi] = map[string]string{} found[bi] = map[string]string{}
@@ -967,18 +969,25 @@ func (im *importer) run() {
} }
found[bi][b.kind] = where found[bi][b.kind] = where
if prefix { if prefix {
heads[b.kind] = append(heads[b.kind], headRule{bi, p})
continue continue
} }
} }
prefix = false prefix = false
kind := b.kind rest[b.kind] = append(rest[b.kind], p)
deferred = append(deferred, func() { im.rule(kind, p) })
} }
} }
} }
im.settleBuiltins(found) im.settleBuiltins(found, heads)
for _, f := range deferred { for _, chain := range chains {
f() for _, h := range heads[chain] {
if found[h.builtin][chain] == "demoted" {
im.rule(chain, h.p)
}
}
for _, p := range rest[chain] {
im.rule(chain, p)
}
} }
if len(im.flow) > 0 { if len(im.flow) > 0 {
im.st.Settings.FlowtableDevices = im.flow[0].Dev im.st.Settings.FlowtableDevices = im.flow[0].Dev
@@ -1239,10 +1248,20 @@ func (im *importer) builtinOf(chain string, p *parsed) string {
return "" return ""
} }
// headRule is a rule at the head of a chain that is a built-in.
type headRule struct {
builtin string
p *parsed
}
// settleBuiltins turns on the built-ins found where they go, in every // settleBuiltins turns on the built-ins found where they go, in every
// chain they apply to; the others stay off, and the rules that looked // chain they apply to; the others stay off, and the rules that looked
// like them are kept as rules. // like them are kept as rules. Built-ins come before every rule, so once
func (im *importer) settleBuiltins(found map[string]map[string]string) { // one at a chain's head stays a rule, the ones after it there stay rules
// too ("demoted"), in their order: an accept moved ahead of a drop (ICMPv6
// ahead of dropping invalid packets) would let through what the drop
// stopped.
func (im *importer) settleBuiltins(found map[string]map[string]string, heads map[string][]headRule) {
b := &im.st.Settings.Builtins b := &im.st.Settings.Builtins
on := func(name string, chains ...string) bool { on := func(name string, chains ...string) bool {
for _, c := range chains { for _, c := range chains {
@@ -1252,14 +1271,36 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
} }
return true return true
} }
b.Loopback = on("loopback", model.ChainInput) for {
b.Established = on("established", model.ChainInput, model.ChainForward) b.Loopback = on("loopback", model.ChainInput)
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward) b.Established = on("established", model.ChainInput, model.ChainForward)
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput) b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
b.PingInternal = on("ping", model.ChainInput) b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput) b.PingInternal = on("ping", model.ChainInput)
b.MSSClamp = on("mss-clamp", model.ChainForward) b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
b.Antispoof = on("antispoof", model.ChainEarly) b.MSSClamp = on("mss-clamp", model.ChainForward)
b.Antispoof = on("antispoof", model.ChainEarly)
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}
changed := false
for chain, hs := range heads {
cut := false
for _, h := range hs {
// mss-clamp and flowtable decide nothing: their place
// doesn't matter.
if !kept[h.builtin] && h.builtin != "mss-clamp" && h.builtin != "flowtable" {
cut = true
}
if cut && found[h.builtin][chain] == "prefix" {
found[h.builtin][chain] = "demoted"
changed = true
}
}
}
if !changed {
break
}
}
if !b.Antispoof { if !b.Antispoof {
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil
} else if im.st.Settings.AntispoofV6 == nil { } else if im.st.Settings.AntispoofV6 == nil {
@@ -1268,40 +1309,16 @@ func (im *importer) settleBuiltins(found map[string]map[string]string) {
if b.ICMPv6 { if b.ICMPv6 {
im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)") im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)")
} }
// The ones found in one chain's head but not another's: explicit rules // The ones that stay rules, in their place: say which.
// in the chains where they were found. for _, chain := range []string{model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput} {
for name, chains := range found { for _, h := range heads[chain] {
for chain, where := range chains { if found[h.builtin][chain] == "demoted" {
if where != "prefix" { im.note("%s in the %s chain stays a rule, in its place (the built-in isn't in every chain it covers, or a rule before it stays a rule)", h.builtin, chain)
continue
}
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}[name]
if !kept {
im.note("%s in the %s chain stays a rule (it isn't in every chain the built-in covers, or comes after other rules)", name, chain)
im.builtinRule(name, chain)
} }
} }
} }
} }
// builtinRule writes a built-in that stays off as a rule of its chain.
func (im *importer) builtinRule(name, chain string) {
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain) - 1000}
switch name {
case "established":
r.CtState, r.Action = []string{"established", "related"}, model.ActionAccept
case "invalid":
r.CtState, r.Action = []string{"invalid"}, model.ActionDrop
case "loopback":
r.Raw, r.Action = `iifname "lo" accept`, model.ActionAccept
default:
im.note("built-in %s of the %s chain couldn't be kept; add it by hand", name, chain)
return
}
im.st.Rules = append(im.st.Rules, r)
}
func (im *importer) nextPos(chain string) float64 { func (im *importer) nextPos(chain string) float64 {
im.positions[chain]++ im.positions[chain]++
return im.positions[chain] return im.positions[chain]
+15 -2
View File
@@ -50,11 +50,24 @@ func TestImportRouter(t *testing.T) {
if s := st.Set("masq_to_internet"); s == nil { if s := st.Set("masq_to_internet"); s == nil {
t.Errorf("no set masq_to_internet (sets %v)", setNames(st)) t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
} }
// Built-ins: the ones at the head of their chains. // Built-ins: the ones at the head of their chains, up to the invalid
// drop, which stays a rule (below): the ICMP, ICMPv6 and DHCPv6
// accepts after it stay rules too, in their order, so nothing the
// drop stopped gets through ahead of it.
b := st.Settings.Builtins b := st.Settings.Builtins
if !b.Loopback || !b.Established || !b.ICMPv6 || !b.PingInternal || !b.DHCPv6Client || !b.MSSClamp || !b.Antispoof { if !b.Loopback || !b.Established || b.ICMPv6 || b.PingInternal || b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
t.Errorf("built-ins %+v", b) t.Errorf("built-ins %+v", b)
} }
var input []string
for _, r := range st.Rules {
if r.Chain == model.ChainInput {
input = append(input, strings.Join(r.CtState, ",")+"|"+strings.Join(r.Services, ",")+"|"+r.Raw)
}
}
if len(input) < 5 || input[0] != "invalid||" || !strings.HasPrefix(input[1], "|icmp/echo-reply,icmp/echo-request|") ||
!strings.Contains(input[2], "icmpv6/packet-too-big") || !strings.Contains(input[3], "ip6 hoplimit 255") || !strings.Contains(input[4], "udp/546") {
t.Errorf("input rules start %q", input)
}
// The forward chain's asymmetric-routing rule sits between the // The forward chain's asymmetric-routing rule sits between the
// established and invalid rules: invalid stays rules, in order. // established and invalid rules: invalid stays rules, in order.
if b.DropInvalid { if b.DropInvalid {
+96
View File
@@ -0,0 +1,96 @@
// Package linkwatch tells when the machine's network interfaces change:
// one appears, goes, or comes back (ppp0 when PPPoE reconnects). It
// listens to the kernel's rtnetlink link messages and calls back once
// they have been quiet for a moment, since one change brings several.
package linkwatch
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"time"
"golang.org/x/sys/unix"
)
// Settle is how long the link messages must be quiet before the callback.
const Settle = 2 * time.Second
// Watch calls changed after interfaces changed, until ctx ends. It returns
// an error only if it can't listen at all.
func Watch(ctx context.Context, changed func()) error {
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC|unix.SOCK_NONBLOCK, unix.NETLINK_ROUTE)
if err != nil {
return fmt.Errorf("opening a netlink socket: %w", err)
}
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK, Groups: unix.RTMGRP_LINK}); err != nil {
unix.Close(fd)
return fmt.Errorf("listening to link changes: %w", err)
}
// A non-blocking descriptor goes through Go's poller, so closing the
// file ends a Read in progress.
f := os.NewFile(uintptr(fd), "rtnetlink")
go func() {
<-ctx.Done()
f.Close()
}()
events := make(chan struct{}, 1)
go func() {
buf := make([]byte, 64<<10)
for {
n, err := f.Read(buf)
switch {
case err != nil && ctx.Err() != nil:
close(events)
return
case errors.Is(err, unix.ENOBUFS):
// Messages were lost: something changed anyway.
case err != nil:
time.Sleep(time.Second)
continue
case !links(buf[:n]):
continue
}
select {
case events <- struct{}{}:
default:
}
}
}()
var timer <-chan time.Time
for {
select {
case _, ok := <-events:
if !ok {
return nil
}
timer = time.After(Settle)
case <-timer:
timer = nil
changed()
}
}
}
// links reports whether a netlink datagram holds a link being added,
// changed or deleted.
func links(b []byte) bool {
for len(b) >= unix.NLMSG_HDRLEN {
size := int(binary.NativeEndian.Uint32(b[0:4]))
typ := binary.NativeEndian.Uint16(b[4:6])
if size < unix.NLMSG_HDRLEN || size > len(b) {
return false
}
if typ == unix.RTM_NEWLINK || typ == unix.RTM_DELLINK {
return true
}
size = (size + unix.NLMSG_ALIGNTO - 1) &^ (unix.NLMSG_ALIGNTO - 1)
if size >= len(b) {
return false
}
b = b[size:]
}
return false
}
+36
View File
@@ -0,0 +1,36 @@
package linkwatch
import (
"encoding/binary"
"testing"
"golang.org/x/sys/unix"
)
// msg is a netlink message of a type, with a payload of n bytes.
func msg(typ uint16, n int) []byte {
b := make([]byte, unix.NLMSG_HDRLEN+n)
binary.NativeEndian.PutUint32(b[0:4], uint32(len(b)))
binary.NativeEndian.PutUint16(b[4:6], typ)
return b
}
func TestLinks(t *testing.T) {
for _, c := range []struct {
name string
b []byte
want bool
}{
{"a new link", msg(unix.RTM_NEWLINK, 16), true},
{"a link going", msg(unix.RTM_DELLINK, 16), true},
{"an address", msg(unix.RTM_NEWADDR, 16), false},
{"a link after an address (padded)", append(msg(unix.RTM_NEWADDR, 13), append(make([]byte, 3), msg(unix.RTM_NEWLINK, 8)...)...), true},
{"truncated", msg(unix.RTM_NEWLINK, 16)[:10], false},
{"a length beyond the datagram", msg(unix.RTM_NEWLINK, 16)[:unix.NLMSG_HDRLEN+4], false},
{"empty", nil, false},
} {
if got := links(c.b); got != c.want {
t.Errorf("%s: %v", c.name, got)
}
}
}
+323
View File
@@ -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, " · ")
}
+112
View File
@@ -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
}
}
+4
View File
@@ -36,6 +36,10 @@ type Settings struct {
// itself once it reaches the API again over a new connection, from the // itself once it reaches the API again over a new connection, from the
// same address: proof that it can still manage the firewall. // same address: proof that it can still manage the firewall.
ManualConfirm bool `json:"manual_confirm,omitempty"` ManualConfirm bool `json:"manual_confirm,omitempty"`
// StatsPerDevice counts forwarded traffic to and from external zones
// per inside address (IPv6 per /64), for the statistics' top devices.
StatsPerDevice bool `json:"stats_per_device,omitempty"`
} }
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl". // Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
+86 -6
View File
@@ -14,6 +14,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -201,11 +202,17 @@ type Table struct {
// Quotas: the rules' quotas' used bytes (quota_r12), and the // Quotas: the rules' quotas' used bytes (quota_r12), and the
// per-address quotas' entries by set (quota_r12_v4). // per-address quotas' entries by set (quota_r12_v4).
QuotaUsage QuotaUsage
// DeviceSets are the per-device traffic counters by set
// (stats_up_v4, stats_down_v6 …): each address's packets and bytes.
DeviceSets map[string][]render.Element
// Since is when the counters started (housecarld's first apply). // Since is when the counters started (housecarld's first apply).
Since time.Time Since time.Time
// Tables are all the machine's tables ("inet main"), when the whole // Tables are all the machine's tables ("inet main"), when the whole
// ruleset was read. // ruleset was read.
Tables []string Tables []string
// Fallback: the table is the boot fallback (render.Fallback), not a
// ruleset housecarld applied.
Fallback bool
} }
// QuotaUsage is what the quotas used: kept across reloads, and saved in // QuotaUsage is what the quotas used: kept across reloads, and saved in
@@ -287,7 +294,7 @@ func (q jsonQuota) bytes() int64 {
// ParseTable reads `nft -j list table inet housecarl`. // ParseTable reads `nft -j list table inet housecarl`.
func ParseTable(data []byte) (*Table, error) { func ParseTable(data []byte) (*Table, error) {
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}, t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}, DeviceSets: map[string][]render.Element{},
QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}} QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}}
if len(data) == 0 { if len(data) == 0 {
return t, nil return t, nil
@@ -303,6 +310,9 @@ func ParseTable(data []byte) (*Table, error) {
continue continue
} }
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ") mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
if strings.HasPrefix(mark, render.FallbackMark+"-") {
t.Fallback = true
}
for _, e := range r.Expr { for _, e := range r.Expr {
var c struct { var c struct {
Counter *Counter `json:"counter"` Counter *Counter `json:"counter"`
@@ -340,6 +350,21 @@ func ParseTable(data []byte) (*Table, error) {
} }
continue continue
} }
if render.IsDeviceSet(s.Name) {
for _, e := range s.Elem {
var w struct {
Elem struct {
Val string `json:"val"`
Expires int `json:"expires"`
Counter *Counter `json:"counter"`
} `json:"elem"`
}
if json.Unmarshal(e, &w) == nil && w.Elem.Counter != nil {
t.DeviceSets[s.Name] = append(t.DeviceSets[s.Name], render.Element{Addr: w.Elem.Val, Timeout: w.Elem.Expires, Packets: w.Elem.Counter.Packets, Bytes: w.Elem.Counter.Bytes})
}
}
continue
}
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6") name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") { if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
continue continue
@@ -393,6 +418,35 @@ type Firewall struct {
// restore is the quota usage saved before housecarld (or the machine) // restore is the quota usage saved before housecarld (or the machine)
// stopped, put back by the next apply. // stopped, put back by the next apply.
restore *QuotaUsage restore *QuotaUsage
// flowDevs are the flowtable's devices in the ruleset in force, by
// name, with their interface index then (0: left out, missing).
flowDevs map[string]int
}
// ifIndex is an interface's index, 0 if it doesn't exist (tests change it).
var ifIndex = func(name string) int {
if i, err := net.InterfaceByName(name); err == nil {
return i.Index
}
return 0
}
// FlowtableStale reports whether the flowtable in force no longer matches
// the machine: a device of st's flowtable appeared, went, or came back as
// a new interface (ppp0 after PPPoE reconnects; the kernel drops a
// device from the flowtable when it goes). Applying again fixes it.
func (f *Firewall) FlowtableStale(st *model.State) bool {
f.mu.Lock()
defer f.mu.Unlock()
if f.flowDevs == nil {
return false
}
for _, d := range st.Settings.FlowtableDevices {
if ifIndex(d) != f.flowDevs[d] {
return true
}
}
return false
} }
// RestoreQuotas has the next apply put this saved quota usage back where // RestoreQuotas has the next apply put this saved quota usage back where
@@ -457,10 +511,19 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
} }
usage.merge(f.restore) usage.merge(f.restore)
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
if old != nil {
opt.DeviceSets = old.DeviceSets
}
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil { if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
return err return err
} }
f.restore = nil f.restore = nil
f.flowDevs = map[string]int{}
for _, d := range st.Settings.FlowtableDevices {
if opt.Interfaces == nil || opt.Interfaces[d] {
f.flowDevs[d] = ifIndex(d)
}
}
if f.base == nil { if f.base == nil {
f.base, f.since = map[string]Counter{}, time.Now() f.base, f.since = map[string]Counter{}, time.Now()
} }
@@ -475,20 +538,37 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
return nil return nil
} }
// SaveBoot writes the ruleset of st for the boot unit, atomically. // SaveBoot writes the ruleset of st for the boot unit, and the fallback
// it loads if that fails (FallbackFile), atomically. The boot ruleset has
// no flowtable: it loads before the network comes up, when its devices
// (ppp0, wg0) don't exist yet and nftables would refuse the whole file;
// housecarld adds it once they do.
func (f *Firewall) SaveBoot(st *model.State) error { func (f *Firewall) SaveBoot(st *model.State) error {
if f.BootFile == "" { if f.BootFile == "" {
return nil return nil
} }
rs := render.Render(st, f.options())
tmp := f.BootFile + ".tmp"
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
return err return err
} }
if err := os.WriteFile(tmp, []byte(rs.Text), 0o640); err != nil { if err := writeAtomic(FallbackFile(f.BootFile), render.Fallback(st)); err != nil {
return err return err
} }
return os.Rename(tmp, f.BootFile) rs := render.Render(st, render.Options{Interfaces: map[string]bool{}})
return writeAtomic(f.BootFile, rs.Text)
}
// FallbackFile is where the fallback for a boot ruleset is saved:
// ruleset.nft's is ruleset-fallback.nft beside it.
func FallbackFile(boot string) string {
return strings.TrimSuffix(boot, ".nft") + "-fallback.nft"
}
func writeAtomic(path, text string) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(text), 0o640); err != nil {
return err
}
return os.Rename(tmp, path)
} }
// Table reads Housecarl's table back, with counters totalled since // Table reads Housecarl's table back, with counters totalled since
+157
View File
@@ -2,8 +2,10 @@ package nft
import ( import (
"context" "context"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -208,3 +210,158 @@ func TestQuotasInContainer(t *testing.T) {
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved) t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
} }
} }
// TestDeviceCountsInContainer: the per-device counters are read back and
// survive a reload.
func TestDeviceCountsInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-stats-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
ctx := context.Background()
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
st.Settings.StatsPerDevice = true
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply: %v", err)
}
if out, err := exec.Command("docker", "exec", name, "nft", "add", "element", "inet", "housecarl", "stats_up_v4", "{ 10.0.10.5 counter packets 7 bytes 7000 }").CombinedOutput(); err != nil {
t.Fatalf("adding an element: %v %s", err, out)
}
read := func() render.Element {
tb, err := fw.Table(ctx)
if err != nil {
t.Fatal(err)
}
for _, e := range tb.DeviceSets["stats_up_v4"] {
if e.Addr == "10.0.10.5" {
return e
}
}
t.Fatalf("no 10.0.10.5 in %+v", tb.DeviceSets)
return render.Element{}
}
if e := read(); e.Packets != 7 || e.Bytes != 7000 || e.Timeout <= 0 {
t.Fatalf("read back %+v", e)
}
st.Settings.InputPolicy = model.PolicyAccept // a change: a reload
if err := fw.Apply(ctx, st); err != nil {
t.Fatal(err)
}
if e := read(); e.Packets != 7 || e.Bytes != 7000 {
t.Errorf("after a reload: %+v", e)
}
}
// TestFlowtableStale: the flowtable in force goes stale when one of its
// devices appears, goes, or comes back as a new interface.
func TestFlowtableStale(t *testing.T) {
idx := map[string]int{"eth1": 3}
old := ifIndex
ifIndex = func(name string) int { return idx[name] }
t.Cleanup(func() { ifIndex = old })
exists := func() map[string]bool {
out := map[string]bool{}
for n, i := range idx {
out[n] = i > 0
}
return out
}
// "true" stands in for nft: every apply succeeds, and lists nothing.
fw := &Firewall{Runner: &Runner{Command: []string{"true"}}, Interfaces: exists}
st := model.NewState()
st.Settings.FlowtableDevices = []string{"ppp0", "eth1"}
if fw.FlowtableStale(st) {
t.Error("stale before anything was applied")
}
if err := fw.Apply(context.Background(), st); err != nil {
t.Fatal(err)
}
if fw.FlowtableStale(st) {
t.Error("stale right after applying")
}
idx["ppp0"] = 7 // PPPoE came up
if !fw.FlowtableStale(st) {
t.Error("ppp0 appeared, yet not stale")
}
fw.Apply(context.Background(), st)
idx["ppp0"] = 9 // and reconnected: a new interface by the same name
if !fw.FlowtableStale(st) {
t.Error("ppp0 came back as a new interface, yet not stale")
}
fw.Apply(context.Background(), st)
idx["eth9"] = 4 // not a flowtable device
if fw.FlowtableStale(st) {
t.Error("stale over an interface the flowtable doesn't use")
}
}
// TestBootInContainer: the boot ruleset loads although its flowtable
// devices don't exist yet, the fallback loads and is recognised, and
// housecarld's apply replaces it.
func TestBootInContainer(t *testing.T) {
image := os.Getenv("HOUSECARL_NFT_IMAGE")
if image == "" {
t.Skip("HOUSECARL_NFT_IMAGE not set")
}
name := "housecarl-nft-boot-test"
exec.Command("docker", "rm", "-f", name).Run()
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
t.Fatalf("starting the container: %v %s", err, out)
}
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
nft := func(script string) error {
c := exec.Command("docker", "exec", "-i", name, "nft", "-f", "/dev/stdin")
c.Stdin = strings.NewReader(script)
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, out)
}
return nil
}
dir := t.TempDir()
// ppp0 exists when housecarld saves, but not at boot.
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, BootFile: filepath.Join(dir, "ruleset.nft"),
Interfaces: func() map[string]bool { return map[string]bool{"ppp0": true, "eth0": true} }}
st := model.NewState()
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"ppp0"}, External: true}, {ID: 2, Name: "lan", Interfaces: []string{"eth0"}}}
st.SNATs = []*model.SourceNAT{{ID: 3, Match: model.Match{OutZones: []string{"wan"}}}}
st.Settings.FlowtableDevices = []string{"ppp0", "eth0"}
if err := fw.SaveBoot(st); err != nil {
t.Fatal(err)
}
boot, _ := os.ReadFile(fw.BootFile)
if strings.Contains(string(boot), "flowtable") {
t.Errorf("the boot ruleset has a flowtable:\n%s", boot)
}
fallback, err := os.ReadFile(filepath.Join(dir, "ruleset-fallback.nft"))
if err != nil {
t.Fatal(err)
}
if err := nft(string(boot)); err != nil {
t.Fatalf("the boot ruleset doesn't load: %v", err)
}
ctx := context.Background()
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("after the boot ruleset: %+v %v", tb, err)
}
if err := nft(string(fallback)); err != nil {
t.Fatalf("the fallback doesn't load: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || !tb.Fallback {
t.Errorf("the fallback isn't recognised: %+v %v", tb, err)
}
fw.Interfaces = func() map[string]bool { return map[string]bool{"eth0": true} }
if err := fw.Apply(ctx, st); err != nil {
t.Fatalf("apply over the fallback: %v", err)
}
if tb, err := fw.Table(ctx); err != nil || tb.Fallback {
t.Errorf("the fallback stayed after an apply: %+v %v", tb, err)
}
}
+89
View File
@@ -0,0 +1,89 @@
package render
import (
"fmt"
"strings"
"housecarl/internal/model"
)
// FallbackMark starts the comment of every fallback rule ("hc:fallback-…"):
// how housecarld tells the fallback is in force.
const FallbackMark = "fallback"
// Fallback builds the ruleset housecarl-ruleset.service loads at boot when
// the saved one fails to: nothing new gets in from the external zones'
// interfaces, everything else is let through, and IPv4 leaving an
// external zone is masqueraded (when any source NAT is configured), so the
// inside keeps working until housecarld puts the real ruleset back. With
// no external zone it can't tell which interface faces the internet, and
// lets nothing but loopback through.
//
// It is deliberately small: no sets, flowtable, meters or quotas, and
// interfaces by name only, so it loads before they exist.
func Fallback(st *model.State) string {
var b strings.Builder
line := func(format string, args ...any) {
fmt.Fprintf(&b, format, args...)
b.WriteByte('\n')
}
// rule writes a counted rule: what it matches, then its verdict.
rule := func(name, match, verdict string) {
line("\t\t%s counter %s comment \"hc:%s-%s\"", match, verdict, FallbackMark, name)
}
ext := st.ExternalInterfaces()
line("# Housecarl's fallback: loaded at boot only if ruleset.nft fails to load,")
line("# until housecarld starts and puts the real ruleset back.")
line("table inet %s {}", Table)
line("delete table inet %s", Table)
line("table inet %s {", Table)
if len(ext) == 0 {
// Nothing says which interface faces the internet: close it all.
for _, c := range []string{"input", "forward"} {
line("\tchain %s {", c)
line("\t\ttype filter hook %s priority filter; policy drop;", c)
line("\t\tiifname \"lo\" accept")
line("\t\tcounter comment \"hc:%s-%s\"", FallbackMark, c)
line("\t}")
}
line("}")
return b.String()
}
e := ifaces(ext)
line("\tchain input {")
line("\t\ttype filter hook input priority filter; policy accept;")
line("\t\tiifname \"lo\" accept")
line("\t\tct state established,related accept")
rule("icmpv6", fmt.Sprintf("iifname %s icmpv6 type { %s, echo-request, echo-reply }", e, icmpv6Errors), "accept")
rule("icmpv6-nd", fmt.Sprintf("iifname %s icmpv6 type { %s } ip6 hoplimit 255", e, icmpv6ND), "accept")
rule("icmpv6-mld", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 icmpv6 type { %s }", e, icmpv6MLD), "accept")
rule("dhcpv6-client", fmt.Sprintf("iifname %s ip6 saddr fe80::/10 udp dport 546", e), "accept")
rule("input", "iifname "+e, "drop")
line("\t}")
line("\tchain forward {")
line("\t\ttype filter hook forward priority filter; policy accept;")
if st.Settings.Builtins.MSSClamp {
line("\t\toifname %s tcp flags syn tcp option maxseg size set rt mtu", e)
}
line("\t\tct state established,related accept")
rule("forward", "iifname "+e, "drop")
line("\t}")
if masquerades(st) {
line("\tchain srcnat {")
line("\t\ttype nat hook postrouting priority srcnat; policy accept;")
rule("masquerade", "meta nfproto ipv4 oifname "+e, "masquerade")
line("\t}")
}
line("}")
return b.String()
}
// masquerades reports whether any source NAT is configured.
func masquerades(st *model.State) bool {
for _, x := range st.SNATs {
if !x.Disabled {
return true
}
}
return false
}
+75
View File
@@ -0,0 +1,75 @@
package render
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"housecarl/internal/model"
)
func TestFallback(t *testing.T) {
st := router()
text := Fallback(st)
for _, want := range []string{
"delete table inet housecarl",
"type filter hook input priority filter; policy accept;",
`iifname "wan" counter drop comment "hc:fallback-input"`,
`iifname "wan" counter drop comment "hc:fallback-forward"`,
`iifname "wan" ip6 saddr fe80::/10 udp dport 546 counter accept`,
`ip6 hoplimit 255 counter accept`,
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`,
`meta nfproto ipv4 oifname "wan" counter masquerade comment "hc:fallback-masquerade"`,
} {
if !strings.Contains(text, want) {
t.Errorf("no %q in\n%s", want, text)
}
}
// Nothing that could fail to load before the network is up.
for _, not := range []string{"flowtable", "@", "\tset ", "enp2s0", "wg0"} {
if strings.Contains(text, not) {
t.Errorf("%q in the fallback:\n%s", not, text)
}
}
// Established connections are accepted before the drops.
if strings.Index(text, "ct state established,related accept") > strings.Index(text, "hc:fallback-input") {
t.Errorf("established after the drop:\n%s", text)
}
// No source NAT: no masquerade.
st.SNATs = nil
if text := Fallback(st); strings.Contains(text, "masquerade") {
t.Errorf("masquerade without source NAT:\n%s", text)
}
// No external zone: everything but loopback is dropped.
for _, z := range st.Zones {
z.External = false
}
text = Fallback(st)
if strings.Count(text, "policy drop;") != 2 || strings.Contains(text, "policy accept") || strings.Contains(text, "established") {
t.Errorf("no external zone:\n%s", text)
}
}
func TestFallbackPassesNft(t *testing.T) {
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
if len(cmd) == 0 {
t.Skip("HOUSECARL_NFT not set")
}
closed := router()
for _, z := range closed.Zones {
z.External = false
}
wild := router()
wild.Zones = append(wild.Zones, &model.Zone{ID: 99, Name: "lte", Interfaces: []string{"wwan*"}, External: true})
for name, st := range map[string]*model.State{"router": router(), "closed": closed, "wildcard": wild} {
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(Fallback(st))
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("%s: nft -c: %v\n%s\n%s", name, err, out, Fallback(st))
}
}
}
+58
View File
@@ -39,6 +39,9 @@ type Options struct {
// quota_r12_v4), so a reload keeps what was used. // quota_r12_v4), so a reload keeps what was used.
Quotas map[string]int64 Quotas map[string]int64
QuotaSets map[string][]Element QuotaSets map[string][]Element
// DeviceSets are the per-device traffic counters' entries
// (stats_up_v4 …), so a reload keeps them.
DeviceSets map[string][]Element
} }
// Element is a dynamic set's entry with its remaining lifetime, or a // Element is a dynamic set's entry with its remaining lifetime, or a
@@ -47,6 +50,9 @@ type Element struct {
Addr string `json:"address"` Addr string `json:"address"`
Timeout int `json:"timeout,omitempty"` // seconds left (0: none) Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
Used int64 `json:"used,omitempty"` // bytes Used int64 `json:"used,omitempty"` // bytes
// Packets and Bytes are a per-device counter's.
Packets uint64 `json:"packets,omitempty"`
Bytes uint64 `json:"bytes,omitempty"`
} }
// QuotaName is the name of a rule's quota: the named quota of a total // QuotaName is the name of a rule's quota: the named quota of a total
@@ -110,6 +116,7 @@ func (r *renderer) render() {
w.emit("", "delete table inet %s", Table) w.emit("", "delete table inet %s", Table)
w.emit("", "table inet %s {", Table) w.emit("", "table inet %s {", Table)
r.sets() r.sets()
r.deviceSets()
devices := r.flowtableDevices() devices := r.flowtableDevices()
if len(devices) > 0 { if len(devices) > 0 {
w.emit("built-in: flowtable", "\tflowtable ft { hook ingress priority filter; devices = { %s }; }", strings.Join(quoteAll(devices), ", ")) w.emit("built-in: flowtable", "\tflowtable ft { hook ingress priority filter; devices = { %s }; }", strings.Join(quoteAll(devices), ", "))
@@ -190,6 +197,56 @@ func (r *renderer) elements() {
} }
} }
// IsDeviceSet reports whether a set is a per-device traffic counter.
func IsDeviceSet(name string) bool {
return strings.HasPrefix(name, "stats_up_") || strings.HasPrefix(name, "stats_down_")
}
// deviceSets declares the per-device traffic counters, with what they
// counted before a reload.
func (r *renderer) deviceSets() {
if !r.st.Settings.StatsPerDevice || len(r.st.ExternalInterfaces()) == 0 {
return
}
for _, dir := range []string{"up", "down"} {
for _, v6 := range []bool{false, true} {
name := setName("stats_"+dir, v6)
var elems []string
for _, e := range r.opt.DeviceSets[name] {
a, err := netip.ParseAddr(e.Addr)
if err != nil || a.Is6() != v6 {
continue
}
timeout := ""
if e.Timeout > 0 {
timeout = fmt.Sprintf(" timeout %ds", e.Timeout)
}
elems = append(elems, fmt.Sprintf("%s%s counter packets %d bytes %d", a, timeout, e.Packets, e.Bytes))
}
list := ""
if len(elems) > 0 {
list = " elements = { " + strings.Join(elems, ", ") + " }"
}
r.w.emit("built-in: stats", "\tset %s { type %s; flags dynamic,timeout; timeout 1h; size 65535; counter;%s }", name, addrType(v6), list)
}
}
}
// deviceCounts counts forwarded traffic per inside address: what leaves
// for an external zone by its source, what comes from one by its
// destination (IPv6 by /64).
func (r *renderer) deviceCounts() {
ext := r.st.ExternalInterfaces()
if !r.st.Settings.StatsPerDevice || len(ext) == 0 {
return
}
e := ifaces(ext)
r.builtin("stats", "iifname != %s oifname %s update @stats_up_v4 { ip saddr }", e, e)
r.builtin("stats", "iifname != %s oifname %s update @stats_up_v6 { ip6 saddr and ffff:ffff:ffff:ffff:: }", e, e)
r.builtin("stats", "iifname %s oifname != %s update @stats_down_v4 { ip daddr }", e, e)
r.builtin("stats", "iifname %s oifname != %s update @stats_down_v6 { ip6 daddr and ffff:ffff:ffff:ffff:: }", e, e)
}
// SetName is the nftables name of a set's family part. // SetName is the nftables name of a set's family part.
func SetName(name string, v6 bool) string { return setName(name, v6) } func SetName(name string, v6 bool) string { return setName(name, v6) }
@@ -313,6 +370,7 @@ func (r *renderer) forward(flowtable bool) {
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext)) r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
} }
r.rules(model.ChainForward, true) r.rules(model.ChainForward, true)
r.deviceCounts()
if flowtable { if flowtable {
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft") r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
} }
+38 -2
View File
@@ -206,6 +206,31 @@ func TestRenderRouter(t *testing.T) {
} }
} }
func TestRenderDeviceCounts(t *testing.T) {
st := router()
st.Settings.StatsPerDevice = true
text := Render(st, Options{DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}}}).Text
for _, want := range []string{
`set stats_up_v4 { type ipv4_addr; flags dynamic,timeout; timeout 1h; size 65535; counter; elements = { 10.0.10.5 timeout 3000s counter packets 7 bytes 7000 } }`,
`set stats_down_v6 { type ipv6_addr; flags dynamic,timeout; timeout 1h; size 65535; counter; }`,
`iifname != "wan" oifname "wan" update @stats_up_v4 { ip saddr } comment "hc:b-stats"`,
`iifname "wan" oifname != "wan" update @stats_down_v6 { ip6 daddr and ffff:ffff:ffff:ffff:: } comment "hc:b-stats"`,
} {
if !strings.Contains(text, want) {
t.Errorf("missing %q", want)
}
}
// Counted before the established built-in, so every packet is.
fwd := text[strings.Index(text, "chain forward {"):]
if i := strings.Index(fwd, "update @stats_up_v4"); i < 0 || i > strings.Index(fwd, "hc:b-established") {
t.Error("the counting comes after the established built-in")
}
st.Settings.StatsPerDevice = false
if strings.Contains(Render(st, Options{}).Text, "stats_") {
t.Error("counting without the setting")
}
}
// TestRenderedRulesetPassesNft has the real nft check the router's // TestRenderedRulesetPassesNft has the real nft check the router's
// ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g. // ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g.
// //
@@ -217,9 +242,20 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
if len(cmd) == 0 { if len(cmd) == 0 {
t.Skip("HOUSECARL_NFT not set") t.Skip("HOUSECARL_NFT not set")
} }
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} { stats := router()
stats.Settings.StatsPerDevice = true
// VLAN sub-interfaces: zones of one VLAN, of all of a parent's, and
// the flowtable on one.
vlan := router()
vlan.Zones = append(vlan.Zones, &model.Zone{ID: 900, Name: "iot", Interfaces: []string{"enp2s0.20"}},
&model.Zone{ID: 901, Name: "vlans", Interfaces: []string{"enp3s0.*"}})
vlan.Rules = append(vlan.Rules, &model.Rule{ID: 902, Chain: model.ChainForward, Position: 99, Action: model.ActionAccept,
Match: model.Match{InZones: []string{"iot", "vlans"}, OutZones: []string{"wan"}}})
vlan.Settings.FlowtableDevices = []string{"lo", "enp2s0.20"}
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats, "vlan": vlan} {
q, qs := quotaOptions(st) q, qs := quotaOptions(st)
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs}) rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}}})
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...) c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(rs.Text) c.Stdin = strings.NewReader(rs.Text)
if out, err := c.CombinedOutput(); err != nil { if out, err := c.CombinedOutput(); err != nil {
+78
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/netip"
"sort" "sort"
"strings" "strings"
@@ -12,6 +13,7 @@ import (
"housecarl/internal/api" "housecarl/internal/api"
"housecarl/internal/auth" "housecarl/internal/auth"
"housecarl/internal/engine" "housecarl/internal/engine"
"housecarl/internal/matrix"
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/store" "housecarl/internal/store"
) )
@@ -379,6 +381,47 @@ func usedBy(st *model.State, kind string, o model.Object) string {
return "" return ""
} }
// renameRefs makes every object that refers to a zone, set or service by
// its old name use the new one: renaming it changes them in the same
// change, so the history shows (and undoes) them together.
func renameRefs(st *model.State, kind, old, now string) {
fix := func(list []string) {
for i, x := range list {
if strings.EqualFold(x, old) {
list[i] = now
}
}
}
match := func(m *model.Match) {
switch kind {
case model.KindZone:
fix(m.InZones)
fix(m.OutZones)
case model.KindSet:
fix(m.Sources)
fix(m.Destinations)
case model.KindService:
fix(m.Services)
}
}
for _, x := range st.Rules {
match(&x.Match)
}
for _, x := range st.SNATs {
match(&x.Match)
}
for _, x := range st.Redirects {
match(&x.Match)
}
if kind == model.KindSet {
for _, f := range st.Forwards {
fix(f.Sources)
fix(f.PublicAddresses)
fix(f.HairpinSources)
}
}
}
// moveObject reorders a rule or NAT rule (moveOp). // moveObject reorders a rule or NAT rule (moveOp).
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r) kind, err := pathKind(r)
@@ -416,6 +459,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}) 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 // ---- the ruleset
func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
+70
View File
@@ -0,0 +1,70 @@
package server
import (
"net/http"
"sync"
"time"
"housecarl/internal/version"
)
// fallbackInForce is what status and /healthz say while the boot fallback
// (render.Fallback) is in force.
const fallbackInForce = "the boot fallback is in force, not Housecarl's ruleset: the saved ruleset failed to load at boot (journalctl -u housecarl-ruleset), and housecarld couldn't apply it since; it lets nothing new in from external zones (with none, nothing but loopback)"
// health is GET /healthz, for monitoring (Checkmk, a load balancer). It
// needs no authentication, so it says only what a monitor needs, and reads
// the ruleset at most every few seconds however often it is asked.
type health struct {
OK bool `json:"ok"`
Version string `json:"version"`
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
// Probation: changes waiting to be confirmed, rolled back otherwise.
Probation int `json:"probation"`
// Warnings are the codes of the machine's problems (nftables_service).
Warnings []string `json:"warnings"`
}
const healthTTL = 5 * time.Second
type healthCache struct {
mu sync.Mutex
at time.Time
out health
}
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
c := &s.health
c.mu.Lock()
if time.Since(c.at) >= healthTTL {
c.out, c.at = s.checkHealth(r), time.Now()
}
out := c.out
c.mu.Unlock()
code := http.StatusOK
if !out.OK {
code = http.StatusServiceUnavailable
}
writeJSON(w, code, out)
}
func (s *Server) checkHealth(r *http.Request) health {
out := health{Version: version.String(), Warnings: []string{}}
t, err := s.fw.Table(r.Context())
switch {
case err != nil && s.eng.State().Empty():
out.Firewall, out.OK = "unconfigured", true
case err != nil:
out.Firewall = "missing"
case t.Fallback:
out.Firewall = "fallback"
default:
out.Firewall, out.OK = "applied", true
}
pr := s.probationStatus()
out.Probation = len(pr.Pending) + pr.Takeovers
for _, p := range s.hostProblems(r.Context(), nil) {
out.Warnings = append(out.Warnings, p.Code)
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"housecarl/internal/api"
)
func TestHealthz(t *testing.T) {
e := newEnv(t)
link := filepath.Join(t.TempDir(), "nftables.service")
e.srv.unitLinks = []string{link}
get := func() (int, health) {
t.Helper()
e.srv.health.at = time.Time{} // no caching between the steps
resp, err := http.Get(e.ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var h health
json.NewDecoder(resp.Body).Decode(&h)
return resp.StatusCode, h
}
// Nothing configured yet: nothing to be in force.
if code, h := get(); code != 200 || !h.OK || h.Firewall != "unconfigured" || h.Version == "" {
t.Errorf("unconfigured: %d %+v", code, h)
}
if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true}, nil); code != 201 {
t.Fatalf("creating a zone: %d", code)
}
if code, h := get(); code != 200 || h.Firewall != "applied" || h.Probation != 1 || len(h.Warnings) != 0 {
t.Errorf("applied, on probation: %d %+v", code, h)
}
os.WriteFile(link, nil, 0o644)
if _, h := get(); strings.Join(h.Warnings, ",") != "nftables_service" {
t.Errorf("warnings %v", h.Warnings)
}
e.fw.mu.Lock()
e.fw.fallback = true
e.fw.mu.Unlock()
if code, h := get(); code != 503 || h.OK || h.Firewall != "fallback" {
t.Errorf("fallback: %d %+v", code, h)
}
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
if st.Applied || !strings.Contains(st.ApplyError, "boot fallback") {
t.Errorf("status with the fallback: applied %v, %q", st.Applied, st.ApplyError)
}
// Asked often, the ruleset is read once per few seconds.
e.fw.mu.Lock()
e.fw.fallback = false
e.fw.mu.Unlock()
resp, _ := http.Get(e.ts.URL + "/healthz")
resp.Body.Close()
if resp.StatusCode != 503 {
t.Errorf("not cached: %d", resp.StatusCode)
}
}
+266
View File
@@ -0,0 +1,266 @@
package server
import (
"net/http"
"strconv"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/stats"
"housecarl/internal/validate"
)
// Statistics: the firewall's figures over time (internal/stats).
// StatsSources fills in what the statistics collector needs from the
// server: the interfaces' zones, and which counters count drops.
func (s *Server) StatsSources(src *stats.Sources) {
src.Zones = s.interfaceZones
src.Classify = s.classifyMark
}
// SetStats gives the server the statistics collector (nil: statistics
// are off).
func (s *Server) SetStats(c *stats.Collector) { s.stats = c }
// interfaceZones maps the machine's interfaces to their zones.
func (s *Server) interfaceZones() map[string]string {
out := map[string]string{}
for _, i := range s.hostInterfaces(s.eng.State()) {
if i.Zone != "" {
out[i.Name] = i.Zone
}
}
return out
}
// classifyMark says which chain a counter is in and whether what it
// counts is dropped: rules that drop or reject, the drop policies, and
// the built-ins that drop (antispoofing, invalid packets).
func (s *Server) classifyMark(mark string) (string, bool) {
st := s.eng.State()
switch {
case strings.HasPrefix(mark, "p-"):
chain := strings.TrimPrefix(mark, "p-")
policy := map[string]string{"input": st.Settings.InputPolicy, "forward": st.Settings.ForwardPolicy, "output": st.Settings.OutputPolicy}[chain]
return chain, policy == model.PolicyDrop
case strings.HasPrefix(mark, "b-antispoof"):
return model.ChainEarly, true
case mark == "b-invalid":
return "invalid", true
case strings.HasPrefix(mark, "r"):
num := strings.TrimLeft(mark[1:], "0123456789")
id, _ := strconv.ParseInt(strings.TrimSuffix(mark[1:], num), 10, 64)
if o, ok := st.ByID(model.KindRule, id).(*model.Rule); ok && o != nil {
return o.Chain, o.Action == model.ActionDrop || o.Action == model.ActionReject
}
}
return "", false
}
// markName names a counter's object: "rule input #12 (ssh)", "built-in:
// established", "input policy".
func markName(st *model.State, mark string) string {
if c, ok := strings.CutPrefix(mark, "p-"); ok {
return c + " policy"
}
if l := markLabel(st, api.TraceStep{Kind: "rule", Mark: mark}); l != "" {
return l
}
if len(mark) < 2 {
return mark
}
// An object deleted since it counted.
kind := map[byte]string{'r': "rule", 'f': "port forward", 's': "source NAT", 'd': "redirect"}[mark[0]]
if num := strings.TrimLeft(mark[1:], "0123456789"); kind != "" && len(num) < len(mark)-1 {
return kind + " #" + strings.TrimSuffix(mark[1:], num) + " (deleted)"
}
return mark
}
func (s *Server) statsCollector() (*stats.Collector, error) {
if s.stats == nil {
return nil, &httpError{http.StatusServiceUnavailable, "stats_off", "statistics are off: set stats.enabled in housecarl.yaml and restart housecarld"}
}
return s.stats, nil
}
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
if err := require(p, auth.ResLog, false); err != nil {
return nil, err
}
c, err := s.statsCollector()
if err != nil {
return nil, err
}
q := stats.NewQuerier(c)
q.Now = s.nowFn
return q, nil
}
func since(r *http.Request, def string) (string, error) {
v := r.URL.Query().Get("since")
if v == "" {
v = def
}
if _, err := stats.ParseSince(v); err != nil {
return "", badRequest("%v", err)
}
return v, nil
}
// StatsStatus is the collector's health, with the setting that counts
// traffic per device.
type StatsStatus struct {
stats.Status
Latest *stats.Latest `json:"latest,omitempty"`
}
func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
out := StatsStatus{}
if s.stats != nil {
out.Status = s.stats.Status()
out.Latest = s.stats.Latest()
if !p.IsAdmin() {
out.Database = ""
}
}
out.PerDevice = s.eng.State().Settings.StatsPerDevice
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "24h")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
sum, err := q.Summary(r.Context(), d, queryInt(r, "limit", 10))
if err != nil {
return err
}
st := s.eng.State()
for i := range sum.Rules {
sum.Rules[i].Name = markName(st, sum.Rules[i].Key)
}
writeJSON(w, http.StatusOK, sum)
return nil
}
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "24h")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
var keys []string
for _, k := range strings.Split(r.URL.Query().Get("keys"), ",") {
if k = strings.TrimSpace(k); k != "" {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return badRequest("say which figures: keys=zrx:*,ztx:* (docs/stats.md)")
}
tl, err := q.Timeline(r.Context(), d, r.URL.Query().Get("step"), keys)
if err != nil {
return badRequest("%v", err)
}
writeJSON(w, http.StatusOK, tl)
return nil
}
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "24h")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
es, err := q.Top(r.Context(), r.URL.Query().Get("kind"), d, queryInt(r, "limit", 20))
if err != nil {
return badRequest("%v", err)
}
writeJSON(w, http.StatusOK, es)
return nil
}
// statsHits are the packets each rule, built-in and policy counted over
// the range, named; rules that counted none are listed too, with 0.
func (s *Server) statsHits(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "30d")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
es, err := q.Sums(r.Context(), "hits:", d)
if err != nil {
return err
}
st := s.eng.State()
seen := map[string]bool{}
for i := range es {
es[i].Name = markName(st, es[i].Key)
seen[es[i].Key] = true
}
for _, rule := range st.Rules {
if mark := "r" + strconv.FormatInt(rule.ID, 10); !seen[mark] && !rule.Disabled {
es = append(es, stats.Entry{Key: mark, Name: "rule " + rule.Label()})
}
}
writeJSON(w, http.StatusOK, es)
return nil
}
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "28d")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
hm, err := q.Heatmap(r.Context(), r.URL.Query().Get("key"), d)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, hm)
return nil
}
// statsProblems warns when the connection tracking table is nearly full:
// new connections are dropped once it is.
func (s *Server) statsProblems() []validate.Problem {
if s.stats == nil {
return nil
}
l := s.stats.Latest()
if l == nil || l.ConntrackMax == 0 || l.Conntrack*100 < l.ConntrackMax*80 {
return nil
}
return []validate.Problem{{Severity: validate.Warning, Code: "conntrack_full", Object: "connections",
Message: "the connection tracking table is " + strconv.FormatInt(l.Conntrack*100/l.ConntrackMax, 10) + "% full (" + strconv.FormatInt(l.Conntrack, 10) + " of " + strconv.FormatInt(l.ConntrackMax, 10) + "); once it is, new connections are dropped: raise net.netfilter.nf_conntrack_max, or find what opens so many (Connections)"}}
}
+10 -2
View File
@@ -37,10 +37,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
for _, k := range model.Kinds { for _, k := range model.Kinds {
out.Counts[k] = len(st.Objects(k)) out.Counts[k] = len(st.Objects(k))
} }
if _, err := s.fw.Table(r.Context()); err != nil && st.Empty() { if t, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
out.Unconfigured = true out.Unconfigured = true
} else if err != nil { } else if err != nil {
out.ApplyError = err.Error() out.ApplyError = err.Error()
} else if t.Fallback {
out.ApplyError = fallbackInForce
} else { } else {
out.Applied = true out.Applied = true
} }
@@ -48,6 +50,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
out.OtherTables = otherTables(data) out.OtherTables = otherTables(data)
} }
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...) out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
out.Problems = append(out.Problems, s.statsProblems()...)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
} }
@@ -296,7 +299,12 @@ func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) [
// LogPacket records a packet the firewall logged (internal/nflog calls // LogPacket records a packet the firewall logged (internal/nflog calls
// it). // it).
func (s *Server) LogPacket(e api.LogEntry) { s.logs.Add(e) } func (s *Server) LogPacket(e api.LogEntry) {
s.logs.Add(e)
if s.stats != nil {
s.stats.Packet(e)
}
}
func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil { if err := require(p, auth.ResLog, false); err != nil {
+11
View File
@@ -94,10 +94,21 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
return 0, err return 0, err
} }
setID(next, id) setID(next, id)
if old, now := o.Label(), next.Label(); old != now && isNamed(op.Kind) {
if set, ok := o.(*model.AddressSet); ok && set.Dynamic {
return 0, conflict("set %s is dynamic: its entries, and the API tokens and programs that fill it, go by its name; make a new set instead", old)
}
renameRefs(st, op.Kind, old, now)
}
data, _ := json.Marshal(next) data, _ := json.Marshal(next)
return id, st.Put(op.Kind, id, data) return id, st.Put(op.Kind, id, data)
} }
// isNamed reports whether other objects refer to a kind by name.
func isNamed(kind string) bool {
return kind == model.KindZone || kind == model.KindSet || kind == model.KindService
}
func deleteOp(st *model.State, op *model.Op) error { func deleteOp(st *model.State, op *model.Op) error {
o, err := findObject(st, op.Kind, op.Ref) o, err := findObject(st, op.Kind, op.Ref)
if err != nil { if err != nil {
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"strings"
"testing"
"housecarl/internal/api"
)
// TestRename: renaming a zone, set or service changes what refers to it,
// in the same change, which undoes as one.
func TestRename(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 0
post := func(path string, body map[string]any) {
t.Helper()
if code := e.call(e.token, "POST", path, body, nil); code != 201 {
t.Fatalf("POST %s: %d", path, code)
}
}
post("/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true})
post("/zones", map[string]any{"name": "dhcp", "interfaces": []string{"eth1"}})
post("/sets", map[string]any{"name": "trusted_ips", "entries": []string{"10.0.10.0/24"}})
post("/sets", map[string]any{"name": "guests", "dynamic": true})
post("/services", map[string]any{"name": "jarl", "entries": []string{"tcp/8053"}})
post("/rules", map[string]any{"chain": "forward", "in_zones": []string{"dhcp"}, "out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
post("/rules", map[string]any{"chain": "input", "in_zones": []string{"dhcp"}, "services": []string{"jarl"}})
post("/snats", map[string]any{"out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
post("/forwards", map[string]any{"name": "jf", "ports": "8096", "to": "10.0.10.57", "public_addresses": []string{"217.155.8.194"}, "sources": []string{"trusted_ips"}})
var res api.MutationResponse
if code := e.call(e.token, "PATCH", "/zones/dhcp", map[string]any{"name": "lan"}, &res); code != 200 {
t.Fatalf("renaming the zone: %d %+v", code, res)
}
zoneRename := res.HistoryID
e.call(e.token, "PATCH", "/sets/trusted_ips", map[string]any{"name": "trusted"}, &res)
setRename := res.HistoryID
e.call(e.token, "PATCH", "/services/jarl", map[string]any{"name": "jarl-api"}, &res)
serviceRename := res.HistoryID
st := e.eng.State()
if st.Zone("dhcp") != nil || st.Zone("lan") == nil {
t.Fatalf("zones %+v", st.Zones)
}
for _, r := range st.Rules {
if strings.Join(r.InZones, ",") != "lan" {
t.Errorf("rule %d in zones %v", r.ID, r.InZones)
}
if r.Chain == "forward" && strings.Join(r.Sources, ",") != "trusted" {
t.Errorf("rule %d sources %v", r.ID, r.Sources)
}
if r.Chain == "input" && strings.Join(r.Services, ",") != "jarl-api" {
t.Errorf("rule %d services %v", r.ID, r.Services)
}
}
if s := st.SNATs[0].Sources; strings.Join(s, ",") != "trusted" {
t.Errorf("snat sources %v", s)
}
if s := st.Forwards[0].Sources; strings.Join(s, ",") != "trusted" {
t.Errorf("forward sources %v", s)
}
if ps := e.eng.Problems(); len(ps) != 0 {
for _, p := range ps {
if p.Severity == "error" {
t.Errorf("problem after renaming: %+v", p)
}
}
}
// Undoing the renames (newest first) puts the references back too.
for _, id := range []int64{serviceRename, setRename, zoneRename} {
if code := e.call(e.token, "POST", "/history/"+itoa(id)+"/revert", nil, nil); code != 200 {
t.Fatalf("undo %d: %d", id, code)
}
}
st = e.eng.State()
for _, r := range st.Rules {
if strings.Join(r.InZones, ",") != "dhcp" || r.Chain == "forward" && r.Sources[0] != "trusted_ips" || r.Chain == "input" && r.Services[0] != "jarl" {
t.Errorf("after undo: rule %+v", r.Match)
}
}
// A dynamic set keeps its name: tokens and programs go by it.
if code := e.call(e.token, "PATCH", "/sets/guests", map[string]any{"name": "visitors"}, nil); code != 409 {
t.Errorf("renaming a dynamic set: %d", code)
}
}
+8 -4
View File
@@ -10,15 +10,12 @@ import (
"time" "time"
"housecarl/internal/api" "housecarl/internal/api"
"housecarl/internal/version"
) )
func (s *Server) routes() { func (s *Server) routes() {
m := s.mux m := s.mux
p := api.Prefix p := api.Prefix
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { m.HandleFunc("GET /healthz", s.healthz)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
})
// Authentication, users and tokens // Authentication, users and tokens
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login)) m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout)) m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
@@ -63,6 +60,13 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/{kind}/{ref}", s.authed(s.deleteObject)) m.HandleFunc("DELETE "+p+"/{kind}/{ref}", s.authed(s.deleteObject))
m.HandleFunc("POST "+p+"/{kind}/{ref}/move", s.authed(s.moveObject)) m.HandleFunc("POST "+p+"/{kind}/{ref}/move", s.authed(s.moveObject))
m.HandleFunc("GET "+p+"/ruleset", s.authed(s.ruleset)) m.HandleFunc("GET "+p+"/ruleset", s.authed(s.ruleset))
m.HandleFunc("GET "+p+"/matrix", s.authed(s.zoneMatrix))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline))
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
m.HandleFunc("GET "+p+"/stats/hits", s.authed(s.statsHits))
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory)) m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory)) m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory))
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory)) m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
+4
View File
@@ -24,6 +24,7 @@ import (
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/nft" "housecarl/internal/nft"
"housecarl/internal/render" "housecarl/internal/render"
"housecarl/internal/stats"
"housecarl/internal/store" "housecarl/internal/store"
"housecarl/internal/version" "housecarl/internal/version"
) )
@@ -89,6 +90,7 @@ type Server struct {
srvs []*http.Server srvs []*http.Server
probation probation probation probation
health healthCache
logs *logRing logs *logRing
// unitLinks are where an enabled nftables.service shows (tests change // unitLinks are where an enabled nftables.service shows (tests change
// them). // them).
@@ -97,6 +99,8 @@ type Server struct {
conns Conntrack conns Conntrack
// tracing is the packet trace running (or the last one). // tracing is the packet trace running (or the last one).
tracing tracing tracing tracing
// stats collects statistics (nil: off).
stats *stats.Collector
// csMu orders changes to changesets (staging, applying, discarding). // csMu orders changes to changesets (staging, applying, discarding).
csMu sync.Mutex csMu sync.Mutex
nowFn func() time.Time nowFn func() time.Time
+95 -1
View File
@@ -23,6 +23,7 @@ import (
"housecarl/internal/model" "housecarl/internal/model"
"housecarl/internal/nft" "housecarl/internal/nft"
"housecarl/internal/render" "housecarl/internal/render"
"housecarl/internal/stats"
"housecarl/internal/store" "housecarl/internal/store"
) )
@@ -39,6 +40,7 @@ type fakeFW struct {
resets []string resets []string
traced string traced string
traceLines []string traceLines []string
fallback bool // the boot fallback is in force
} }
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil } func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
@@ -63,7 +65,7 @@ func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
if len(f.applied) == 0 { if len(f.applied) == 0 {
return nil, errors.New("No such file or directory: list table inet housecarl") return nil, errors.New("No such file or directory: list table inet housecarl")
} }
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements}, nil return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
} }
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error { func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
f.mu.Lock() f.mu.Lock()
@@ -848,6 +850,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 // TestHostProblems: an enabled nftables.service, and an imported table
// still in force, are warned about. // still in force, are warned about.
func TestHostProblems(t *testing.T) { func TestHostProblems(t *testing.T) {
@@ -966,3 +996,67 @@ func TestTrace(t *testing.T) {
t.Errorf("tracing everything: %d", code) t.Errorf("tracing everything: %d", code)
} }
} }
// TestStatsEndpoints: statistics off, then on: the summary's top lists,
// hits per rule (named, with those that counted none), and which
// counters count drops.
func TestStatsEndpoints(t *testing.T) {
e := newEnv(t)
if code := e.call(e.token, "GET", "/stats/summary", nil, nil); code != http.StatusServiceUnavailable {
t.Errorf("statistics off: %d", code)
}
var st StatsStatus
e.call(e.token, "GET", "/stats/status", nil, &st)
if st.Enabled {
t.Error("enabled without a collector")
}
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Sources: []string{"198.51.100.0/24"}}, Action: "drop", Comment: "bad net"}, nil)
rule := e.eng.State().Rules[0]
ss, err := stats.OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer ss.Close()
src := stats.Sources{Table: e.fw.Table, SysNet: t.TempDir(), Proc: t.TempDir()}
e.srv.StatsSources(&src)
c := stats.NewCollector(ss, stats.Options{RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour}, src, nil)
e.srv.SetStats(c)
c.Start(context.Background())
e.srv.LogPacket(api.LogEntry{Time: time.Now(), Source: "198.51.100.7", Protocol: "tcp", DestPort: 22, Verdict: "drop"})
c.Sample(context.Background())
var sum stats.Summary
if code := e.call(e.token, "GET", "/stats/summary?since=1h", nil, &sum); code != http.StatusOK {
t.Fatalf("summary: %d", code)
}
if len(sum.BlockedSources) != 1 || sum.BlockedSources[0].Key != "198.51.100.7" || len(sum.BlockedPorts) != 1 || sum.BlockedPorts[0].Key != "tcp/22" {
t.Errorf("summary: %+v", sum)
}
var hits []stats.Entry
e.call(e.token, "GET", "/stats/hits", nil, &hits)
if len(hits) != 1 || hits[0].Key != "r"+itoa(rule.ID) || hits[0].Count != 0 || !strings.Contains(hits[0].Name, "bad net") {
t.Errorf("hits: %+v", hits)
}
var top []stats.Entry
if code := e.call(e.token, "GET", "/stats/top?kind=blocked_port&since=7d", nil, &top); code != http.StatusOK || len(top) != 1 {
t.Errorf("top: %d %+v", code, top)
}
if code := e.call(e.token, "GET", "/stats/top?kind=nonsense", nil, nil); code != http.StatusBadRequest {
t.Errorf("an unknown list: %d", code)
}
if code := e.call(e.token, "GET", "/stats/timeline?keys=zrx:*,drops&since=2h", nil, nil); code != http.StatusOK {
t.Errorf("timeline: %d", code)
}
e.call(e.token, "GET", "/stats/status", nil, &st)
if !st.Enabled || st.LastSample == nil {
t.Errorf("status: %+v", st)
}
for mark, want := range map[string]string{"r" + itoa(rule.ID): "input true", "p-input": "input true", "p-output": "output false", "b-antispoof6": "early true", "b-established": " false"} {
chain, drop := e.srv.classifyMark(mark)
if got := fmt.Sprint(chain, " ", drop); got != want {
t.Errorf("%s: %q, want %q", mark, got, want)
}
}
}
+546
View File
@@ -0,0 +1,546 @@
package stats
import (
"cmp"
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/nft"
)
// Resolutions and retention, and the sources the figures come from.
type Options struct {
TopN int
RetainMinute time.Duration
RetainHour time.Duration
RetainDay time.Duration
Location *time.Location // for days (default: local time)
// Interval is how often the collector samples (default: a minute).
Interval time.Duration
}
// Sources are what the collector reads. Nil functions are left out.
type Sources struct {
// Table reads Housecarl's table: the counters by mark (totals across
// reloads), the quotas, the per-device sets.
Table func(context.Context) (*nft.Table, error)
// Zones maps the interfaces to their zones.
Zones func() map[string]string
// Classify says which chain a mark's counter is in and whether what
// it counts is dropped ("r12" → forward, true).
Classify func(mark string) (chain string, drop bool)
// SysNet and Proc are /sys/class/net and /proc (tests point them
// elsewhere).
SysNet, Proc string
}
// Counter keys. Traffic: rx:IF, tx:IF (bytes), rxp:IF, txp:IF (packets),
// zrx:ZONE, ztx:ZONE (bytes received from and sent to a zone's
// interfaces). Hits: hits:MARK (packets), hitb:MARK (bytes). Drops:
// drop:CHAIN and drops (packets). Quotas: quota:r12 (bytes). Gauges
// (g:KEY, see gauge) are below.
const (
GaugeConntrack = "conntrack"
GaugeConntrackMax = "conntrack_max"
GaugeRSS = "rss" // housecarld's resident memory, bytes
GaugeCPU = "cpu" // housecarld's CPU, tenths of a percent of one CPU
GaugeMem = "mem" // the host's memory in use, bytes
GaugeLoad = "load" // the host's 1-minute load average, times 100
)
// Top-list kinds: per hour and per day, the TopN largest.
const (
KindBlockedSource = "blocked_src" // logged drops by source address
KindBlockedPort = "blocked_port" // logged drops by what they tried: tcp/22, icmp
KindDeviceUp = "device_up" // bytes sent to the internet, by inside address
KindDeviceDown = "device_down" // bytes received from it
// A per-address quota's use by address is "quota_r12".
)
// Kinds are the top lists Top accepts (and quota_rN).
var Kinds = []string{KindBlockedSource, KindBlockedPort, KindDeviceUp, KindDeviceDown}
// Status describes the collector's health.
type Status struct {
Enabled bool `json:"enabled"`
Database string `json:"database,omitempty"`
Size int64 `json:"size"`
LastSample *time.Time `json:"last_sample,omitempty"`
SampleError string `json:"sample_error,omitempty"`
FirstData *time.Time `json:"first_data,omitempty"`
// PerDevice: the firewall counts traffic per device (a setting).
PerDevice bool `json:"per_device"`
Retention struct {
Minute string `json:"minute"`
Hour string `json:"hour"`
Day string `json:"day"`
} `json:"retention"`
}
// Latest are the newest gauge values.
type Latest struct {
At time.Time `json:"at"`
Conntrack int64 `json:"conntrack"`
ConntrackMax int64 `json:"conntrack_max"`
RSS int64 `json:"rss"`
CPUPermille int64 `json:"cpu_permille"`
MemUsed int64 `json:"mem_used"`
Load float64 `json:"load"`
}
// Collector gathers statistics into a Store.
type Collector struct {
opts Options
src Sources
store *Store
log *slog.Logger
now func() time.Time
// Each source's last totals: the next sample stores the difference.
ifs, table deltas
cpuTicks int64
cpuAt time.Time
lastPrune time.Time
mu sync.Mutex
hours map[int64]map[string]map[string]int64 // live hours: hour -> kind -> key -> count
status Status
latest *Latest
}
// maxKeys bounds each kind of an hour's top list in memory.
const maxKeys = 100_000
// NewCollector creates a collector. Run starts it.
func NewCollector(store *Store, opts Options, src Sources, log *slog.Logger) *Collector {
if opts.Location == nil {
opts.Location = time.Local
}
if log == nil {
log = slog.Default()
}
if opts.TopN <= 0 {
opts.TopN = 100
}
if opts.Interval <= 0 {
opts.Interval = time.Minute
}
if src.SysNet == "" {
src.SysNet = "/sys/class/net"
}
if src.Proc == "" {
src.Proc = "/proc"
}
c := &Collector{opts: opts, src: src, store: store, log: log, now: time.Now, hours: map[int64]map[string]map[string]int64{}}
c.status.Enabled = true
c.status.Database = store.Path()
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
c.status.Retention.Hour = humanDuration(opts.RetainHour)
c.status.Retention.Day = humanDuration(opts.RetainDay)
return c
}
// SetClock replaces the clock (tests).
func (c *Collector) SetClock(now func() time.Time) { c.now = now }
// Store returns the database.
func (c *Collector) Store() *Store { return c.store }
// Options returns the collector's options.
func (c *Collector) Options() Options { return c.opts }
// Status returns the collector's health.
func (c *Collector) Status() Status {
c.mu.Lock()
defer c.mu.Unlock()
s := c.status
s.Size = c.store.Size()
return s
}
// Latest returns the newest gauges (nil before the first sample).
func (c *Collector) Latest() *Latest {
c.mu.Lock()
defer c.mu.Unlock()
if c.latest == nil {
return nil
}
l := *c.latest
return &l
}
func (c *Collector) minuteOf(t time.Time) int64 { return t.Unix() - t.Unix()%60 }
func (c *Collector) hourOf(t time.Time) int64 { return t.Unix() - t.Unix()%3600 }
// dayOf returns the start of the local day containing t.
func (c *Collector) dayOf(t time.Time) int64 {
t = t.In(c.opts.Location)
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, c.opts.Location).Unix()
}
// Run collects until ctx is done.
func (c *Collector) Run(ctx context.Context) {
c.Start(ctx)
for {
t := time.NewTimer(time.Until(c.nextSample()))
select {
case <-ctx.Done():
t.Stop()
c.write(context.Background(), &Batch{})
return
case <-t.C:
c.Sample(ctx)
}
}
}
// nextSample: two seconds into the next minute, so the minute that just
// ended is complete (or after the interval, when it isn't a minute).
func (c *Collector) nextSample() time.Time {
if c.opts.Interval != time.Minute {
return c.now().Add(c.opts.Interval)
}
return time.Unix(c.minuteOf(c.now())+62, 0)
}
// Start takes the first sample (the baseline the next ones are counted
// from), continues the current hour's top lists, and adds hours that
// ended while housecarld wasn't running to their days.
func (c *Collector) Start(ctx context.Context) {
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
t := time.Unix(n, 0)
c.status.FirstData = &t
}
}
cur := c.hourOf(c.now())
b := &Batch{}
merged, _ := c.store.Meta(ctx, "merged_through")
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
for h := max(last+3600, cur-48*3600); h < cur; h += 3600 {
kinds, _ := c.store.KindsAt(ctx, ResHour, h)
for _, k := range kinds {
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
}
}
}
}
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
kinds, _ := c.store.KindsAt(ctx, ResHour, cur)
c.mu.Lock()
for _, k := range kinds {
if es, err := c.store.TopsAt(ctx, ResHour, cur, k); err == nil {
m := c.topsMap(cur, k)
for _, e := range es {
m[e.Key] = e.Count
}
}
}
c.mu.Unlock()
if err := c.store.Commit(ctx, b); err != nil {
c.log.Warn("statistics: catching up failed", "err", err)
}
c.sample(ctx, -1)
c.lastPrune = c.now()
c.prune(ctx)
c.log.Info("statistics collector started", "database", c.store.Path())
}
// Sample reads every source and writes what changed since the last
// sample.
func (c *Collector) Sample(ctx context.Context) {
now := c.now()
// The interval that just ended: with a minute's interval, sampling two
// seconds into a minute, the previous minute.
minute := c.minuteOf(now.Add(-c.opts.Interval / 2))
vals := c.sample(ctx, minute)
b := &Batch{}
if len(vals) > 0 {
t := time.Unix(minute, 0)
b.AddCounters(ResMinute, minute, vals)
b.AddCounters(ResHour, c.hourOf(t), vals)
b.AddCounters(ResDay, c.dayOf(t), vals)
c.mu.Lock()
if c.status.FirstData == nil {
c.status.FirstData = &t
b.SetMeta("first_data", strconv.FormatInt(minute, 10))
}
c.mu.Unlock()
}
c.write(ctx, b)
if now.Sub(c.lastPrune) >= time.Hour {
c.lastPrune = now
c.prune(ctx)
}
}
// write adds the live hours' top lists to b, ends the hours that are
// over, and commits.
func (c *Collector) write(ctx context.Context, b *Batch) {
c.mu.Lock()
cur := c.hourOf(c.now())
for h, kinds := range c.hours {
for k, m := range kinds {
b.SetTops(ResHour, h, k, top(m, c.opts.TopN))
}
if h < cur {
// The hour is over: add it to its day.
for k, m := range kinds {
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, top(m, c.opts.TopN), 3*c.opts.TopN)
}
delete(c.hours, h)
b.SetMeta("merged_through", strconv.FormatInt(h, 10))
}
}
c.mu.Unlock()
if err := c.store.Commit(ctx, b); err != nil {
c.log.Error("statistics: writing failed", "err", err)
}
}
// deltas turn a source's totals into what changed since its last read.
type deltas struct {
prev, next map[string]int64
based bool
}
// of returns how much key grew. The first read is the baseline (nothing
// counts); after it, a new key counts whole (a new interface, rule or
// device starts from zero), and a total that went down started again (an
// interface came back, a quota was reset, a device's entry expired).
func (d *deltas) of(key string, cur int64) int64 {
if d.next == nil {
d.next = map[string]int64{}
}
d.next[key] = cur
if !d.based {
return 0
}
p, ok := d.prev[key]
if !ok || cur < p {
return cur
}
return cur - p
}
// done keeps this read's totals for the next (a failed read skips it, so
// its keys aren't new next time).
func (d *deltas) done() { d.prev, d.next, d.based = d.next, nil, true }
// sample reads the sources; minute is the bucket the changes go to (-1:
// only the baseline). It returns the counters.
func (c *Collector) sample(ctx context.Context, minute int64) map[string]int64 {
now := c.now()
vals := map[string]int64{}
add := func(k string, v int64) {
if v != 0 {
vals[k] += v
}
}
gauge := func(k string, v int64) {
vals["g:"+k] += v
vals["gn:"+k]++
}
tops := map[string]map[string]int64{}
addTop := func(kind, key string, v int64) {
if v <= 0 {
return
}
if tops[kind] == nil {
tops[kind] = map[string]int64{}
}
tops[kind][key] += v
}
var errs []string
// Interfaces, and their zones.
var zones map[string]string
if c.src.Zones != nil {
zones = c.src.Zones()
}
for name, ic := range readInterfaces(c.src.SysNet) {
rx, tx := c.ifs.of("rx:"+name, ic.RxBytes), c.ifs.of("tx:"+name, ic.TxBytes)
add("rx:"+name, rx)
add("tx:"+name, tx)
add("rxp:"+name, c.ifs.of("rxp:"+name, ic.RxPackets))
add("txp:"+name, c.ifs.of("txp:"+name, ic.TxPackets))
if z := zones[name]; z != "" {
add("zrx:"+z, rx)
add("ztx:"+z, tx)
}
}
c.ifs.done()
// Housecarl's table: hits, drops, quotas, devices.
if c.src.Table != nil {
t, err := c.src.Table(ctx)
switch {
case err == nil:
for mark, ctr := range t.Counters {
p := c.table.of("hits:"+mark, int64(ctr.Packets))
add("hits:"+mark, p)
add("hitb:"+mark, c.table.of("hitb:"+mark, int64(ctr.Bytes)))
if c.src.Classify != nil && p > 0 {
if chain, drop := c.src.Classify(mark); drop {
add("drop:"+chain, p)
add("drops", p)
}
}
}
for name, used := range t.Quotas {
add("quota:"+strings.TrimPrefix(name, "quota_"), c.table.of("quota:"+name, used))
}
for set, es := range t.QuotaSets {
kind := strings.TrimSuffix(strings.TrimSuffix(set, "_v4"), "_v6")
for _, e := range es {
addTop(kind, e.Addr, c.table.of(set+"/"+e.Addr, e.Used))
}
}
for set, es := range t.DeviceSets {
kind := map[bool]string{true: KindDeviceUp, false: KindDeviceDown}[strings.HasPrefix(set, "stats_up")]
for _, e := range es {
key := e.Addr
if strings.HasSuffix(set, "_v6") {
key += "/64"
}
addTop(kind, key, c.table.of(set+"/"+e.Addr, int64(e.Bytes)))
}
}
c.table.done()
case errors.Is(err, nft.ErrNoTable):
default:
errs = append(errs, "reading the table: "+err.Error())
}
}
// Gauges.
l := &Latest{At: now}
if n, m, ok := readConntrack(c.src.Proc); ok {
l.Conntrack, l.ConntrackMax = n, m
gauge(GaugeConntrack, n)
gauge(GaugeConntrackMax, m)
}
if rss, ticks, ok := readSelf(c.src.Proc); ok {
l.RSS = rss
gauge(GaugeRSS, rss)
if !c.cpuAt.IsZero() && ticks >= c.cpuTicks {
secs := max(int64(now.Sub(c.cpuAt)/time.Second), 1)
l.CPUPermille = (ticks - c.cpuTicks) * 1000 / clockTicks / secs
gauge(GaugeCPU, l.CPUPermille)
}
c.cpuTicks, c.cpuAt = ticks, now
}
if mem, load, ok := readHost(c.src.Proc); ok {
l.MemUsed, l.Load = mem, load
gauge(GaugeMem, mem)
gauge(GaugeLoad, int64(load*100))
}
c.mu.Lock()
defer c.mu.Unlock()
c.latest = l
c.status.LastSample = &now
c.status.SampleError = strings.Join(errs, "; ")
if minute < 0 {
return nil
}
h := c.hourOf(time.Unix(minute, 0))
for kind, m := range tops {
for k, v := range m {
c.addTop(h, kind, k, v)
}
}
return vals
}
// Packet counts a packet the firewall logged: drops by source and by
// what they tried.
func (c *Collector) Packet(e api.LogEntry) {
if e.Verdict != "drop" && e.Verdict != "reject" {
return
}
what := e.Protocol
switch {
case e.DestPort > 0:
what = fmt.Sprintf("%s/%d", e.Protocol, e.DestPort)
case e.ICMPType != "":
what = e.Protocol + "/" + e.ICMPType
}
t := e.Time
if t.IsZero() {
t = c.now()
}
h := c.hourOf(t)
c.mu.Lock()
defer c.mu.Unlock()
if e.Source != "" {
c.addTop(h, KindBlockedSource, e.Source, 1)
}
if what != "" {
c.addTop(h, KindBlockedPort, what, 1)
}
}
// addTop counts into an hour's top list. Called with c.mu held.
func (c *Collector) addTop(hour int64, kind, key string, n int64) {
m := c.topsMap(hour, kind)
if _, ok := m[key]; !ok && len(m) >= maxKeys {
return
}
m[key] += n
}
func (c *Collector) topsMap(hour int64, kind string) map[string]int64 {
h := c.hours[hour]
if h == nil {
h = map[string]map[string]int64{}
c.hours[hour] = h
}
m := h[kind]
if m == nil {
m = map[string]int64{}
h[kind] = m
}
return m
}
// top returns the n largest entries of m.
func top(m map[string]int64, n int) []Entry {
out := make([]Entry, 0, len(m))
for k, v := range m {
out = append(out, Entry{Key: k, Count: v})
}
slices.SortFunc(out, func(a, b Entry) int {
if a.Count != b.Count {
return cmp.Compare(b.Count, a.Count)
}
return cmp.Compare(a.Key, b.Key)
})
if len(out) > n {
out = out[:n]
}
return out
}
func (c *Collector) prune(ctx context.Context) {
now := c.now().Unix()
for res, keep := range map[string]time.Duration{ResMinute: c.opts.RetainMinute, ResHour: c.opts.RetainHour, ResDay: c.opts.RetainDay} {
if keep <= 0 {
continue
}
if n, err := c.store.Prune(ctx, res, now-int64(keep.Seconds())); err != nil {
c.log.Warn("statistics: pruning failed", "err", err)
} else if n > 0 {
c.log.Debug("statistics: pruned", "resolution", res, "rows", n)
}
}
}
+238
View File
@@ -0,0 +1,238 @@
package stats
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"housecarl/internal/api"
"housecarl/internal/nft"
"housecarl/internal/render"
)
// fakeHost is a /sys/class/net and a /proc to read from.
type fakeHost struct {
t *testing.T
sys, proc string
table *nft.Table
tableError error
}
func newFakeHost(t *testing.T) *fakeHost {
dir := t.TempDir()
h := &fakeHost{t: t, sys: filepath.Join(dir, "sys"), proc: filepath.Join(dir, "proc"),
table: &nft.Table{Counters: map[string]nft.Counter{}, QuotaUsage: nft.QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}},
DeviceSets: map[string][]render.Element{}}}
h.write("proc/sys/net/netfilter/nf_conntrack_count", "120")
h.write("proc/sys/net/netfilter/nf_conntrack_max", "65536")
h.write("proc/self/stat", "4242 (housecarld) S 1 4242 4242 0 -1 4194560 3000 0 0 0 150 50 0 0 20 0 9 0 100 1000000 3000")
h.write("proc/self/status", "Name:\thousecarld\nVmRSS:\t 20480 kB\nThreads:\t9\n")
h.write("proc/meminfo", "MemTotal: 8000000 kB\nMemFree: 1000000 kB\nMemAvailable: 6000000 kB\n")
h.write("proc/loadavg", "0.50 0.40 0.30 1/200 4242\n")
return h
}
func (h *fakeHost) write(rel, content string) {
p := filepath.Join(filepath.Dir(h.sys), rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
h.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
h.t.Fatal(err)
}
}
func (h *fakeHost) iface(name string, rx, tx int64) {
h.write("sys/"+name+"/statistics/rx_bytes", fmt.Sprint(rx))
h.write("sys/"+name+"/statistics/tx_bytes", fmt.Sprint(tx))
h.write("sys/"+name+"/statistics/rx_packets", fmt.Sprint(rx/100))
h.write("sys/"+name+"/statistics/tx_packets", fmt.Sprint(tx/100))
}
func (h *fakeHost) sources() Sources {
return Sources{
SysNet: h.sys, Proc: h.proc,
Table: func(context.Context) (*nft.Table, error) {
if h.tableError != nil {
return nil, h.tableError
}
return h.table, nil
},
Zones: func() map[string]string { return map[string]string{"eth0": "wan", "eth1": "lan"} },
Classify: func(mark string) (string, bool) {
switch mark {
case "r1":
return "forward", true
case "p-input":
return "input", true
}
return "input", false
},
}
}
func TestCollector(t *testing.T) {
ctx := context.Background()
h := newFakeHost(t)
h.iface("eth0", 1000, 500)
h.iface("eth1", 2000, 2000)
h.iface("lo", 9999, 9999)
h.table.Counters["r1"] = nft.Counter{Packets: 10, Bytes: 1000}
h.table.Counters["r2"] = nft.Counter{Packets: 100, Bytes: 9000}
h.table.Quotas["quota_r5"] = 1 << 20
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}}
store, err := OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
loc := time.FixedZone("test", 0)
now := time.Date(2026, 10, 19, 10, 0, 30, 0, loc)
c := NewCollector(store, Options{TopN: 10, RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour, Location: loc},
h.sources(), slog.New(slog.NewTextHandler(io.Discard, nil)))
c.SetClock(func() time.Time { return now })
c.Start(ctx)
// A minute of traffic.
h.iface("eth0", 5000, 1500)
h.table.Counters["r1"] = nft.Counter{Packets: 25, Bytes: 2500}
h.table.Counters["p-input"] = nft.Counter{Packets: 3, Bytes: 180} // a new counter counts whole
h.table.Quotas["quota_r5"] = 3 << 20
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 8000}}
h.table.DeviceSets["stats_down_v6"] = []render.Element{{Addr: "2001:db8:1:2::", Bytes: 70000}}
for range 2 {
c.Packet(api.LogEntry{Time: now, Source: "198.51.100.7", Protocol: "tcp", DestPort: 22, Verdict: "drop"})
}
c.Packet(api.LogEntry{Time: now, Source: "198.51.100.8", Protocol: "icmp", ICMPType: "echo-request", Verdict: "drop"})
c.Packet(api.LogEntry{Time: now, Source: "10.0.10.5", Protocol: "udp", DestPort: 53, Verdict: "accept"}) // not a drop
now = time.Date(2026, 10, 19, 10, 1, 2, 0, loc)
c.Sample(ctx)
q := NewQuerier(c)
sum := func(prefix string) map[string]int64 {
es, err := q.Sums(ctx, prefix, time.Hour)
if err != nil {
t.Fatal(err)
}
m := map[string]int64{}
for _, e := range es {
m[e.Key] = e.Count
}
return m
}
check := func(what string, got, want int64) {
t.Helper()
if got != want {
t.Errorf("%s = %d, want %d", what, got, want)
}
}
check("rx:eth0", sum("rx:")["eth0"], 4000)
check("rx:eth1 (no change)", sum("rx:")["eth1"], 0)
check("rx:lo", sum("rx:")["lo"], 0)
check("zrx:wan", sum("zrx:")["wan"], 4000)
check("ztx:wan", sum("ztx:")["wan"], 1000)
check("hits:r1", sum("hits:")["r1"], 15)
check("hits:p-input", sum("hits:")["p-input"], 3)
check("drop:forward", sum("drop:")["forward"], 15)
check("drop:input", sum("drop:")["input"], 3)
check("quota:r5", sum("quota:")["r5"], 2<<20)
s, err := q.Summary(ctx, time.Hour, 10)
if err != nil {
t.Fatal(err)
}
check("drops", s.Drops, 18)
if len(s.Zones) == 0 || s.Zones[0].Name != "wan" || s.Zones[0].Received != 4000 {
t.Errorf("zones %+v", s.Zones)
}
if len(s.BlockedSources) != 2 || s.BlockedSources[0] != (Entry{Key: "198.51.100.7", Count: 2}) {
t.Errorf("blocked sources %+v", s.BlockedSources)
}
if len(s.BlockedPorts) != 2 || s.BlockedPorts[0].Key != "tcp/22" || s.BlockedPorts[1].Key != "icmp/echo-request" {
t.Errorf("blocked ports %+v", s.BlockedPorts)
}
if len(s.DevicesUp) != 1 || s.DevicesUp[0] != (Entry{Key: "10.0.10.5", Count: 3000}) {
t.Errorf("devices up %+v", s.DevicesUp)
}
if len(s.DevicesDown) != 1 || s.DevicesDown[0] != (Entry{Key: "2001:db8:1:2::/64", Count: 70000}) {
t.Errorf("devices down %+v", s.DevicesDown)
}
check("peak connections", s.PeakConnections, 120)
// Gauges average; the CPU needs two samples.
tl, err := q.Timeline(ctx, time.Hour, "1m", []string{"g:" + GaugeRSS, "g:" + GaugeMem, "g:" + GaugeLoad, "rx:*"})
if err != nil {
t.Fatal(err)
}
last := func(i int) int64 {
for j := len(tl.Times) - 1; j >= 0; j-- {
if tl.Times[j] == time.Date(2026, 10, 19, 10, 0, 0, 0, loc).Unix() {
return tl.Series[i].Values[j]
}
}
return -2
}
check("rss", last(0), 20480*1024)
check("mem", last(1), 2000000*1024)
check("load", last(2), 50)
if len(tl.Series) != 4 || tl.Series[3].Key != "rx:eth0" {
t.Errorf("rx:* expanded to %+v", tl.Series[3:])
}
// Totals that go down started again: an interface came back, a quota
// was reset. A table that can't be read is skipped, and its keys
// aren't new the next time.
h.iface("eth0", 100, 1500)
h.table.Quotas["quota_r5"] = 1000
h.tableError = fmt.Errorf("nft failed")
now = time.Date(2026, 10, 19, 10, 2, 2, 0, loc)
c.Sample(ctx)
if st := c.Status(); st.SampleError == "" {
t.Error("no sample error")
}
h.tableError = nil
now = time.Date(2026, 10, 19, 10, 3, 2, 0, loc)
c.Sample(ctx)
check("rx:eth0 after a reset", sum("rx:")["eth0"], 4100)
check("quota:r5 after a reset", sum("quota:")["r5"], 2<<20+1000)
check("hits:r1 after a failed read", sum("hits:")["r1"], 15)
// The hour ends: its top lists go to its day.
now = time.Date(2026, 10, 19, 11, 0, 2, 0, loc)
c.Sample(ctx)
day, err := store.TopsAt(ctx, ResDay, time.Date(2026, 10, 19, 0, 0, 0, 0, loc).Unix(), KindBlockedSource)
if err != nil || len(day) != 2 || day[0].Count != 2 {
t.Errorf("the day's blocked sources: %+v %v", day, err)
}
if es, _ := q.Top(ctx, KindBlockedSource, 30*24*time.Hour, 5); len(es) != 2 {
t.Errorf("30 days of blocked sources: %+v", es)
}
// A restart continues the hour's top lists.
c.Packet(api.LogEntry{Time: now, Source: "203.0.113.9", Protocol: "tcp", DestPort: 23, Verdict: "drop"})
c.write(ctx, &Batch{})
c2 := NewCollector(store, c.opts, h.sources(), c.log)
c2.SetClock(func() time.Time { return now })
c2.Start(ctx)
c2.Packet(api.LogEntry{Time: now, Source: "203.0.113.9", Protocol: "tcp", DestPort: 23, Verdict: "drop"})
c2.write(ctx, &Batch{})
hour, _ := store.TopsAt(ctx, ResHour, time.Date(2026, 10, 19, 11, 0, 0, 0, loc).Unix(), KindBlockedSource)
if len(hour) != 1 || hour[0].Count != 2 {
t.Errorf("after a restart, the hour's blocked sources: %+v", hour)
}
// The heatmap of drops.
hm, err := q.Heatmap(ctx, "", 7*24*time.Hour)
if err != nil {
t.Fatal(err)
}
if hm.Avg[0][10] != 18 { // a Monday, 10:00
t.Errorf("heatmap Monday 10:00 = %v", hm.Avg[0][10])
}
}
+480
View File
@@ -0,0 +1,480 @@
package stats
import (
"cmp"
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
// Querier answers questions about stored statistics.
type Querier struct {
Store *Store
Opts Options
Now func() time.Time
}
// NewQuerier returns a querier for a collector's data.
func NewQuerier(c *Collector) *Querier {
return &Querier{Store: c.store, Opts: c.opts, Now: c.now}
}
func (q *Querier) loc() *time.Location {
if q.Opts.Location != nil {
return q.Opts.Location
}
return time.Local
}
// Resolution picks the finest stored resolution that covers a time range
// starting since ago.
func (q *Querier) Resolution(since time.Duration) string {
switch {
case since <= 48*time.Hour && since <= q.Opts.RetainMinute:
return ResMinute
case since <= q.Opts.RetainHour:
return ResHour
}
return ResDay
}
// topsResolution picks the resolution for top lists, which exist per hour
// and per day.
func (q *Querier) topsResolution(since time.Duration) string {
if since <= 7*24*time.Hour && since <= q.Opts.RetainHour {
return ResHour
}
return ResDay
}
// bucketStart returns the start of the bucket containing t at a resolution.
func (q *Querier) bucketStart(res string, t time.Time) int64 {
switch res {
case ResMinute:
return t.Unix() - t.Unix()%60
case ResHour:
return t.Unix() - t.Unix()%3600
}
l := t.In(q.loc())
return time.Date(l.Year(), l.Month(), l.Day(), 0, 0, 0, 0, q.loc()).Unix()
}
// sums returns the totals of counters (optionally with a key prefix) in
// [from, to).
func (s *Store) sums(ctx context.Context, res string, from, to int64, prefix string) (map[string]int64, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(value) FROM counters WHERE res = ? AND ts >= ? AND ts < ? AND key LIKE ? ESCAPE '\' GROUP BY key`,
res, from, to, likePrefix(prefix))
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int64{}
for rows.Next() {
var k string
var v int64
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
func likePrefix(p string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(p) + "%"
}
// series returns the values of a counter per bucket in [from, to).
func (s *Store) series(ctx context.Context, res string, from, to int64, key string) (map[int64]int64, error) {
rows, err := s.db.QueryContext(ctx, `SELECT ts, value FROM counters WHERE res = ? AND key = ? AND ts >= ? AND ts < ?`, res, key, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]int64{}
for rows.Next() {
var ts, v int64
if err := rows.Scan(&ts, &v); err != nil {
return nil, err
}
out[ts] = v
}
return out, rows.Err()
}
// tops sums top lists (of one or more kinds) in [from, to).
func (s *Store) tops(ctx context.Context, res string, kinds []string, from, to int64, limit int) ([]Entry, error) {
args := []any{res}
for _, k := range kinds {
args = append(args, k)
}
args = append(args, from, to, limit)
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind IN (`+strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")+`) AND ts >= ? AND ts < ?
GROUP BY key ORDER BY n DESC, key LIMIT ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Entry{}
for rows.Next() {
var e Entry
if err := rows.Scan(&e.Key, &e.Count); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func sorted(m map[string]int64, prefix string) []Entry {
out := []Entry{}
for k, v := range m {
if strings.HasPrefix(k, prefix) && v != 0 {
out = append(out, Entry{Key: strings.TrimPrefix(k, prefix), Count: v})
}
}
slices.SortFunc(out, func(a, b Entry) int {
if a.Count != b.Count {
return cmp.Compare(b.Count, a.Count)
}
return cmp.Compare(a.Key, b.Key)
})
return out
}
// Top returns the largest entries of a top list over the last since.
func (q *Querier) Top(ctx context.Context, kind string, since time.Duration, limit int) ([]Entry, error) {
if !slices.Contains(Kinds, kind) && !quotaKind(kind) {
return nil, fmt.Errorf("unknown list %q (use %s, or quota_rN)", kind, strings.Join(Kinds, ", "))
}
now := q.Now()
res := q.topsResolution(since)
return q.Store.tops(ctx, res, []string{kind}, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
}
func quotaKind(k string) bool {
n, ok := strings.CutPrefix(k, "quota_r")
_, err := strconv.ParseInt(n, 10, 64)
return ok && err == nil
}
// Sums returns the totals of the counters with a prefix over the last
// since, largest first, the prefix taken off ("hits:" → "r12").
func (q *Querier) Sums(ctx context.Context, prefix string, since time.Duration) ([]Entry, error) {
now := q.Now()
res := q.Resolution(since)
m, err := q.Store.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, prefix)
if err != nil {
return nil, err
}
return sorted(m, prefix), nil
}
// Traffic is what an interface or zone received and sent, in bytes.
type Traffic struct {
Name string `json:"name"`
Received int64 `json:"received"`
Sent int64 `json:"sent"`
}
// Summary is an overview of the last since.
type Summary struct {
Since string `json:"since"`
From time.Time `json:"from"`
Zones []Traffic `json:"zones"`
Interfaces []Traffic `json:"interfaces"`
// Drops are the packets dropped (rules, built-ins, policies), and by
// chain.
Drops int64 `json:"drops"`
DropsByChain []Entry `json:"drops_by_chain"`
// Rules are the busiest rules, built-ins and policies, by packets.
Rules []Entry `json:"rules"`
// Connections: the most tracked at once (per-bucket averages), and
// the table's size.
PeakConnections int64 `json:"peak_connections"`
ConntrackMax int64 `json:"conntrack_max"`
BlockedSources []Entry `json:"blocked_sources"`
BlockedPorts []Entry `json:"blocked_ports"`
DevicesUp []Entry `json:"devices_up"`
DevicesDown []Entry `json:"devices_down"`
}
// Summary sums up the last since, with top lists of up to topLimit.
func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int) (*Summary, error) {
now := q.Now()
res := q.Resolution(since)
from := q.bucketStart(res, now.Add(-since))
all, err := q.Store.sums(ctx, res, from, now.Unix()+1, "")
if err != nil {
return nil, err
}
s := &Summary{Since: humanDuration(since), From: time.Unix(from, 0), Zones: []Traffic{}, Interfaces: []Traffic{}}
traffic := func(rx, tx string) []Traffic {
by := map[string]*Traffic{}
for k, v := range all {
var name string
var in bool
if n, ok := strings.CutPrefix(k, rx); ok {
name, in = n, true
} else if n, ok := strings.CutPrefix(k, tx); ok {
name = n
} else {
continue
}
t := by[name]
if t == nil {
t = &Traffic{Name: name}
by[name] = t
}
if in {
t.Received += v
} else {
t.Sent += v
}
}
out := []Traffic{}
for _, t := range by {
out = append(out, *t)
}
slices.SortFunc(out, func(a, b Traffic) int {
if c := cmp.Compare(b.Received+b.Sent, a.Received+a.Sent); c != 0 {
return c
}
return cmp.Compare(a.Name, b.Name)
})
return out
}
s.Zones, s.Interfaces = traffic("zrx:", "ztx:"), traffic("rx:", "tx:")
s.Drops = all["drops"]
s.DropsByChain = sorted(all, "drop:")
s.Rules = sorted(all, "hits:")
if len(s.Rules) > topLimit {
s.Rules = s.Rules[:topLimit]
}
if tl, err := q.Timeline(ctx, since, "auto", []string{"g:" + GaugeConntrack, "g:" + GaugeConntrackMax}); err == nil {
for _, v := range tl.Series[0].Values {
s.PeakConnections = max(s.PeakConnections, v)
}
for _, v := range tl.Series[1].Values {
s.ConntrackMax = max(s.ConntrackMax, v)
}
}
for _, l := range []struct {
kind string
dst *[]Entry
}{{KindBlockedSource, &s.BlockedSources}, {KindBlockedPort, &s.BlockedPorts}, {KindDeviceUp, &s.DevicesUp}, {KindDeviceDown, &s.DevicesDown}} {
es, err := q.Top(ctx, l.kind, since, topLimit)
if err != nil {
return nil, err
}
*l.dst = es
}
return s, nil
}
// Timeline is a set of series over time.
type Timeline struct {
Step string `json:"step"`
Times []int64 `json:"times"` // bucket starts (Unix seconds)
Series []*Series `json:"series"`
}
// Series is one counter over time. For a gauge (g:KEY) the values are
// averages, and -1 where there is no sample.
type Series struct {
Key string `json:"key"`
Values []int64 `json:"values"`
}
var steps = []struct {
name string
d time.Duration
res string
}{
{"1m", time.Minute, ResMinute}, {"5m", 5 * time.Minute, ResMinute}, {"15m", 15 * time.Minute, ResMinute},
{"1h", time.Hour, ResHour}, {"6h", 6 * time.Hour, ResHour}, {"1d", 24 * time.Hour, ResDay},
}
// Timeline returns counters over the last since. keys are counter names;
// a key ending in ":*" means every counter of that group. step is "auto"
// or one of 1m, 5m, 15m, 1h, 6h, 1d.
func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string, keys []string) (*Timeline, error) {
idx := -1
for i, st := range steps {
ok := st.res == ResMinute && since <= q.Opts.RetainMinute || st.res == ResHour && since <= q.Opts.RetainHour || st.res == ResDay
if step == st.name {
if !ok {
return nil, fmt.Errorf("step %s needs %s data, which is only kept for %s", st.name, map[string]string{ResMinute: "per-minute", ResHour: "hourly"}[st.res], humanDuration(map[string]time.Duration{ResMinute: q.Opts.RetainMinute, ResHour: q.Opts.RetainHour}[st.res]))
}
idx = i
break
}
if step == "auto" || step == "" {
if ok && since/st.d <= 300 {
idx = i
break
}
}
}
if idx < 0 {
if step != "auto" && step != "" {
return nil, fmt.Errorf("unknown step %q (use auto, 1m, 5m, 15m, 1h, 6h or 1d)", step)
}
idx = len(steps) - 1
}
st := steps[idx]
now := q.Now()
// Buckets aligned to the step (days to local midnight).
var starts []int64
if st.res == ResDay {
for t := time.Unix(q.bucketStart(ResDay, now.Add(-since)), 0).In(q.loc()); !t.After(now); t = t.AddDate(0, 0, 1) {
starts = append(starts, t.Unix())
}
} else {
sd := int64(st.d.Seconds())
first := now.Add(-since).Unix()
first -= first % sd
for t := first; t <= now.Unix(); t += sd {
starts = append(starts, t)
}
}
from, to := starts[0], now.Unix()+1
// Expand group keys.
var expanded []string
for _, k := range keys {
if g, ok := strings.CutSuffix(k, ":*"); ok {
m, err := q.Store.sums(ctx, st.res, from, to, g+":")
if err != nil {
return nil, err
}
for _, e := range sorted(m, "") {
expanded = append(expanded, e.Key)
}
continue
}
expanded = append(expanded, k)
}
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
bucketed := func(key string) ([]int64, error) {
raw, err := q.Store.series(ctx, st.res, from, to, key)
if err != nil {
return nil, err
}
vals := make([]int64, len(starts))
for ts, v := range raw {
// The bucket is the last start <= ts.
i, found := slices.BinarySearch(starts, ts)
if !found {
i--
}
if i >= 0 && i < len(vals) {
vals[i] += v
}
}
return vals, nil
}
for _, k := range expanded {
vals, err := bucketed(k)
if err != nil {
return nil, err
}
// A gauge (g:KEY) is the average of its samples: its sum divided
// by gn:KEY, their number. A bucket without samples is -1.
if g, ok := strings.CutPrefix(k, "g:"); ok {
n, err := bucketed("gn:" + g)
if err != nil {
return nil, err
}
for i := range vals {
if n[i] > 0 {
vals[i] /= n[i]
} else {
vals[i] = -1
}
}
}
tl.Series = append(tl.Series, &Series{Key: k, Values: vals})
}
return tl, nil
}
// Heatmap is average activity per weekday and hour of day (local time).
type Heatmap struct {
Key string `json:"key"`
// Avg[d][h] is the average per hour on weekday d (0 = Monday) at
// hour h; -1 where there is no data.
Avg [7][24]float64 `json:"avg"`
// Samples[d][h] is how many hours the average is based on.
Samples [7][24]int `json:"samples"`
From time.Time `json:"from"`
}
// Heatmap averages a counter (default "drops") by weekday and hour over
// the last since (at most the hourly retention).
func (q *Querier) Heatmap(ctx context.Context, key string, since time.Duration) (*Heatmap, error) {
if key == "" {
key = "drops"
}
since = min(since, q.Opts.RetainHour)
now := q.Now()
from := q.bucketStart(ResHour, now.Add(-since))
raw, err := q.Store.series(ctx, ResHour, from, now.Unix()+1, key)
if err != nil {
return nil, err
}
hm := &Heatmap{Key: key, From: time.Unix(from, 0)}
var sum [7][24]int64
for ts, v := range raw {
t := time.Unix(ts, 0).In(q.loc())
d := (int(t.Weekday()) + 6) % 7
sum[d][t.Hour()] += v
hm.Samples[d][t.Hour()]++
}
for d := range 7 {
for h := range 24 {
if n := hm.Samples[d][h]; n > 0 {
hm.Avg[d][h] = float64(sum[d][h]) / float64(n)
} else {
hm.Avg[d][h] = -1
}
}
}
return hm, nil
}
// ParseSince parses a range such as 90m, 24h, 7d or 2w.
func ParseSince(s string) (time.Duration, error) {
if s == "" {
return 24 * time.Hour, nil
}
mult := time.Duration(0)
switch {
case strings.HasSuffix(s, "d"):
mult = 24 * time.Hour
case strings.HasSuffix(s, "w"):
mult = 7 * 24 * time.Hour
}
if mult > 0 {
var n int
if _, err := fmt.Sscanf(s[:len(s)-1], "%d", &n); err != nil || n <= 0 || fmt.Sprint(n) != s[:len(s)-1] {
return 0, fmt.Errorf("invalid range %q (use e.g. 90m, 24h, 7d, 2w)", s)
}
return time.Duration(n) * mult, nil
}
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return 0, fmt.Errorf("invalid range %q (use e.g. 90m, 24h, 7d, 2w)", s)
}
return d, nil
}
func humanDuration(d time.Duration) string {
if d%(24*time.Hour) == 0 && d > 0 {
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
}
return d.String()
}
+136
View File
@@ -0,0 +1,136 @@
package stats
import (
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
)
// What the collector reads from the kernel without privileges: the
// interfaces' counters (/sys/class/net), the connection tracking table's
// size, and housecarld's and the host's memory and load (/proc).
// clockTicks is USER_HZ, the unit of the CPU times in /proc/PID/stat. It
// is 100 on every Linux architecture Housecarl runs on.
const clockTicks = 100
// ifCounters are an interface's totals since it came up.
type ifCounters struct {
RxBytes, TxBytes, RxPackets, TxPackets int64
}
// readInterfaces reads every interface's counters but the loopback's.
func readInterfaces(sysNet string) map[string]ifCounters {
out := map[string]ifCounters{}
entries, err := os.ReadDir(sysNet)
if err != nil {
return out
}
for _, e := range entries {
name := e.Name()
if name == "lo" {
continue
}
dir := filepath.Join(sysNet, name, "statistics")
read := func(f string) (int64, bool) {
b, err := os.ReadFile(filepath.Join(dir, f))
if err != nil {
return 0, false
}
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
return n, err == nil
}
var c ifCounters
var ok bool
if c.RxBytes, ok = read("rx_bytes"); !ok {
continue
}
c.TxBytes, _ = read("tx_bytes")
c.RxPackets, _ = read("rx_packets")
c.TxPackets, _ = read("tx_packets")
out[name] = c
}
return out
}
// readConntrack returns how many connections the kernel tracks, and how
// many it can (ok false without connection tracking).
func readConntrack(proc string) (count, max int64, ok bool) {
read := func(f string) (int64, bool) {
b, err := os.ReadFile(filepath.Join(proc, "sys/net/netfilter", f))
if err != nil {
return 0, false
}
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
return n, err == nil
}
count, ok1 := read("nf_conntrack_count")
max, ok2 := read("nf_conntrack_max")
return count, max, ok1 && ok2
}
// readSelf returns housecarld's resident memory (bytes) and CPU time
// (clock ticks).
func readSelf(proc string) (rss, cpu int64, ok bool) {
dir := filepath.Join(proc, "self")
stat, err := os.ReadFile(filepath.Join(dir, "stat"))
if err != nil {
return 0, 0, false
}
// The command name is in parentheses and may contain spaces: the
// fields that follow start after the last ')'.
i := bytes.LastIndexByte(stat, ')')
if i < 0 {
return 0, 0, false
}
f := strings.Fields(string(stat[i+1:]))
// f[0] is field 3 (state); utime and stime are fields 14 and 15.
if len(f) < 13 {
return 0, 0, false
}
utime, _ := strconv.ParseInt(f[11], 10, 64)
stime, _ := strconv.ParseInt(f[12], 10, 64)
status, err := os.ReadFile(filepath.Join(dir, "status"))
if err != nil {
return 0, 0, false
}
for _, line := range strings.Split(string(status), "\n") {
if k, v, ok := strings.Cut(line, ":"); ok && k == "VmRSS" {
rss = kib(v)
}
}
return rss, utime + stime, true
}
// readHost returns the memory in use (bytes) and the 1-minute load
// average.
func readHost(proc string) (memUsed int64, load float64, ok bool) {
b, err := os.ReadFile(filepath.Join(proc, "meminfo"))
if err != nil {
return 0, 0, false
}
var total, avail int64
for _, line := range strings.Split(string(b), "\n") {
k, v, _ := strings.Cut(line, ":")
switch k {
case "MemTotal":
total = kib(v)
case "MemAvailable":
avail = kib(v)
}
}
if b, err := os.ReadFile(filepath.Join(proc, "loadavg")); err == nil {
if f := strings.Fields(string(b)); len(f) > 0 {
load, _ = strconv.ParseFloat(f[0], 64)
}
}
return total - avail, load, total > 0
}
// kib parses a "1234 kB" value from /proc into bytes.
func kib(v string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB")), 10, 64)
return n * 1024
}
+304
View File
@@ -0,0 +1,304 @@
// Package stats keeps the firewall's figures over time: traffic per
// interface and zone, hits per rule, drops, blocked sources, connections,
// quotas and the machine's health, sampled every minute. It stores
// aggregates in a SQLite database of its own, separate from Housecarl's
// configuration, so it can be deleted at any time without losing anything
// important.
package stats
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
_ "modernc.org/sqlite" // registers the "sqlite" driver
)
// Resolutions of stored data.
const (
ResMinute = "m"
ResHour = "h"
ResDay = "d"
)
// Store is the statistics database.
type Store struct {
db *sql.DB
path string
}
var schema = []string{
`CREATE TABLE counters (
res TEXT NOT NULL,
ts INTEGER NOT NULL,
key TEXT NOT NULL,
value INTEGER NOT NULL,
PRIMARY KEY (res, ts, key)
) WITHOUT ROWID;
CREATE INDEX counters_by_key ON counters (res, key, ts);
CREATE TABLE tops (
res TEXT NOT NULL,
ts INTEGER NOT NULL,
kind TEXT NOT NULL,
key TEXT NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (res, ts, kind, key)
) WITHOUT ROWID;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
}
// Size is the database's size on disk, with its write-ahead log.
func (s *Store) Size() int64 {
var n int64
for _, f := range []string{s.path, s.path + "-wal"} {
if fi, err := os.Stat(f); err == nil {
n += fi.Size()
}
}
return n
}
// Oldest is the start of the oldest stored bucket (0 when empty).
func (s *Store) Oldest(ctx context.Context) (int64, error) {
var ts sql.NullInt64
err := s.db.QueryRowContext(ctx, `SELECT MIN(ts) FROM counters`).Scan(&ts)
return ts.Int64, err
}
// OpenStore opens (creating if needed) the statistics database. Use
// ":memory:" for tests.
func OpenStore(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=synchronous(NORMAL)&_txlock=immediate"
if path == ":memory:" {
dsn = "file::memory:?_txlock=immediate"
} else {
dsn += "&_pragma=journal_mode(WAL)"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
if path == ":memory:" {
db.SetMaxOpenConns(1)
}
s := &Store{db: db, path: path}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("statistics database %s: %w", path, err)
}
return s, nil
}
// Path returns the database file ("" in memory).
func (s *Store) Path() string {
if s.path == ":memory:" {
return ""
}
return s.path
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
return err
}
var v int
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
if _, err := s.db.Exec(`INSERT INTO schema_version (version) VALUES (0)`); err != nil {
return err
}
} else if err != nil {
return err
}
if v > len(schema) {
return fmt.Errorf("schema version %d is newer than this Housecarl; upgrade Housecarl or delete the statistics database", v)
}
for i := v; i < len(schema); i++ {
tx, err := s.db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(schema[i]); err != nil {
tx.Rollback()
return err
}
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, i+1); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// Batch collects writes that are committed together.
type Batch struct {
counters []counterRow
tops []topsWrite
meta map[string]string
}
type counterRow struct {
res string
ts int64
key string
value int64
}
type topsWrite struct {
res, kind string
ts int64
entries []Entry
replace bool // replace the (res, ts, kind) rows instead of adding
keep int // after adding, keep only the largest entries (0: all)
}
// Entry is a key and its count.
type Entry struct {
Key string `json:"key"`
Count int64 `json:"count"`
// Name is what the key stands for (a rule's label, a device's host
// name), set by the API.
Name string `json:"name,omitempty"`
}
// AddCounters adds values to the counters of a bucket.
func (b *Batch) AddCounters(res string, ts int64, values map[string]int64) {
for k, v := range values {
b.counters = append(b.counters, counterRow{res, ts, k, v})
}
}
// SetTops replaces a bucket's top list of a kind.
func (b *Batch) SetTops(res string, ts int64, kind string, entries []Entry) {
b.tops = append(b.tops, topsWrite{res: res, ts: ts, kind: kind, entries: entries, replace: true})
}
// AddTops adds counts to a bucket's top list and keeps the keep largest.
func (b *Batch) AddTops(res string, ts int64, kind string, entries []Entry, keep int) {
b.tops = append(b.tops, topsWrite{res: res, ts: ts, kind: kind, entries: entries, keep: keep})
}
// SetMeta stores a value that survives restarts.
func (b *Batch) SetMeta(key, value string) {
if b.meta == nil {
b.meta = map[string]string{}
}
b.meta[key] = value
}
// Commit writes the batch in one transaction.
func (s *Store) Commit(ctx context.Context, b *Batch) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if len(b.counters) > 0 {
st, err := tx.PrepareContext(ctx, `INSERT INTO counters (res, ts, key, value) VALUES (?, ?, ?, ?)
ON CONFLICT (res, ts, key) DO UPDATE SET value = value + excluded.value`)
if err != nil {
return err
}
for _, c := range b.counters {
if _, err := st.ExecContext(ctx, c.res, c.ts, c.key, c.value); err != nil {
st.Close()
return err
}
}
st.Close()
}
for _, t := range b.tops {
if t.replace {
if _, err := tx.ExecContext(ctx, `DELETE FROM tops WHERE res = ? AND ts = ? AND kind = ?`, t.res, t.ts, t.kind); err != nil {
return err
}
}
for _, e := range t.entries {
if _, err := tx.ExecContext(ctx, `INSERT INTO tops (res, ts, kind, key, count) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (res, ts, kind, key) DO UPDATE SET count = count + excluded.count`, t.res, t.ts, t.kind, e.Key, e.Count); err != nil {
return err
}
}
if t.keep > 0 {
if _, err := tx.ExecContext(ctx, `DELETE FROM tops WHERE res = ? AND ts = ? AND kind = ? AND key NOT IN (
SELECT key FROM tops WHERE res = ? AND ts = ? AND kind = ? ORDER BY count DESC, key LIMIT ?)`,
t.res, t.ts, t.kind, t.res, t.ts, t.kind, t.keep); err != nil {
return err
}
}
}
for k, v := range b.meta {
if _, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, k, v); err != nil {
return err
}
}
return tx.Commit()
}
// Meta returns a stored value ("" if unset).
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
// Prune deletes data of a resolution older than before (a Unix time).
func (s *Store) Prune(ctx context.Context, res string, before int64) (int64, error) {
var n int64
for _, table := range []string{"counters", "tops"} {
r, err := s.db.ExecContext(ctx, `DELETE FROM `+table+` WHERE res = ? AND ts < ?`, res, before)
if err != nil {
return n, err
}
c, _ := r.RowsAffected()
n += c
}
return n, nil
}
// TopsAt returns a bucket's top list of a kind.
func (s *Store) TopsAt(ctx context.Context, res string, ts int64, kind string) ([]Entry, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, count FROM tops WHERE res = ? AND ts = ? AND kind = ? ORDER BY count DESC, key`, res, ts, kind)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Entry
for rows.Next() {
var e Entry
if err := rows.Scan(&e.Key, &e.Count); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// KindsAt returns the top-list kinds stored for one bucket.
func (s *Store) KindsAt(ctx context.Context, res string, ts int64) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT kind FROM tops WHERE res = ? AND ts = ?`, res, ts)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
+13 -2
View File
@@ -185,6 +185,12 @@ func (c *checker) settings() {
} }
} }
} }
if s.StatsPerDevice && len(c.st.ExternalInterfaces()) == 0 {
c.add(Warning, "stats_no_external", "settings", "traffic per device counts what goes to and comes from external zones, and no zone is external (with interfaces)")
}
if s.StatsPerDevice && len(s.FlowtableDevices) > 0 {
c.add(Warning, "stats_flowtable", "settings", "the flowtable's fast path skips the forward chain once a connection is established, so traffic per device only counts each connection's first packets; turn the flowtable off for full figures")
}
if p := s.ProbationSeconds; p != 0 && (p < 10 || p > 3600) { if p := s.ProbationSeconds; p != 0 && (p < 10 || p > 3600) {
c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p) c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p)
} }
@@ -217,8 +223,8 @@ func (c *checker) zones() {
} }
} }
} }
if len(c.st.Zones) > 0 && !external { if !c.st.Empty() && !external {
c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses") c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses; and if the saved ruleset fails to load at boot, the fallback can't tell which interface faces the internet, and lets nothing but loopback through")
} }
} }
@@ -227,6 +233,11 @@ func (c *checker) sets() {
for _, s := range c.st.Sets { for _, s := range c.st.Sets {
obj := "set " + s.Name obj := "set " + s.Name
c.name("set", obj, s.Name, seen) c.name("set", obj, s.Name, seen)
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_"} {
if strings.HasPrefix(strings.ToLower(s.Name), pre) {
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics); choose another name", pre)
}
}
for _, e := range s.Entries { for _, e := range s.Entries {
if _, err := model.ParseAddress(e); err != nil { if _, err := model.ParseAddress(e); err != nil {
c.add(Error, "set_entry", obj, "%v", err) c.add(Error, "set_entry", obj, "%v", err)
+35
View File
@@ -65,6 +65,15 @@ func TestCodes(t *testing.T) {
}}, }},
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }}, {"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }}, {"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
{"set_name_reserved", func(st *model.State) { st.Sets[0].Name = "stats_up" }},
{"stats_flowtable", func(st *model.State) {
st.Settings.StatsPerDevice = true
st.Settings.FlowtableDevices = []string{"wan", "eth1"}
}},
{"stats_no_external", func(st *model.State) {
st.Settings.StatsPerDevice = true
st.Zones[0].External = false
}},
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }}, {"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }}, {"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }}, {"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
@@ -156,3 +165,29 @@ func TestHostWarnings(t *testing.T) {
} }
} }
} }
func TestNoExternalZone(t *testing.T) {
st := base()
if got := codes(Check(st, nil)); strings.Contains(got, "no_external_zone") {
t.Errorf("an external zone, yet: [%s]", got)
}
st.Zones[0].External = false
if got := codes(Check(st, nil)); !strings.Contains(got, "no_external_zone") {
t.Errorf("no external zone, and no warning: [%s]", got)
}
// An empty configuration applies nothing, so has no fallback either.
if got := codes(Check(&model.State{Settings: model.DefaultSettings()}, nil)); strings.Contains(got, "no_external_zone") {
t.Errorf("empty configuration: [%s]", got)
}
}
func TestVLANInterfaces(t *testing.T) {
st := base()
st.Zones[1].Interfaces = []string{"enp2s0.10", "enp2s0.4094", "br0.20", "enp3s0.*"}
st.Settings.FlowtableDevices = []string{"enp2s0.10"}
for _, p := range Check(st, nil) {
if p.Severity == Error {
t.Errorf("VLAN interfaces refused: %+v", p)
}
}
}
+6
View File
@@ -5,6 +5,8 @@ import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDra
import * as changes from "./pages/changes.js"; import * as changes from "./pages/changes.js";
import * as connections from "./pages/connections.js"; import * as connections from "./pages/connections.js";
import * as tracePage from "./pages/trace.js"; import * as tracePage from "./pages/trace.js";
import * as matrixPage from "./pages/matrix.js";
import * as statsPage from "./pages/stats.js";
import * as dashboard from "./pages/dashboard.js"; import * as dashboard from "./pages/dashboard.js";
import * as rules from "./pages/rules.js"; import * as rules from "./pages/rules.js";
import * as nat from "./pages/nat.js"; import * as nat from "./pages/nat.js";
@@ -24,12 +26,14 @@ const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" }, { pattern: /^$/, page: dashboard, nav: "dashboard" },
{ pattern: /^rules$/, page: rules, nav: "rules" }, { pattern: /^rules$/, page: rules, nav: "rules" },
{ pattern: /^nat$/, page: nat, nav: "nat" }, { pattern: /^nat$/, page: nat, nav: "nat" },
{ pattern: /^matrix$/, page: matrixPage, nav: "matrix" },
{ pattern: /^zones$/, page: zones, nav: "zones" }, { pattern: /^zones$/, page: zones, nav: "zones" },
{ pattern: /^sets$/, page: sets, nav: "sets" }, { pattern: /^sets$/, page: sets, nav: "sets" },
{ pattern: /^services$/, page: services, nav: "services" }, { pattern: /^services$/, page: services, nav: "services" },
{ pattern: /^log$/, page: log, nav: "log" }, { pattern: /^log$/, page: log, nav: "log" },
{ pattern: /^connections$/, page: connections, nav: "connections" }, { pattern: /^connections$/, page: connections, nav: "connections" },
{ pattern: /^trace$/, page: tracePage, nav: "trace" }, { pattern: /^trace$/, page: tracePage, nav: "trace" },
{ pattern: /^stats$/, page: statsPage, nav: "stats" },
{ pattern: /^settings$/, page: settings, nav: "settings" }, { pattern: /^settings$/, page: settings, nav: "settings" },
{ pattern: /^import$/, page: importPage, nav: "import" }, { pattern: /^import$/, page: importPage, nav: "import" },
{ pattern: /^changes$/, page: changes, nav: "changes" }, { pattern: /^changes$/, page: changes, nav: "changes" },
@@ -45,6 +49,7 @@ const routes = [
const navItems = [ const navItems = [
["dashboard", "#/", "Dashboard"], ["dashboard", "#/", "Dashboard"],
["rules", "#/rules", "Rules", "config"], ["rules", "#/rules", "Rules", "config"],
["matrix", "#/matrix", "Matrix", "config"],
["nat", "#/nat", "NAT", "config"], ["nat", "#/nat", "NAT", "config"],
{ menu: "Objects", items: [ { menu: "Objects", items: [
["zones", "#/zones", "Zones", "config"], ["zones", "#/zones", "Zones", "config"],
@@ -52,6 +57,7 @@ const navItems = [
["services", "#/services", "Services", "config"], ["services", "#/services", "Services", "config"],
] }, ] },
{ menu: "Traffic", items: [ { menu: "Traffic", items: [
["stats", "#/stats", "Statistics", "log"],
["log", "#/log", "Log", "log"], ["log", "#/log", "Log", "log"],
["connections", "#/connections", "Connections", "log"], ["connections", "#/connections", "Connections", "log"],
["trace", "#/trace", "Trace", "log"], ["trace", "#/trace", "Trace", "log"],
+351
View File
@@ -0,0 +1,351 @@
// Small SVG charts for the statistics page: a line/area chart with a
// crosshair tooltip, a weekday x hour heatmap, and bar lists. No library;
// colors come from CSS custom properties (--series-N, --heat-N) so light
// and dark themes are handled in style.css. All text is inserted as text,
// never as markup.
import { h } from "./ui.js";
const NS = "http://www.w3.org/2000/svg";
function svg(tag, attrs, ...children) {
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v !== undefined && v !== null && v !== false) el.setAttribute(k, v);
}
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue;
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
}
return el;
}
// compact formats a count: 1,234 / 12.9K / 4.2M.
export function compact(n) {
const a = Math.abs(n);
if (a >= 1e9) return (n / 1e9).toFixed(a >= 1e10 ? 0 : 1).replace(/\.0$/, "") + "B";
if (a >= 1e6) return (n / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M";
if (a >= 1e4) return (n / 1e3).toFixed(a >= 1e5 ? 0 : 1).replace(/\.0$/, "") + "K";
return Math.round(n).toLocaleString();
}
// niceTicks returns 0..max in clean steps (1, 2 or 5 x 10^k), at most n+1 ticks.
export function niceTicks(max, n = 4) {
if (max <= 0) return [0, 1];
const raw = max / n;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw);
const ticks = [];
for (let v = 0; v < max + step * 0.999; v += step) ticks.push(Math.round(v * 1e6) / 1e6);
return ticks;
}
const MIN = 60, HOUR = 3600, DAY = 86400;
const tickSteps = [5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN, HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 30 * DAY];
// timeTicks picks local-time-aligned ticks between t0 and t1 (Unix seconds).
function timeTicks(t0, t1, maxTicks) {
const span = t1 - t0;
const step = tickSteps.find((s) => span / s <= maxTicks) || 30 * DAY;
const out = [];
const d = new Date(t0 * 1000);
if (step >= DAY) {
d.setHours(0, 0, 0, 0);
} else {
d.setSeconds(0, 0);
const mins = d.getHours() * 60 + d.getMinutes();
const stepMin = step / 60;
d.setHours(0, Math.floor(mins / stepMin) * stepMin, 0, 0);
}
for (let t = d.getTime() / 1000; t <= t1; ) {
if (t >= t0) out.push(t);
if (step >= DAY) {
const nd = new Date(t * 1000);
nd.setDate(nd.getDate() + step / DAY);
t = nd.getTime() / 1000;
} else t += step;
}
return { ticks: out, step };
}
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const pad = (n) => String(n).padStart(2, "0");
function tickLabel(t, step) {
const d = new Date(t * 1000);
if (step >= DAY) return step >= 7 * DAY ? months[d.getMonth()] + " " + d.getDate() : weekdays[d.getDay()] + " " + d.getDate();
const hm = pad(d.getHours()) + ":" + pad(d.getMinutes());
return d.getHours() === 0 && d.getMinutes() === 0 ? weekdays[d.getDay()] + " " + d.getDate() : hm;
}
// longTime describes a bucket for tooltips and tables.
export function bucketLabel(t, stepSecs) {
const d = new Date(t * 1000);
const day = weekdays[d.getDay()] + " " + d.getDate() + " " + months[d.getMonth()];
if (stepSecs >= DAY) return day;
const end = new Date((t + stepSecs) * 1000);
const hm = (x) => pad(x.getHours()) + ":" + pad(x.getMinutes());
return day + ", " + hm(d) + (stepSecs > MIN ? "–" + hm(end) : "");
}
export const stepSeconds = { "1m": MIN, "5m": 5 * MIN, "15m": 15 * MIN, "1h": HOUR, "6h": 6 * HOUR, "1d": DAY };
// tooltip is one floating readout per chart.
function tooltip() {
const el = h("div", { class: "chart-tip", role: "status" });
el.hidden = true;
return el;
}
// chartCard wraps a chart with its title and a table-view toggle (the
// accessible twin of every chart).
export function chartCard(title, subtitle, chart, tableView, opts = {}) {
const body = h("div", { class: "chart-body" }, chart);
const tableBox = h("div", { class: "chart-table" }, tableView);
tableBox.hidden = true;
const toggle = h("button", { class: "small link", type: "button", "aria-pressed": "false" }, "Table");
toggle.addEventListener("click", () => {
const showTable = tableBox.hidden;
tableBox.hidden = !showTable;
body.hidden = showTable;
toggle.textContent = showTable ? "Chart" : "Table";
toggle.setAttribute("aria-pressed", String(showTable));
});
return h("figure", { class: "panel chart-card" + (opts.wide ? " wide" : "") },
h("figcaption", { class: "chart-head" },
h("div", { class: "grow" }, h("h2", null, title), subtitle ? h("div", { class: "muted small" }, subtitle) : null),
opts.legend || null,
tableView ? toggle : null),
body, tableBox);
}
// legend lists series with a short line key (the mark the series uses).
export function legend(series) {
return h("ul", { class: "chart-legend" }, series.map((s) =>
h("li", null, h("span", { class: "key line s" + s.slot }), s.label)));
}
// endLabelWidth is the room lineChart needs for the end labels of series.
export function endLabelWidth(series) {
return series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
}
// lineChart draws series over time. series: [{label, values, slot}] (slot
// = categorical color 1..3); times: bucket starts (Unix seconds); step in
// seconds. With area, the first series gets a 10% wash under its line.
// gutter {left, right} sets the least room for the axis labels and the end
// labels: charts shown one above the other pass the same gutter, so their
// time axes line up. format formats values (axis and readout); a null
// value is a gap in the line (no data for that time).
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true, gutter = {}, format = compact }) {
const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" });
const tip = tooltip();
root.append(tip);
let width = 0, focusIdx = -1, geom = null;
function draw() {
const w = root.clientWidth;
if (!w || w === width) return;
width = w;
root.querySelector("svg")?.remove();
const max = Math.max(1, ...series.flatMap((s) => s.values).filter((v) => v != null));
const yTicks = niceTicks(max, 4);
const yMax = yTicks[yTicks.length - 1];
const labelW = Math.max(...yTicks.map((t) => format(t).length)) * 7 + 10;
const endW = endLabels ? endLabelWidth(series) : 8;
const m = { top: 10, right: Math.max(endW, gutter.right || 0), bottom: 24, left: Math.max(labelW, gutter.left || 0) };
const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom;
const t0 = times[0], t1 = times[times.length - 1] + step;
const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw;
const cx = (i) => x(times[i] + step / 2); // the middle of a bucket
const y = (v) => m.top + ph - (v / yMax) * ph;
const s = svg("svg", { width: w, height, viewBox: `0 0 ${w} ${height}`, role: "img", "aria-hidden": "true" });
// Grid and axes.
for (const t of yTicks) {
s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) }));
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, format(t)));
}
const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90)));
for (const t of ticks) {
s.append(svg("text", { class: "tick", x: x(t), y: height - 6, "text-anchor": "middle" }, tickLabel(t, tstep)));
}
// Series.
series.forEach((sr, k) => {
// A gap (null) starts a new piece of line.
const pts = sr.values.map((v, i) => (v == null ? null : [cx(i), y(v)]));
const d = pts.map((p, i) => (p ? (i && pts[i - 1] ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1) : "")).join("");
if (area && k === 0 && pts.length && pts.every(Boolean)) {
s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` }));
}
s.append(svg("path", { class: "line s" + sr.slot, d }));
// A point without neighbours has no line: show it as a dot.
pts.forEach((p, i) => {
if (p && !pts[i - 1] && !pts[i + 1]) s.append(svg("circle", { class: "dot s" + sr.slot, cx: p[0], cy: p[1], r: 3 }));
});
});
// Direct end labels when there is room and they don't collide.
if (m.right > 8) {
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values.findLast((v) => v != null) || 0) })).sort((a, b) => a.y - b.y);
if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) {
for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label));
}
}
// Crosshair (drawn on hover/focus).
const cross = svg("line", { class: "crosshair", y1: m.top, y2: m.top + ph, visibility: "hidden" });
const dots = series.map((sr) => svg("circle", { class: "dot s" + sr.slot, r: 4, visibility: "hidden" }));
s.append(cross, ...dots);
// The hit area is the whole plot: readers aim at a time, not a line.
const hit = svg("rect", { class: "hit", x: m.left, y: m.top, width: pw, height: ph });
s.append(hit);
root.prepend(s);
geom = { cx, y, m, pw, ph, cross, dots, hit };
hit.addEventListener("pointermove", (e) => {
const r = s.getBoundingClientRect();
const px = e.clientX - r.left;
const i = Math.round(((px - m.left) / pw) * (times.length) - 0.5);
show(Math.max(0, Math.min(times.length - 1, i)));
});
hit.addEventListener("pointerleave", hide);
if (focusIdx >= 0) show(focusIdx);
}
function show(i) {
if (!geom) return;
focusIdx = i;
const xpos = geom.cx(i);
geom.cross.setAttribute("x1", xpos);
geom.cross.setAttribute("x2", xpos);
geom.cross.setAttribute("visibility", "visible");
series.forEach((sr, k) => {
geom.dots[k].setAttribute("cx", xpos);
geom.dots[k].setAttribute("cy", geom.y(sr.values[i] ?? 0));
geom.dots[k].setAttribute("visibility", sr.values[i] == null ? "hidden" : "visible");
});
tip.replaceChildren(
h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
...series.map((sr) => h("div", { class: "tip-row" },
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i] == null ? "–" : format === compact ? sr.values[i].toLocaleString() : format(sr.values[i])), " ", h("span", { class: "muted" }, sr.label))));
tip.hidden = false;
// Keep the readout inside the chart, on the far side of the pointer.
const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12;
tip.style.left = Math.max(0, left) + "px";
tip.style.top = geom.m.top + "px";
}
function hide() {
if (!geom) return;
geom.cross.setAttribute("visibility", "hidden");
geom.dots.forEach((d) => d.setAttribute("visibility", "hidden"));
tip.hidden = true;
focusIdx = -1;
}
// Keyboard: arrows move the crosshair, as hover does.
root.addEventListener("focus", () => show(focusIdx >= 0 ? focusIdx : times.length - 1));
root.addEventListener("blur", hide);
root.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
show(Math.max(0, Math.min(times.length - 1, (focusIdx < 0 ? times.length - 1 : focusIdx) + (e.key === "ArrowLeft" ? -1 : 1))));
}
});
new ResizeObserver(draw).observe(root);
return root;
}
// timeTable is the table view of a line chart (busiest buckets first is
// not useful here: keep time order, newest first).
export function timeTable(series, times, step, format) {
const cell = (v) => (v == null ? "–" : format ? format(v) : v.toLocaleString());
const rows = times.map((t, i) => ({ t, i })).reverse();
return h("div", { class: "table-wrap scroll" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))),
h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)),
series.map((s) => h("td", { class: "num" }, cell(s.values[r.i]))))))));
}
// heatmap draws average activity by weekday (rows, Monday first) and hour
// (columns) with a sequential one-hue ramp; cells without data stay empty.
export function heatmap(hm, unit) {
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let peak = 0;
hm.avg.forEach((row) => row.forEach((v) => { peak = Math.max(peak, v); }));
const steps = 7;
const level = (v) => (peak <= 0 ? 0 : Math.min(steps - 1, Math.floor((v / peak) * steps)));
const tip = tooltip();
const grid = h("div", { class: "heatmap", role: "grid", "aria-label": "Average " + unit + " per hour by weekday and hour of day" });
grid.append(h("div", { class: "hm-corner" }));
for (let hr = 0; hr < 24; hr++) grid.append(h("div", { class: "hm-col" }, hr % 3 === 0 ? pad(hr) : ""));
days.forEach((day, d) => {
grid.append(h("div", { class: "hm-row" }, day));
for (let hr = 0; hr < 24; hr++) {
const v = hm.avg[d][hr];
const n = hm.samples[d][hr];
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)");
const cell = h("div", { class: "hm-cell " + (v < 0 ? "hm-empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
const showTip = () => {
tip.textContent = text;
tip.hidden = false;
const r = cell.getBoundingClientRect(), pr = grid.getBoundingClientRect();
tip.style.left = Math.max(0, Math.min(pr.width - tip.offsetWidth, r.left - pr.left - tip.offsetWidth / 2 + r.width / 2)) + "px";
tip.style.top = (r.bottom - pr.top + 6) + "px";
};
cell.addEventListener("pointerenter", showTip);
cell.addEventListener("focus", showTip);
cell.addEventListener("pointerleave", () => { tip.hidden = true; });
cell.addEventListener("blur", () => { tip.hidden = true; });
grid.append(cell);
}
});
// Arrow keys move between cells.
grid.tabIndex = 0;
let cur = -1;
const cells = () => [...grid.querySelectorAll(".hm-cell")];
grid.addEventListener("keydown", (e) => {
const delta = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -24, ArrowDown: 24 }[e.key];
if (delta === undefined) return;
e.preventDefault();
cur = Math.max(0, Math.min(7 * 24 - 1, (cur < 0 ? 0 : cur) + delta));
cells()[cur].focus();
});
const scale = h("div", { class: "hm-scale" },
h("span", { class: "muted small" }, "0"),
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
h("span", { class: "hm-swatch hm-empty" }), h("span", { class: "muted small" }, "no data"));
return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
}
export function heatmapTable(hm, unit) {
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
return h("div", { class: "table-wrap scroll" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Hour"), days.map((d) => h("th", { class: "num" }, d)))),
h("tbody", null, Array.from({ length: 24 }, (_, hr) => h("tr", null, h("td", null, pad(hr) + ":00"),
days.map((_, d) => h("td", { class: "num" }, hm.avg[d][hr] < 0 ? "–" : Math.round(hm.avg[d][hr]).toLocaleString())))))),
h("div", { class: "muted small" }, "Average " + unit + " per hour."));
}
// barList shows ranked entries as labelled horizontal bars in one hue
// (one series: no legend, the title names it). Values are always printed,
// so the list is its own table view.
// extra(key), when given, adds something after an entry's label (e.g. a
// link that excludes it).
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact } = {}) {
if (!entries.length) return h("div", { class: "empty" }, empty);
// Entries may be in a natural order (e.g. latency buckets), not by size.
const peak = Math.max(1, ...entries.map((e) => e.count));
const sum = total || entries.reduce((a, e) => a + e.count, 0);
return h("ol", { class: "bar-list" }, entries.map((e) => {
const share = sum ? (e.count / sum) * 100 : 0;
const name = label(e.key);
const link = href ? href(e.key) : null;
// Entries with a name (a rule's label): the name, then the key.
const shown = e.name ? [e.name, h("span", { class: "muted" }, " " + name)] : name;
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" },
h("div", { class: "bar-text" },
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
}));
}
+23 -1
View File
@@ -4,15 +4,19 @@ import { h, mount, table, badge, problemsList, time } from "../ui.js";
import { get, can } from "../api.js"; import { get, can } from "../api.js";
import { onLeave } from "../app.js"; import { onLeave } from "../app.js";
import { describe, hits, humanCount } from "../widgets.js"; import { describe, hits, humanCount } from "../widgets.js";
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
import { bytes } from "./stats.js";
export async function render(el) { export async function render(el) {
const draw = async () => { const draw = async () => {
const st = await get("/status"); const st = await get("/status");
const config = can("config", false), logs = can("log", false); const config = can("config", false), logs = can("log", false);
const [rules, cs, log] = await Promise.all([ const [rules, cs, log, tl, zones] = await Promise.all([
config ? get("/rules") : [], config ? get("/rules") : [],
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {}, logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
logs ? get("/log", { limit: 8 }).catch(() => []) : [], logs ? get("/log", { limit: 8 }).catch(() => []) : [],
logs ? get("/stats/timeline", { since: "24h", step: "1h", keys: "zrx:*,ztx:*,drops" }).catch(() => null) : null,
config ? get("/zones").catch(() => []) : [],
]); ]);
const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0) const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0)
.sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8); .sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8);
@@ -35,6 +39,7 @@ export async function render(el) {
card("Dropped by policy", policyDrops("input") + " / " + policyDrops("forward"), "input / forward", logs ? "#/log" : null), card("Dropped by policy", policyDrops("input") + " / " + policyDrops("forward"), "input / forward", logs ? "#/log" : null),
card("Problems", st.problems.length, st.problems.length ? "see below" : "none", null)), card("Problems", st.problems.length, st.problems.length ? "see below" : "none", null)),
st.problems.length ? h("div", { class: "panel" }, h("h2", null, "Problems"), problemsList(st.problems)) : null, st.problems.length ? h("div", { class: "panel" }, h("h2", null, "Problems"), problemsList(st.problems)) : null,
tl ? statsCharts(tl, zones) : null,
h("div", { class: "panel" }, h("h2", null, "Interfaces"), table([ h("div", { class: "panel" }, h("h2", null, "Interfaces"), table([
{ label: "Interface", render: (i) => [h("span", { class: "dot " + (i.up ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] }, { label: "Interface", render: (i) => [h("span", { class: "dot " + (i.up ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
{ label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") }, { label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") },
@@ -63,3 +68,20 @@ export async function render(el) {
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000); const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
onLeave(() => clearInterval(t)); onLeave(() => clearInterval(t));
} }
// statsCharts: the last 24 hours' traffic with the internet and drops,
// per hour (the Statistics page has the rest).
function statsCharts(tl, zones) {
const ext = new Set(zones.filter((z) => z.external).map((z) => z.name));
const step = stepSeconds[tl.step];
const total = (prefix) => tl.times.map((_, i) => tl.series.filter((x) => x.key.startsWith(prefix) && ext.has(x.key.slice(prefix.length))).reduce((a, x) => a + x.values[i], 0));
const traffic = [{ label: "Downloaded", values: total("zrx:"), slot: 1 }, { label: "Uploaded", values: total("ztx:"), slot: 2 }];
const drops = [{ label: "Dropped", values: (tl.series.find((x) => x.key === "drops") || { values: tl.times.map(() => 0) }).values, slot: 2 }];
const gutter = { left: 64, right: 8 };
return h("div", { class: "chart-grid" },
ext.size ? chartCard("Internet traffic", "bytes per hour from and to external zones, last 24 hours",
lineChart({ series: traffic, times: tl.times, step, area: true, height: 150, endLabels: false, gutter, format: bytes }),
timeTable(traffic, tl.times, step, bytes), { legend: legend(traffic) }) : null,
chartCard("Dropped packets", h("span", null, "per hour, last 24 hours · ", h("a", { href: "#/stats" }, "Statistics")),
lineChart({ series: drops, times: tl.times, step, area: true, height: 150, endLabels: false, gutter }), timeTable(drops, tl.times, step)));
}
@@ -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 });
}
+5 -3
View File
@@ -27,6 +27,7 @@ export async function render(el) {
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" }); const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" }); const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" }); const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
const perDevice = checkbox("count traffic per inside device (IPv6 per /64), for the Statistics page's top devices: a counter for each address talking to an external zone", s.stats_per_device);
const manual = checkbox("a person confirms each change (otherwise the browser or hc that made it confirms it by itself, once it reaches Housecarl again over a new connection)", s.manual_confirm); const manual = checkbox("a person confirms each change (otherwise the browser or hc that made it confirms it by itself, once it reaches Housecarl again over a new connection)", s.manual_confirm);
const err = h("div"); const err = h("div");
const save = async (dry) => { const save = async (dry) => {
@@ -38,7 +39,7 @@ export async function render(el) {
try { try {
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value, const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(), log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p, manual_confirm: manual.input.checked }, { query: dry ? { dry_run: 1 } : undefined }); flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p, manual_confirm: manual.input.checked, stats_per_device: perDevice.input.checked }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && (r.applied || r.staged)) rerender(); if (!dry && (r.applied || r.staged)) rerender();
} catch (e) { mount(err, errorBox(e)); } } catch (e) { mount(err, errorBox(e)); }
}; };
@@ -54,9 +55,10 @@ export async function render(el) {
field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."), field("Your prefixes", own, "Your public prefixes (your delegated IPv6 /48): they can't come from outside."),
field("Antispoof IPv4", v4), field("Antispoof IPv4", v4),
field("Antispoof IPv6", v6), field("Antispoof IPv6", v6),
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path). Exact names; none: off."), field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path): for a CPU that can't keep up with the line. Forward quotas, byte rates and traffic per device then only count first packets (docs/flowtable.md). Exact names, both sides (wan and lan); none: off."),
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."), field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
h("label"), manual), h("label"), manual,
field("Statistics", perDevice)),
err, err,
write ? h("div", { class: "actions form-actions" }, write ? h("div", { class: "actions form-actions" },
h("button", { class: "primary", onclick: () => save(false) }, "Save"), h("button", { class: "primary", onclick: () => save(false) }, "Save"),
+214
View File
@@ -0,0 +1,214 @@
// Statistics: traffic per zone, drops, blocked sources, devices,
// connections, quotas and the machine over time (docs/stats.md).
import { h, mount, fmtTime, errorBox } from "../ui.js";
import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400 };
let generation = 0;
// The zone the traffic chart shows, kept while the page refreshes.
let shownZone = null;
export async function render(el, params, query) {
const gen = ++generation;
const want = query && query.get ? query.get("since") : null;
const since = rangeSecs[want] ? want : "24h";
const status = await get("/stats/status");
const head = h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Statistics"),
h("div", { class: "muted" }, status.enabled
? "Sampled every minute · data since " + (status.first_data ? fmtTime(status.first_data) : "–") +
" · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day
: "Statistics are off")));
if (!status.enabled) {
mount(el, head, h("div", { class: "panel" },
h("p", null, "Housecarl keeps traffic per zone, drops, blocked sources, connections and quotas over time, in a database of its own. Turn it on in /etc/housecarl/housecarl.yaml and restart housecarl:"),
h("pre", { class: "code" }, "stats:\n enabled: true"),
h("p", { class: "muted" }, "See docs/stats.md.")));
return;
}
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : rangeSecs[since] > 90 * 86400 ? "90d" : since;
let d;
try {
d = await loadAll(since, heatSince);
} catch (e) {
mount(el, head, rangeRow(since), errorBox(e));
return;
}
if (gen !== generation) return; // the user moved on
const s = d.summary;
const rangeLabel = "in the last " + ranges.find((r) => r[0] === since)[1];
const parts = [head, rangeRow(since)];
if (status.sample_error) parts.push(h("div", { class: "msg warn" }, "The last sample failed: " + status.sample_error));
// Headline numbers: what came from and went to the external zones.
const ext = new Set(d.zones.filter((z) => z.external).map((z) => z.name));
const sum = (f) => s.zones.filter((z) => ext.has(z.name)).reduce((a, z) => a + f(z), 0);
const l = status.latest || {};
parts.push(h("div", { class: "cards" },
ext.size ? tile("Downloaded", bytes(sum((z) => z.received)), "from external zones, " + rangeLabel) : null,
ext.size ? tile("Uploaded", bytes(sum((z) => z.sent)), "to external zones") : null,
tile("Dropped", compact(s.drops), "packets: rules, built-ins and policies"),
s.conntrack_max ? tile("Connections", compact(s.peak_connections), "most at once (of " + compact(s.conntrack_max) + " allowed)" + (l.conntrack != null ? " · now " + compact(l.conntrack) : "")) : null));
// Traffic per zone: one zone at a time, received and sent.
const traffic = complete(d.traffic);
const step = stepSeconds[traffic.step];
const zoneNames = s.zones.map((z) => z.name);
if (!zoneNames.includes(shownZone)) shownZone = zoneNames.find((z) => ext.has(z)) || zoneNames[0] || null;
const trafficBox = h("div");
const drawTraffic = () => {
const pick = h("div", { class: "segmented" }, zoneNames.map((z) =>
h("button", { type: "button", class: z === shownZone ? "active" : "", onclick: () => { shownZone = z; drawTraffic(); } }, z)));
const series = [
{ label: "Received", values: values(traffic, "zrx:" + shownZone), slot: 1 },
{ label: "Sent", values: values(traffic, "ztx:" + shownZone), slot: 2 },
];
mount(trafficBox, chartCard("Traffic" + (shownZone ? ": " + shownZone : ""),
"bytes per " + stepName(traffic.step) + " received from and sent to the zone's interfaces, " + rangeLabel,
shownZone ? h("div", null, zoneNames.length > 1 ? h("div", { class: "filter-row" }, pick) : null,
lineChart({ series, times: traffic.times, step, area: true, gutter: { left: 64, right: endLabelWidth(series) }, format: bytes }))
: h("div", { class: "empty" }, "No traffic recorded yet: interfaces count once they are in a zone."),
shownZone ? timeTable(series, traffic.times, step, bytes) : null, { wide: true, legend: shownZone ? legend(series) : null }));
};
drawTraffic();
parts.push(trafficBox);
if (s.zones.length) {
parts.push(h("div", { class: "chart-grid" },
listCard("Received per zone", rangeLabel, barList(s.zones.map((z) => ({ key: z.name, count: z.received })), { format: bytes })),
listCard("Sent per zone", rangeLabel, barList(s.zones.map((z) => ({ key: z.name, count: z.sent })), { format: bytes }))));
}
// Drops over time, by chain, and when they happen.
const drops = complete(d.drops);
const dSeries = [{ label: "Dropped", values: values(drops, "drops"), slot: 2 }];
const anyDrops = dSeries[0].values.some((v) => v > 0);
parts.push(chartCard("Dropped packets", "per " + stepName(drops.step) + ", by rules, built-ins and policies that drop, " + rangeLabel,
anyDrops ? lineChart({ series: dSeries, times: drops.times, step: stepSeconds[drops.step], area: true, gutter: { left: 64, right: 8 } }) : h("div", { class: "empty" }, "Nothing dropped " + rangeLabel + "."),
anyDrops ? timeTable(dSeries, drops.times, stepSeconds[drops.step]) : null, { wide: true }));
parts.push(chartCard("When drops happen", "average dropped packets per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
heatmap(d.heat, "packets"), heatmapTable(d.heat, "packets"), { wide: true }));
// Who and what.
const unused = d.hits.filter((e) => e.count === 0 && e.key.startsWith("r"));
const devHint = h("div", { class: "empty" }, status.per_device
? "No traffic per device " + rangeLabel + "."
: ["Traffic per device is off. Turn it on under ", h("a", { href: "#/settings" }, "Settings"), " (it adds a counter for each inside address)."]);
parts.push(h("div", { class: "chart-grid" },
listCard("Busiest rules", "packets each counted, " + rangeLabel, barList(s.rules, { label: (k) => k, href: (k) => ruleHref(k) })),
listCard("Dropped, by chain", "early: antispoofing · invalid: packets conntrack can't place", barList(s.drops_by_chain, { empty: "Nothing dropped " + rangeLabel + "." })),
listCard("Blocked most", "sources of logged drops (the policies' with 'log drops' on, and rules that log)", barList(s.blocked_sources, { empty: "No logged drops " + rangeLabel + ".", href: (k) => "#/trace?address=" + encodeURIComponent(k) })),
listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })),
listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint),
listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)),
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => "rule #" + k.slice(1), href: () => "#/rules" })) : null,
listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove",
unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))),
unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something."))));
// Connections and the machine.
const g = complete(d.gauges);
const gStep = stepSeconds[g.step];
const gauge = (k, f = (v) => v) => values(g, k).map((v) => (v == null || v < 0 ? null : f(v)));
const any = (ss) => ss.some((x) => x.values.some((v) => v != null));
const cSeries = [{ label: "Tracked", values: gauge("g:conntrack"), slot: 1 }];
const mSeries = [{ label: "Host in use", values: gauge("g:mem"), slot: 1 }, { label: "housecarld", values: gauge("g:rss"), slot: 2 }];
const lSeries = [{ label: "Load", values: gauge("g:load", (v) => v / 100), slot: 1 }, { label: "housecarld CPU %", values: gauge("g:cpu", (v) => v / 10), slot: 2 }];
const gutter = { left: 64, right: Math.max(endLabelWidth(cSeries), endLabelWidth(mSeries), endLabelWidth(lSeries)) };
const empty = h("div", { class: "empty" }, "No samples yet: they are taken once a minute.");
parts.push(h("h2", { class: "section" }, "Connections and the machine"));
parts.push(chartCard("Tracked connections", "average per " + stepName(g.step) + (l.conntrack_max ? "; the kernel allows " + compact(l.conntrack_max) + " (nf_conntrack_max), and drops new connections beyond" : ""),
any(cSeries) ? lineChart({ series: cSeries, times: g.times, step: gStep, area: true, gutter }) : empty, any(cSeries) ? timeTable(cSeries, g.times, gStep) : null, { wide: true }));
parts.push(chartCard("Memory", "average per " + stepName(g.step) + (l.rss ? " · housecarld now " + bytes(l.rss) : ""),
any(mSeries) ? lineChart({ series: mSeries, times: g.times, step: gStep, gutter, format: bytes }) : empty.cloneNode(true), any(mSeries) ? timeTable(mSeries, g.times, gStep, bytes) : null, { wide: true, legend: legend(mSeries) }));
parts.push(chartCard("Load", "the host's 1-minute load average, and housecarld's CPU (percent of one CPU)",
any(lSeries) ? lineChart({ series: lSeries, times: g.times, step: gStep, gutter, format: (v) => (Math.round(v * 100) / 100).toString() }) : empty.cloneNode(true),
any(lSeries) ? timeTable(lSeries, g.times, gStep, (v) => v.toFixed(2)) : null, { wide: true, legend: legend(lSeries) }));
el.classList.remove("refreshing");
mount(el, parts);
scheduleRefresh(el, params, query, gen);
}
async function loadAll(since, heatSince) {
const [summary, zones, traffic, drops, heat, hits, gauges, quotas] = await Promise.all([
get("/stats/summary", { since, limit: 10 }),
get("/zones"),
get("/stats/timeline", { since, keys: "zrx:*,ztx:*" }),
get("/stats/timeline", { since, keys: "drops" }),
get("/stats/heatmap", { since: heatSince }),
get("/stats/hits", { since: "30d" }),
get("/stats/timeline", { since, keys: "g:conntrack,g:conntrack_max,g:mem,g:rss,g:load,g:cpu" }),
get("/stats/timeline", { since, keys: "quota:*" }),
]);
return { summary, zones, traffic, drops, heat, hits, gauges,
quotas: quotas.series.map((x) => ({ key: x.key.slice(6), count: x.values.reduce((a, v) => a + Math.max(v, 0), 0) })).filter((e) => e.count > 0).sort((a, b) => b.count - a.count) };
}
function scheduleRefresh(el, params, query, gen) {
setTimeout(() => {
const hh = location.hash;
if (gen !== generation || !(hh === "#/stats" || hh.startsWith("#/stats?")) || !el.isConnected) return;
el.classList.add("refreshing");
render(el, params, query).catch(() => el.classList.remove("refreshing"));
}, 60_000);
}
// complete drops the last bucket of a timeline if it hasn't ended yet:
// drawn, it would look like a sudden fall.
function complete(tl) {
const step = stepSeconds[tl.step];
const n = tl.times.length;
if (n > 1 && tl.times[n - 1] + step > Date.now() / 1000) {
return { ...tl, times: tl.times.slice(0, -1), series: tl.series.map((x) => ({ ...x, values: x.values.slice(0, -1) })) };
}
return tl;
}
function values(tl, key) {
return (tl.series.find((x) => x.key === key) || { values: tl.times.map(() => 0) }).values;
}
function rangeRow(since) {
return h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
h("span", { class: "muted" }, "Range"),
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k === "365d" ? "1y" : k))));
}
// ruleHref: a counter's mark to its page ("r12" → the rules, "b-…" → the
// built-ins under Settings).
function ruleHref(mark) {
if (/^r\d/.test(mark)) return "#/rules";
if (mark.startsWith("b-") || mark.startsWith("p-")) return "#/settings";
if (mark.startsWith("f")) return "#/nat";
return null;
}
function connHref(addr) {
return "#/connections?q=" + encodeURIComponent(addr.replace(/::\/64$/, ":"));
}
// bytes formats a size in binary units.
export function bytes(n) {
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return (i === 0 || n >= 100 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")) + " " + units[i];
}
function tile(label, value, sub) {
return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub));
}
function listCard(title, subtitle, body) {
return h("section", { class: "panel" }, h("h2", null, title), subtitle ? h("div", { class: "muted small list-sub" }, subtitle) : null, body);
}
function stepName(step) {
return { "1m": "minute", "5m": "5 minutes", "15m": "15 minutes", "1h": "hour", "6h": "6 hours", "1d": "day" }[step] || step;
}
+2 -2
View File
@@ -4,8 +4,8 @@ import { h, mount, input, select, field, errorBox, badge } from "../ui.js";
import { get, api, can } from "../api.js"; import { get, api, can } from "../api.js";
import { onLeave } from "../app.js"; import { onLeave } from "../app.js";
export async function render(el) { export async function render(el, params, query) {
const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono" }); const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono", value: (query && query.get && query.get("address")) || "" });
const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 }); const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 });
const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" }); const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" });
const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 }); const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 });
+144
View File
@@ -419,3 +419,147 @@ table.trace-steps { width: 100%; margin-top: 6px; }
table.trace-steps td { padding: 3px 8px; border-top: 1px solid var(--border); } table.trace-steps td { padding: 3px 8px; border-top: 1px solid var(--border); }
tr.step-drop td { background: var(--del); } tr.step-drop td { background: var(--del); }
tr.step-accept td { background: var(--add); } 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); }
/* ---- Statistics charts. Colors from the validated reference palette
(categorical slots 1-3, sequential blue), checked against this UI's
panel surfaces in both themes. */
:root {
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--chart-grid: #eceef2;
--chart-axis: #c9cfd8;
/* Sequential ramp, low to high (blue 100 -> 700). */
--heat-0: #cde2fb; --heat-1: #9ec5f4; --heat-2: #6da7ec; --heat-3: #3987e5;
--heat-4: #256abf; --heat-5: #184f95; --heat-6: #0d366b;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--chart-grid: #272c34;
--chart-axis: #3a414d;
/* In dark, low values recede into the surface: the ramp runs dark -> light. */
--heat-0: #0d366b; --heat-1: #184f95; --heat-2: #256abf; --heat-3: #3987e5;
--heat-4: #6da7ec; --heat-5: #9ec5f4; --heat-6: #cde2fb;
}
}
:root[data-theme="dark"] {
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--chart-grid: #272c34;
--chart-axis: #3a414d;
--heat-0: #0d366b; --heat-1: #184f95; --heat-2: #256abf; --heat-3: #3987e5;
--heat-4: #6da7ec; --heat-5: #9ec5f4; --heat-6: #cde2fb;
}
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
/* Secondary row actions show when the row is hovered or focused; touch
screens, which can't hover, always show them. Opacity keeps them in the
layout (no shifting) and reachable with Tab. */
tr .reveal { opacity: 0; transition: opacity 0.1s; }
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
@media (hover: none) { tr .reveal { opacity: 1; } }
/* Blocking steps, in order, on the Blocking page. */
.steps-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 4px 0 10px; }
.step-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 12px; font-family: var(--mono); font-size: 13px; background: var(--panel); }
.step-chip.accent { border-color: var(--accent); }
.step-tools { display: inline-flex; gap: 2px; margin-left: 2px; }
.step-tools button { padding: 0 5px; min-width: 0; }
h2.section { margin: 24px 0 10px; font-size: 18px; }
.segmented button { border: none; border-right: 1px solid var(--border); border-radius: 0; background: transparent; padding: 4px 12px; color: var(--text); }
.segmented button:last-child { border-right: none; }
.segmented button:hover { background: var(--info-soft); }
.segmented button.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
.card.stat .value.big { font-size: 26px; font-weight: 600; line-height: 1.2; }
.chart-card { margin: 0 0 14px; }
.chart-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.chart-head .grow { flex: 1; min-width: 200px; }
.chart-head h2 { margin: 0; font-size: 16px; }
/* Two or three columns; the last cards stretch to fill their row, so no
card is left alone beside an empty space. */
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.chart-grid > :last-child:nth-child(odd) { grid-column: 1 / -1; }
@media (min-width: 1100px) {
.chart-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.chart-grid > :last-child:nth-child(odd) { grid-column: auto; }
.chart-grid > :last-child:nth-child(3n+1) { grid-column: 1 / -1; }
.chart-grid > :last-child:nth-child(3n+2) { grid-column: span 2; }
}
.chart-grid > .panel h2 { margin: 0; font-size: 16px; }
.list-sub { margin: 2px 0 8px; }
.chart { position: relative; outline: none; }
.chart:focus-visible { box-shadow: 0 0 0 2px var(--accent); border-radius: var(--radius); }
.chart svg { display: block; overflow: visible; }
.chart .grid { stroke: var(--chart-grid); stroke-width: 1; }
.chart .axis { stroke: var(--chart-axis); stroke-width: 1; }
.chart .tick, .chart .end-label { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.chart .end-label { fill: var(--text); }
.chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart .area { stroke: none; fill-opacity: 0.1; }
.chart .dot { stroke: var(--panel); stroke-width: 2; }
.chart .crosshair { stroke: var(--muted); stroke-width: 1; }
.chart .hit { fill: transparent; cursor: crosshair; }
.chart .s1, .key.s1 { stroke: var(--series-1); }
.chart .s2, .key.s2 { stroke: var(--series-2); }
.chart .s3, .key.s3 { stroke: var(--series-3); }
.chart .area.s1, .chart .dot.s1 { fill: var(--series-1); }
.chart .area.s2, .chart .dot.s2 { fill: var(--series-2); }
.chart .area.s3, .chart .dot.s3 { fill: var(--series-3); }
.key.line { display: inline-block; width: 14px; height: 0; border-top: 2px solid; vertical-align: middle; margin-right: 6px; }
.key.line.s1 { border-color: var(--series-1); }
.key.line.s2 { border-color: var(--series-2); }
.key.line.s3 { border-color: var(--series-3); }
.chart-legend { list-style: none; display: flex; gap: 14px; margin: 2px 0 0; padding: 0; font-size: 13px; color: var(--text); flex-wrap: wrap; }
.chart-tip { position: absolute; z-index: 5; pointer-events: none; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16); padding: 6px 10px; font-size: 13px; white-space: nowrap; }
.chart-tip .tip-head { color: var(--muted); font-size: 12px; margin-bottom: 2px; }
.chart-tip .tip-row strong { font-variant-numeric: tabular-nums; }
.chart-table .scroll, .chart-table .table-wrap { max-height: 360px; overflow: auto; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.heatmap-wrap { position: relative; }
.heatmap { display: grid; grid-template-columns: 36px repeat(24, minmax(10px, 1fr)); gap: 2px; outline: none; }
.heatmap:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
.hm-corner, .hm-col, .hm-row { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.hm-row { display: flex; align-items: center; }
.hm-cell { height: 22px; border-radius: 3px; }
.hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; }
.hm-empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
.heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); }
.heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); }
.heat-6 { background: var(--heat-6); }
.hm-scale { display: flex; align-items: center; gap: 2px; margin-top: 10px; flex-wrap: wrap; }
.hm-scale .muted { margin: 0 6px; }
.hm-swatch { display: inline-block; width: 18px; height: 10px; border-radius: 2px; }
.bar-list { list-style: none; margin: 0; padding: 0; }
.bar-list li { padding: 4px 0; }
.bar-text { display: flex; gap: 8px; justify-content: space-between; font-size: 13px; }
.bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12.5px; min-width: 0; }
.bar-value { white-space: nowrap; font-variant-numeric: tabular-nums; }
.bar-track { height: 6px; margin-top: 3px; }
.bar-fill { height: 6px; background: var(--series-1); border-radius: 0 3px 3px 0; min-width: 2px; }
@media (max-width: 700px) {
.heatmap { grid-template-columns: 30px repeat(24, minmax(6px, 1fr)); gap: 1px; }
.hm-cell { height: 16px; }
.chart-grid { grid-template-columns: 1fr; }
}
.plain-list { margin: 0; padding-left: 18px; }
.plain-list li { padding: 2px 0; }
+121 -1
View File
@@ -100,8 +100,9 @@ database: /w/data/housecarl.db
ruleset: /w/data/ruleset.nft ruleset: /w/data/ruleset.nft
backup: {dir: /w/backups} backup: {dir: /w/backups}
log: {level: debug} log: {level: debug}
stats: {database: /w/data/stats.db}
EOF EOF
start() { R sh -c '(/w/housecarld -c /w/housecarl.yaml serve >> /w/srv.log 2>&1 &); for i in $(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1'; } start() { R sh -c '(HOUSECARL_STATS_INTERVAL=3s /w/housecarld -c /w/housecarl.yaml serve >> /w/srv.log 2>&1 &); for i in $(seq 1 30); do [ -S /run/housecarl/housecarl.sock ] && exit 0; sleep 0.3; done; exit 1'; }
# ---- today's router: the hand-written ruleset # ---- today's router: the hand-written ruleset
@@ -272,6 +273,45 @@ run "the reset is audited" 0 H audit --action quota.
contains "quota.reset" contains "quota.reset"
run "the quota rule goes" 0 H rule delete -y "$QR" 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"
# ---- statistics (sampled every 3 seconds here)
run "traffic per device on" 0 H settings set --stats-per-device on --log-drops on
run "the device counters are in the ruleset" 0 R nft list set inet housecarl stats_up_v4
send 500000 >/dev/null 2>&1
run "a connection from the internet, dropped and logged" 1 connect net $WAN4 23
sleep 8
run "statistics are collected" 0 H stats status
contains "Collecting: *yes"
contains "Per device: *on"
run "the summary" 0 H stats --since 1h
contains "^wan *[0-9.]* [KM]B"
contains "Dropped: *[1-9]"
run "traffic per zone over time" 0 H stats traffic --since 1h --step 1m
contains "wan ↓"
run "the devices" 0 H stats top devices --since 1h
contains "^$CLIENT4 *[0-9.]* KB *[0-9.]* KB"
run "the sources blocked" 0 H stats top blocked --since 1h
contains "^$NET4 *[1-9]"
run "what they tried" 0 H stats top tried --since 1h
contains "^tcp/23"
run "hits per rule" 0 H stats rules --since 1h
contains "input policy *[1-9]"
contains "rule #[0-9]* (deleted) *[1-9]"
run "connections over time" 0 H stats connections --since 1h --step 1m
contains "^[0-9-]* [0-9:]* *[0-9]* *[0-9]*"
# ---- connections and tracing # ---- connections and tracing
# A connection that stays open, masqueraded on its way out. # A connection that stays open, masqueraded on its way out.
@@ -315,6 +355,53 @@ sleep 1
run "the log shows it" 0 H log run "the log shows it" 0 H log
contains "hc input #.* drop.*198.18.1.10:.*198.18.1.2:8070" contains "hc input #.* drop.*198.18.1.10:.*198.18.1.2:8070"
# ---- the flowtable, and devices that appear later
run "a flowtable device that doesn't exist yet" 0 H settings set --flowtable wan --flowtable enp2s0 --flowtable hcft0
contains "flowtable_device_missing"
run "the flowtable has the devices that exist" 0 R nft list flowtables
contains "devices = { enp2s0, wan }"
run "forwarded through the flowtable" 0 connect net $WAN4 8096
contains "jellyfin"
run "the device appears" 0 R ip link add hcft0 type veth peer name hcft1
R ip link set hcft0 up
sleep 4
run "applied again with it" 0 R nft list flowtables
contains "hcft0"
run "logged" 0 R grep -c "interfaces changed: applied the ruleset again" /w/srv.log
run "it comes back as a new interface" 0 R sh -c 'ip link del hcft0 && ip link add hcft0 type veth peer name hcft1 && ip link set hcft0 up'
sleep 4
run "applied again" 0 R sh -c 'grep -c "interfaces changed: applied the ruleset again" /w/srv.log | grep -qx 2'
run "the flowtable still has it" 0 R nft list flowtables
contains "hcft0"
# ---- VLAN sub-interfaces: tagged traffic between the router and the client
R sh -c 'ip link add link enp2s0 name enp2s0.30 type vlan id 30 && ip addr add 10.88.30.1/24 dev enp2s0.30 && ip link set enp2s0.30 up'
docker exec ${P}-client sh -c 'ip link add link eth0 name eth0.30 type vlan id 30 && ip addr add 10.88.30.20/24 dev eth0.30 && ip link set eth0.30 up'
run "a zone of a VLAN" 0 H zone add iot --interface enp2s0.30
run "nothing from the VLAN to the API yet" 1 connect client 10.88.30.1 8070
VR=$(H -o id rule add input --in iot --service tcp/8070 --comment "e2e vlan")
run "a rule for the VLAN's zone" 0 test -n "$VR"
run "the VLAN reaches the API" 0 connect client 10.88.30.1 8070
run "the rule counted it" 0 H rule list input
contains "e2e vlan"
run "the flowtable on the VLAN" 0 H settings set --flowtable wan --flowtable enp2s0 --flowtable hcft0 --flowtable enp2s0.30
run "attached" 0 R nft list flowtables
contains "enp2s0.30"
run "the VLAN rule goes" 0 H rule delete -y "$VR"
run "the VLAN zone goes" 0 H zone delete -y iot
R ip link del enp2s0.30
# ---- renaming a zone others refer to
run "rename the imported dhcp zone" 0 H zone update dhcp --name lan
run "its rules follow" 0 H rule list input
contains "lan"
not_contains "dhcp "
run "the ruleset is unchanged by it" 0 R nft list chain inet housecarl input
contains 'iifname "enp2s0" udp dport 67'
# ---- restart and boot # ---- restart and boot
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1 docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
@@ -329,6 +416,39 @@ run "nothing forwarded after a flush" 1 connect net $WAN4 8096
run "the boot unit's command restores it" 0 R nft -f /w/data/ruleset.nft run "the boot unit's command restores it" 0 R nft -f /w/data/ruleset.nft
run "forwarded again" 0 connect net $WAN4 8096 run "forwarded again" 0 connect net $WAN4 8096
contains "jellyfin" contains "jellyfin"
run "the boot ruleset has no flowtable (its devices may not exist yet)" 1 R grep -q flowtable /w/data/ruleset.nft
run "healthz" 0 R curl -sf http://127.0.0.1:8070/healthz
contains '"firewall":"applied"'
# ---- the boot fallback: what the boot unit runs, with the lab's paths
UNIT=$(sed -n 's/^ExecStart=//p' "$ROOT/deploy/systemd/housecarl-ruleset.service" | sed 's|/var/lib/housecarl|/w/data|g; s|/usr/sbin/nft|nft|g')
run "the fallback was saved" 0 R test -s /w/data/ruleset-fallback.nft
R cp /w/data/ruleset.nft /w/ruleset.good
R sh -c 'echo "this is not nftables" >> /w/data/ruleset.nft'
R nft flush ruleset
run "the boot unit fails, and loads the fallback" 1 R sh -c "$UNIT"
contains "the saved ruleset failed to load"
run "the fallback is in force" 0 R nft list table inet housecarl
contains "hc:fallback-input"
run "nothing new gets in from the internet" 1 connect net $WAN4 8096
run "not to the firewall either" 1 connect net $WAN4 8070
: > "$WORK/net.log"
run "the LAN still reaches the internet" 0 connect client $NET4 9000
sleep 1
run "masqueraded" 0 grep -q "$WAN4" "$WORK/net.log"
run "healthz says so" 0 R sh -c 'curl -s -w " %{http_code}" http://127.0.0.1:8070/healthz'
contains '"firewall":"fallback"'
contains " 503"
run "status says so" 0 H status
contains "NOT in force: the boot fallback"
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1
run "housecarld starts" 0 start
run "and puts the real ruleset back" 0 connect net $WAN4 8096
contains "jellyfin"
run "and saves a good boot ruleset" 0 R nft -c -f /w/data/ruleset.nft
run "healthz is happy again" 0 R curl -sf http://127.0.0.1:8070/healthz
contains '"firewall":"applied"'
# ---- backup # ---- backup