Quotas: reset one address, or give some back

POST /rules/{ref}/reset-quota takes an optional address (one entry of a
per-address quota starts again) and give (bytes taken off what was used,
one address's or everyone's, until the next reset). The firewall keeps
the adjustment for its next apply, which writes the new usage into the
ruleset; housecarld applies at once and saves the usage. hc rule
reset-quota --address/--give; Reset and Give back in the web UI, per
address in the usage dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-21 11:18:13 +01:00
1 parent ba2de59263
commit 4c13297f27
14 files changed
+339 -23

No files matched your search

+39 -2
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
@@ -45,6 +46,8 @@ type fakeFW struct {
fallback bool // the boot fallback is in force
shadow bool
ruleset string // nft -j list ruleset ("": a default)
quotas nft.QuotaUsage
adjusted []nft.QuotaAdjust
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
@@ -74,7 +77,12 @@ func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
if len(f.applied) == 0 {
return nil, errors.New("No such file or directory: list table inet housecarl")
}
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback, QuotaUsage: f.quotas}, nil
}
func (f *fakeFW) AdjustQuota(a nft.QuotaAdjust) {
f.mu.Lock()
defer f.mu.Unlock()
f.adjusted = append(f.adjusted, a)
}
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
f.mu.Lock()
@@ -750,9 +758,38 @@ func TestQuotaResets(t *testing.T) {
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
t.Errorf("quotas %+v", qs)
}
if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 {
reset := "/rules/" + itoa(created.ID) + "/reset-quota"
if code := e.call(e.token, "POST", reset, nil, nil); code != 200 || len(e.fw.resets) != 2 {
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
}
// One address started again, or some given back: the next apply
// (made at once) writes the new usage.
name := render.QuotaName(created.ID)
e.fw.quotas = nft.QuotaUsage{QuotaSets: map[string][]render.Element{name + "_v4": {{Addr: "10.0.10.23", Used: 6 << 30}},
name + "_v6": {{Addr: "2001:db8:1:2::", Used: 1 << 30}}}}
applies := len(e.fw.applied)
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "10.0.10.23"}, nil); code != 200 {
t.Errorf("reset one address: %d", code)
}
if code := e.call(e.token, "POST", reset, api.QuotaReset{Address: "2001:db8:1:2:aaaa::7", Give: 2 << 30}, nil); code != 200 {
t.Errorf("give back to an IPv6 address (its /64): %d", code)
}
if code := e.call(e.token, "POST", reset, api.QuotaReset{Give: 1 << 30}, nil); code != 200 {
t.Errorf("give back to everyone: %d", code)
}
want3 := []nft.QuotaAdjust{{Name: name, Addr: "10.0.10.23", Give: math.MaxInt64}, {Name: name, Addr: "2001:db8:1:2::", Give: 2 << 30}, {Name: name, Give: 1 << 30}}
if !reflect.DeepEqual(e.fw.adjusted, want3) || len(e.fw.applied) != applies+3 || len(e.fw.resets) != 2 {
t.Errorf("adjusted %+v, applied %d more, resets %v", e.fw.adjusted, len(e.fw.applied)-applies, e.fw.resets)
}
for _, c := range []struct {
in api.QuotaReset
code int
}{{api.QuotaReset{Address: "10.0.10.99"}, 404}, {api.QuotaReset{Address: "nonsense"}, 400}, {api.QuotaReset{Give: -5}, 400}} {
if code := e.call(e.token, "POST", reset, c.in, nil); code != c.code {
t.Errorf("%+v: %d, want %d", c.in, code, c.code)
}
}
}
// TestAutoConfirm: the client that made a change confirms it by itself: