Files
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

547 lines
15 KiB
Go

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)
}
}
}