Counters: forget the totals of objects that no longer exist

hc counters kept listing the objects of a configuration replaced since
housecarld started (an import with --replace gives every object a new
ID) beside the current ones. Disabled objects, built-ins and policies
keep their totals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-20 18:45:51 +01:00
1 parent d813ce13db
commit 429c910df1
4 files changed
+61 -1

No files matched your search

+4
View File
@@ -42,6 +42,10 @@ All notable changes to Housecarl are documented here. The format follows
### Fixed ### Fixed
- `hc counters` (`GET /counters`) kept listing the objects of a
configuration replaced since housecarld started (`hc import
--replace` gives every object a new ID), beside the current ones; the
totals of objects that no longer exist are now forgotten.
- Import: two base chains on one hook had their rules imported twice, - Import: two base chains on one hook had their rules imported twice,
and the second chain's leading built-ins were moved above the first and the second chain's leading built-ins were moved above the first
chain's rules; they now follow each other by priority (read in a chain's rules; they now follow each other by priority (read in a
+3 -1
View File
@@ -154,7 +154,9 @@ table on its hook, so a drop in any of them wins.
Every rule, NAT rule, built-in and policy has a counter. `hc rule list`, Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
`hc forward list` and the web UI show packets; `hc counters` shows them `hc forward list` and the web UI show packets; `hc counters` shows them
all, with bytes. They count from housecarld's start, and carry across all, with bytes. They count from housecarld's start, and carry across
changes to the ruleset. changes to the ruleset. A deleted object's counter goes with it; an
import with `--replace` gives every object a new ID, so its counters
start again. A disabled rule keeps its count for when it is enabled.
## Quotas ## Quotas
+30
View File
@@ -556,9 +556,39 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
f.base[k] = b f.base[k] = b
} }
} }
pruneCounters(f.base, st)
return nil return nil
} }
// pruneCounters forgets the totals of objects st no longer has (deleted,
// or replaced by an import with new IDs), so they aren't listed beside
// the current ones. A disabled object keeps its totals for when it comes
// back; built-ins and policies are always kept.
func pruneCounters(base map[string]Counter, st *model.State) {
ids := map[string]bool{}
for _, r := range st.Rules {
ids[fmt.Sprintf("r%d", r.ID)] = true
}
for _, x := range st.Forwards {
ids[fmt.Sprintf("f%d", x.ID)] = true
}
for _, x := range st.SNATs {
ids[fmt.Sprintf("s%d", x.ID)] = true
}
for _, x := range st.Redirects {
ids[fmt.Sprintf("d%d", x.ID)] = true
}
for mark := range base {
obj, _, _ := strings.Cut(mark, "-") // f3-hairpin, f3-6 are f3's
if len(obj) < 2 || !strings.ContainsRune("rfsd", rune(obj[0])) || strings.Trim(obj[1:], "0123456789") != "" {
continue // b-established, p-input
}
if !ids[obj] {
delete(base, mark)
}
}
}
// SaveBoot writes the ruleset of st for the boot unit, and the fallback // SaveBoot writes the ruleset of st for the boot unit, and the fallback
// it loads if that fails (FallbackFile), atomically. The boot ruleset has // it loads if that fails (FallbackFile), atomically. The boot ruleset has
// no flowtable: it loads before the network comes up, when its devices // no flowtable: it loads before the network comes up, when its devices
+24
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"testing" "testing"
@@ -437,3 +438,26 @@ func TestShadowInContainer(t *testing.T) {
t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err) t.Errorf("the shadow fallback isn't recognised: %+v %v", tb, err)
} }
} }
// TestPruneCounters: the totals of objects gone (an import with --replace
// gives every object a new ID) are forgotten; built-ins, policies and
// disabled objects keep theirs.
func TestPruneCounters(t *testing.T) {
base := map[string]Counter{}
for _, m := range []string{"r1", "r2", "r3", "f4", "f4-hairpin", "f4-6", "f5-hairpin", "s6", "d7", "d8", "b-established", "p-input", "fallback-input"} {
base[m] = Counter{Packets: 1}
}
st := model.NewState()
st.Rules = []*model.Rule{{ID: 1}, {ID: 3, Disabled: true}}
st.Forwards = []*model.PortForward{{ID: 4}}
st.Redirects = []*model.Redirect{{ID: 8}}
pruneCounters(base, st)
var got []string
for m := range base {
got = append(got, m)
}
sort.Strings(got)
if strings.Join(got, " ") != "b-established d8 f4 f4-6 f4-hairpin fallback-input p-input r1 r3" {
t.Errorf("kept %v", got)
}
}