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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 22:27:48 +01:00
1 parent 6bb82e86f5
commit cf30715c5a
37 files changed
+3652 -18

No files matched your search

+11
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) {
c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p)
}
@@ -227,6 +233,11 @@ func (c *checker) sets() {
for _, s := range c.st.Sets {
obj := "set " + s.Name
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 {
if _, err := model.ParseAddress(e); err != nil {
c.add(Error, "set_entry", obj, "%v", err)
+9
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_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_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},