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

267 lines
7.5 KiB
Go

package server
import (
"net/http"
"strconv"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/stats"
"housecarl/internal/validate"
)
// Statistics: the firewall's figures over time (internal/stats).
// StatsSources fills in what the statistics collector needs from the
// server: the interfaces' zones, and which counters count drops.
func (s *Server) StatsSources(src *stats.Sources) {
src.Zones = s.interfaceZones
src.Classify = s.classifyMark
}
// SetStats gives the server the statistics collector (nil: statistics
// are off).
func (s *Server) SetStats(c *stats.Collector) { s.stats = c }
// interfaceZones maps the machine's interfaces to their zones.
func (s *Server) interfaceZones() map[string]string {
out := map[string]string{}
for _, i := range s.hostInterfaces(s.eng.State()) {
if i.Zone != "" {
out[i.Name] = i.Zone
}
}
return out
}
// classifyMark says which chain a counter is in and whether what it
// counts is dropped: rules that drop or reject, the drop policies, and
// the built-ins that drop (antispoofing, invalid packets).
func (s *Server) classifyMark(mark string) (string, bool) {
st := s.eng.State()
switch {
case strings.HasPrefix(mark, "p-"):
chain := strings.TrimPrefix(mark, "p-")
policy := map[string]string{"input": st.Settings.InputPolicy, "forward": st.Settings.ForwardPolicy, "output": st.Settings.OutputPolicy}[chain]
return chain, policy == model.PolicyDrop
case strings.HasPrefix(mark, "b-antispoof"):
return model.ChainEarly, true
case mark == "b-invalid":
return "invalid", true
case strings.HasPrefix(mark, "r"):
num := strings.TrimLeft(mark[1:], "0123456789")
id, _ := strconv.ParseInt(strings.TrimSuffix(mark[1:], num), 10, 64)
if o, ok := st.ByID(model.KindRule, id).(*model.Rule); ok && o != nil {
return o.Chain, o.Action == model.ActionDrop || o.Action == model.ActionReject
}
}
return "", false
}
// markName names a counter's object: "rule input #12 (ssh)", "built-in:
// established", "input policy".
func markName(st *model.State, mark string) string {
if c, ok := strings.CutPrefix(mark, "p-"); ok {
return c + " policy"
}
if l := markLabel(st, api.TraceStep{Kind: "rule", Mark: mark}); l != "" {
return l
}
if len(mark) < 2 {
return mark
}
// An object deleted since it counted.
kind := map[byte]string{'r': "rule", 'f': "port forward", 's': "source NAT", 'd': "redirect"}[mark[0]]
if num := strings.TrimLeft(mark[1:], "0123456789"); kind != "" && len(num) < len(mark)-1 {
return kind + " #" + strings.TrimSuffix(mark[1:], num) + " (deleted)"
}
return mark
}
func (s *Server) statsCollector() (*stats.Collector, error) {
if s.stats == nil {
return nil, &httpError{http.StatusServiceUnavailable, "stats_off", "statistics are off: set stats.enabled in housecarl.yaml and restart housecarld"}
}
return s.stats, nil
}
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
if err := require(p, auth.ResLog, false); err != nil {
return nil, err
}
c, err := s.statsCollector()
if err != nil {
return nil, err
}
q := stats.NewQuerier(c)
q.Now = s.nowFn
return q, nil
}
func since(r *http.Request, def string) (string, error) {
v := r.URL.Query().Get("since")
if v == "" {
v = def
}
if _, err := stats.ParseSince(v); err != nil {
return "", badRequest("%v", err)
}
return v, nil
}
// StatsStatus is the collector's health, with the setting that counts
// traffic per device.
type StatsStatus struct {
stats.Status
Latest *stats.Latest `json:"latest,omitempty"`
}
func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
out := StatsStatus{}
if s.stats != nil {
out.Status = s.stats.Status()
out.Latest = s.stats.Latest()
if !p.IsAdmin() {
out.Database = ""
}
}
out.PerDevice = s.eng.State().Settings.StatsPerDevice
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "24h")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
sum, err := q.Summary(r.Context(), d, queryInt(r, "limit", 10))
if err != nil {
return err
}
st := s.eng.State()
for i := range sum.Rules {
sum.Rules[i].Name = markName(st, sum.Rules[i].Key)
}
writeJSON(w, http.StatusOK, sum)
return nil
}
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "24h")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
var keys []string
for _, k := range strings.Split(r.URL.Query().Get("keys"), ",") {
if k = strings.TrimSpace(k); k != "" {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return badRequest("say which figures: keys=zrx:*,ztx:* (docs/stats.md)")
}
tl, err := q.Timeline(r.Context(), d, r.URL.Query().Get("step"), keys)
if err != nil {
return badRequest("%v", err)
}
writeJSON(w, http.StatusOK, tl)
return nil
}
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "24h")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
es, err := q.Top(r.Context(), r.URL.Query().Get("kind"), d, queryInt(r, "limit", 20))
if err != nil {
return badRequest("%v", err)
}
writeJSON(w, http.StatusOK, es)
return nil
}
// statsHits are the packets each rule, built-in and policy counted over
// the range, named; rules that counted none are listed too, with 0.
func (s *Server) statsHits(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "30d")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
es, err := q.Sums(r.Context(), "hits:", d)
if err != nil {
return err
}
st := s.eng.State()
seen := map[string]bool{}
for i := range es {
es[i].Name = markName(st, es[i].Key)
seen[es[i].Key] = true
}
for _, rule := range st.Rules {
if mark := "r" + strconv.FormatInt(rule.ID, 10); !seen[mark] && !rule.Disabled {
es = append(es, stats.Entry{Key: mark, Name: "rule " + rule.Label()})
}
}
writeJSON(w, http.StatusOK, es)
return nil
}
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
if err != nil {
return err
}
v, err := since(r, "28d")
if err != nil {
return err
}
d, _ := stats.ParseSince(v)
hm, err := q.Heatmap(r.Context(), r.URL.Query().Get("key"), d)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, hm)
return nil
}
// statsProblems warns when the connection tracking table is nearly full:
// new connections are dropped once it is.
func (s *Server) statsProblems() []validate.Problem {
if s.stats == nil {
return nil
}
l := s.stats.Latest()
if l == nil || l.ConntrackMax == 0 || l.Conntrack*100 < l.ConntrackMax*80 {
return nil
}
return []validate.Problem{{Severity: validate.Warning, Code: "conntrack_full", Object: "connections",
Message: "the connection tracking table is " + strconv.FormatInt(l.Conntrack*100/l.ConntrackMax, 10) + "% full (" + strconv.FormatInt(l.Conntrack, 10) + " of " + strconv.FormatInt(l.ConntrackMax, 10) + "); once it is, new connections are dropped: raise net.netfilter.nf_conntrack_max, or find what opens so many (Connections)"}}
}