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>
137 lines
3.7 KiB
Go
137 lines
3.7 KiB
Go
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
|
|
}
|