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:
1 parent
6bb82e86f5
commit
cf30715c5a
37 files changed
+3652
-18
No files matched your search
+22
-1
@@ -201,6 +201,9 @@ type Table struct {
|
||||
// Quotas: the rules' quotas' used bytes (quota_r12), and the
|
||||
// per-address quotas' entries by set (quota_r12_v4).
|
||||
QuotaUsage
|
||||
// DeviceSets are the per-device traffic counters by set
|
||||
// (stats_up_v4, stats_down_v6 …): each address's packets and bytes.
|
||||
DeviceSets map[string][]render.Element
|
||||
// Since is when the counters started (housecarld's first apply).
|
||||
Since time.Time
|
||||
// Tables are all the machine's tables ("inet main"), when the whole
|
||||
@@ -287,7 +290,7 @@ func (q jsonQuota) bytes() int64 {
|
||||
|
||||
// ParseTable reads `nft -j list table inet housecarl`.
|
||||
func ParseTable(data []byte) (*Table, error) {
|
||||
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{},
|
||||
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}, DeviceSets: map[string][]render.Element{},
|
||||
QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}}
|
||||
if len(data) == 0 {
|
||||
return t, nil
|
||||
@@ -340,6 +343,21 @@ func ParseTable(data []byte) (*Table, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if render.IsDeviceSet(s.Name) {
|
||||
for _, e := range s.Elem {
|
||||
var w struct {
|
||||
Elem struct {
|
||||
Val string `json:"val"`
|
||||
Expires int `json:"expires"`
|
||||
Counter *Counter `json:"counter"`
|
||||
} `json:"elem"`
|
||||
}
|
||||
if json.Unmarshal(e, &w) == nil && w.Elem.Counter != nil {
|
||||
t.DeviceSets[s.Name] = append(t.DeviceSets[s.Name], render.Element{Addr: w.Elem.Val, Timeout: w.Elem.Expires, Packets: w.Elem.Counter.Packets, Bytes: w.Elem.Counter.Bytes})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||
continue
|
||||
@@ -457,6 +475,9 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
}
|
||||
usage.merge(f.restore)
|
||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||
if old != nil {
|
||||
opt.DeviceSets = old.DeviceSets
|
||||
}
|
||||
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -208,3 +208,52 @@ func TestQuotasInContainer(t *testing.T) {
|
||||
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceCountsInContainer: the per-device counters are read back and
|
||||
// survive a reload.
|
||||
func TestDeviceCountsInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-stats-test"
|
||||
exec.Command("docker", "rm", "-f", name).Run()
|
||||
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||
t.Fatalf("starting the container: %v %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
|
||||
ctx := context.Background()
|
||||
st := model.NewState()
|
||||
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
|
||||
st.Settings.StatsPerDevice = true
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if out, err := exec.Command("docker", "exec", name, "nft", "add", "element", "inet", "housecarl", "stats_up_v4", "{ 10.0.10.5 counter packets 7 bytes 7000 }").CombinedOutput(); err != nil {
|
||||
t.Fatalf("adding an element: %v %s", err, out)
|
||||
}
|
||||
read := func() render.Element {
|
||||
tb, err := fw.Table(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range tb.DeviceSets["stats_up_v4"] {
|
||||
if e.Addr == "10.0.10.5" {
|
||||
return e
|
||||
}
|
||||
}
|
||||
t.Fatalf("no 10.0.10.5 in %+v", tb.DeviceSets)
|
||||
return render.Element{}
|
||||
}
|
||||
if e := read(); e.Packets != 7 || e.Bytes != 7000 || e.Timeout <= 0 {
|
||||
t.Fatalf("read back %+v", e)
|
||||
}
|
||||
st.Settings.InputPolicy = model.PolicyAccept // a change: a reload
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e := read(); e.Packets != 7 || e.Bytes != 7000 {
|
||||
t.Errorf("after a reload: %+v", e)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user