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

+58
View File
@@ -39,6 +39,9 @@ type Options struct {
// quota_r12_v4), so a reload keeps what was used.
Quotas map[string]int64
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
@@ -47,6 +50,9 @@ type Element struct {
Addr string `json:"address"`
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
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
@@ -110,6 +116,7 @@ func (r *renderer) render() {
w.emit("", "delete table inet %s", Table)
w.emit("", "table inet %s {", Table)
r.sets()
r.deviceSets()
devices := r.flowtableDevices()
if len(devices) > 0 {
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.
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.rules(model.ChainForward, true)
r.deviceCounts()
if flowtable {
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
}
+30 -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
// ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g.
//
@@ -217,9 +242,12 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
if len(cmd) == 0 {
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
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState(), "stats": stats} {
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.Stdin = strings.NewReader(rs.Text)
if out, err := c.CombinedOutput(); err != nil {