The established built-in passes most of the traffic, so it topped the statistics' busiest rules every time and made every rule's bar look empty. The summary now lists rules and NAT objects in rules, and built-ins and policies in builtins; the Statistics page and hc stats show them as two lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
7.5 KiB
Go
269 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 _, 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)"}}
|
|
}
|