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 _, es := range [][]stats.Entry{sum.Rules, sum.BuiltIns} { for i := range es { es[i].Name = markName(st, es[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)"}} }