Files
housecarl/internal/stats/collector_test.go
T
abrown38andClaude Opus 5.5 ca5ac971e5 Keep built-ins and policies out of the busiest rules
The established built-in passes most of the traffic, so it topped the
statistics' busiest rules every time and made every rule's bar look
empty. The summary now lists rules and NAT objects in rules, and
built-ins and policies in builtins; the Statistics page and hc stats
show them as two lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:02:12 +01:00

254 lines
9.0 KiB
Go

package stats
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"slices"
"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
if h.table.QuotaSets == nil {
h.table.QuotaSets = map[string][]render.Element{}
}
h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 1000}}
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.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 2000}, {Addr: "10.0.10.6", Used: 500}} // a new entry counts whole
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)
check("quota:r6 (per address: the sum)", sum("quota:")["r6"], 1500)
s, err := q.Summary(ctx, time.Hour, 10)
if err != nil {
t.Fatal(err)
}
check("drops", s.Drops, 18)
for _, e := range s.Rules {
if e.Key[0] == 'b' || e.Key[0] == 'p' {
t.Errorf("built-in or policy %s among the busiest rules %+v", e.Key, s.Rules)
}
}
if !slices.ContainsFunc(s.Rules, func(e Entry) bool { return e.Key == "r1" }) || !slices.ContainsFunc(s.BuiltIns, func(e Entry) bool { return e.Key == "p-input" }) {
t.Errorf("rules %+v, built-ins %+v", s.Rules, s.BuiltIns)
}
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])
}
}