From 4c13297f274ac880a5d1723f3ca109aab716b7af Mon Sep 17 00:00:00 2001 From: abrown38 Date: Wed, 21 Oct 2026 11:18:13 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 7 ++ cmd/hc/rules.go | 45 ++++++++- docs/api.md | 2 +- docs/cli.md | 2 +- docs/operations.md | 3 +- docs/rules.md | 9 ++ internal/api/types.go | 10 ++ internal/nft/nft.go | 50 ++++++++++ internal/nft/nft_test.go | 28 ++++++ internal/server/quota.go | 109 ++++++++++++++++++++-- internal/server/server.go | 2 + internal/server/server_test.go | 41 +++++++- internal/web/static/assets/pages/rules.js | 43 ++++++++- scripts/e2e.sh | 11 +++ 14 files changed, 339 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9983f..cb14fa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to Housecarl are documented here. The format follows ### Added +- **Reset one address's quota, or give some back.** `hc rule + reset-quota ID --address ADDR` starts only that device's allowance + again; `--give 5gbytes` takes that much off what was used instead (one + address's, or everyone's): extra allowance until the next reset, + without changing the rule. In the web UI: **Give back…** beside a + quota's Reset, and Reset and Give back for each address in its usage + dialog. `POST /rules/{ref}/reset-quota` takes `address` and `give`. - **Names beside inside addresses.** Connections, the log, quota usage, the dashboard's quotas and the statistics' devices and blocked sources show each inside address's name, found by a reverse lookup: diff --git a/cmd/hc/rules.go b/cmd/hc/rules.go index 860ff21..0dc0419 100644 --- a/cmd/hc/rules.go +++ b/cmd/hc/rules.go @@ -422,22 +422,57 @@ output (from it) or early (before connection tracking: cheap drops). move.Flags().BoolVar(&last, "last", false, "last in its chain") move.Flags().StringVar(&chain, "chain", "", "to the end of another chain") cmd.AddCommand(move) - cmd.AddCommand(&cobra.Command{ - Use: "reset-quota ID", Short: "Start a rule's quota again now (every address's, for a per-address quota)", Args: cobra.ExactArgs(1), + var resetAddr, give string + resetCmd := &cobra.Command{ + Use: "reset-quota ID [--address ADDR] [--give AMOUNT]", Short: "Start a rule's quota again now, or give some back until its next reset", Args: cobra.ExactArgs(1), + Long: `Starts a rule's quota again now: all of it (every address's, for a +per-address quota), or with --address one address's. --give takes an +amount off what was used instead: extra allowance until the quota's next +reset, without changing the rule. Done at once, not staged or on +probation. + + hc rule reset-quota 31 + hc rule reset-quota 31 --address 192.168.1.23 + hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23`, RunE: func(cmd *cobra.Command, args []string) error { + in := api.QuotaReset{Address: resetAddr} + if give != "" { + bad := fmt.Errorf("--give %q: write it as 500mbytes or 5gbytes", give) + m := amountRe.FindStringSubmatch(strings.TrimSpace(give)) + if m == nil { + return bad + } + unit, ok := byteUnit(m[2]) + n, _ := strconv.ParseInt(m[1], 10, 64) + if !ok || unit == "" || n < 1 { + return bad + } + in.Give = n * model.ByteUnits[unit] + } c, err := newClient() if err != nil { return err } - if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, nil, nil); err != nil { + if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, in, nil); err != nil { return err } if !flagQuiet { - fmt.Printf("reset the quota of rule %s\n", args[0]) + who := "" + if resetAddr != "" { + who = " for " + resetAddr + } + if in.Give > 0 { + fmt.Printf("gave back %s of the quota of rule %s%s, until its next reset\n", humanBytes(in.Give), args[0], who) + } else { + fmt.Printf("reset the quota of rule %s%s\n", args[0], who) + } } return nil }, - }) + } + resetCmd.Flags().StringVar(&resetAddr, "address", "", "only this address's (a per-address quota; an IPv6 address counts for its /64)") + resetCmd.Flags().StringVar(&give, "give", "", "take this much off what was used instead (5gbytes, 500mbytes)") + cmd.AddCommand(resetCmd) for _, on := range []bool{true, false} { verb, short := "disable", "Keep a rule but don't use it" if on { diff --git a/docs/api.md b/docs/api.md index dcbb8ca..67e6b3c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -119,7 +119,7 @@ share one pattern, `/{kind}` with kind `zones`, `sets`, `services`, | `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards | | `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) | | `DELETE /{kind}/{ref}` | refused while something uses it | -| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged) | +| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged). Optional body: `{"address": "192.168.1.23"}` starts only that address's again (a per-address quota; an IPv6 address counts for its /64; 404 if it hasn't used the quota); `{"give": 5368709120}` takes that many bytes off what was used instead (the address's, or every address's), until the next reset | | `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too | The objects: diff --git a/docs/cli.md b/docs/cli.md index ed24790..fcfff3a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -172,7 +172,7 @@ See [rules.md](rules.md). | `rule update ID [flags]` | only the flags given | | `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | | | `rule enable\|disable ID` | | -| `rule reset-quota ID` | start a rule's quota again now | +| `rule reset-quota ID [--address ADDR] [--give 5gbytes]` | start a rule's quota again now, or one address's; `--give` takes an amount off what was used instead, until the next reset | | `rule delete ID` | | The match flags (`rule add`, `snat add`, `redirect add`): diff --git a/docs/operations.md b/docs/operations.md index 67b9227..f3c91d2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -161,7 +161,8 @@ start again. A disabled rule keeps its count for when it is enabled. ## Quotas `hc quotas` (and the rules page) show what each quota used and when it -resets; `hc rule reset-quota ID` starts one again. housecarld resets +resets; `hc rule reset-quota ID` starts one again (`--address` one device's, +`--give 5gbytes` a top-up until the next reset). housecarld resets quotas at midnight when their period ends (late, if it was stopped then), and saves their usage every five minutes and when it stops; after a reboot it puts it back. A reboot without a clean stop loses at most the diff --git a/docs/rules.md b/docs/rules.md index b86603f..76ea5e1 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -186,8 +186,17 @@ hc rule add forward --in lan --out wan --quota 50gbytes,over,per-source,monthly= hc rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop hc quotas # what each used, and the next reset hc rule reset-quota 31 # start again now +hc rule reset-quota 31 --address 192.168.1.23 # one device only +hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23 ``` +`--give` is a top-up: it takes that much off what was used (never below +zero), so the device gets 5 GB more until the quota's next reset, and the +rule stays as it is. The web UI has **Reset** and **Give back…** on the +rule, and for each address in its usage dialog. Like a reset, these are +done at once (audited, not staged or on probation); Housecarl puts the +ruleset in force again with the new figure. + In the web UI each quota's use is on its rule (with **Reset**), all of them together on the dashboard, and their history under **Traffic → Statistics**. diff --git a/internal/api/types.go b/internal/api/types.go index 2238b26..a6c7a07 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -326,6 +326,16 @@ type CertificateResponse struct { Status *CertStatus `json:"status,omitempty"` } +// QuotaReset is POST /rules/{ref}/reset-quota's optional body. Without +// one the whole quota starts again. Address starts one address of a +// per-address quota again (any address in its group: 10.0.10.23, or an +// IPv6 address for its /64); Give takes that many bytes off what was used +// instead (the address's, or every address's), until the next reset. +type QuotaReset struct { + Address string `json:"address,omitempty"` + Give int64 `json:"give,omitempty"` +} + // QuotaInfo is a rule's quota and what it used. type QuotaInfo struct { RuleID int64 `json:"rule_id"` diff --git a/internal/nft/nft.go b/internal/nft/nft.go index 12858cc..de49101 100644 --- a/internal/nft/nft.go +++ b/internal/nft/nft.go @@ -422,6 +422,8 @@ type Firewall struct { // restore is the quota usage saved before housecarld (or the machine) // stopped, put back by the next apply. restore *QuotaUsage + // adjust are quota changes the next apply makes (AdjustQuota). + adjust []QuotaAdjust // flowDevs are the flowtable's devices in the ruleset in force, by // name, with their interface index then (0: left out, missing). flowDevs map[string]int @@ -462,6 +464,52 @@ func (f *Firewall) RestoreQuotas(u *QuotaUsage) { f.restore = u } +// QuotaAdjust takes Give bytes off what a quota used (never below zero): +// the named quota Name, or the entries of the per-address sets Name_v4 +// and Name_v6, Addr's alone if given (the set's key, as listed). +type QuotaAdjust struct { + Name string + Addr string + Give int64 +} + +// AdjustQuota has the next apply change what a quota used. +func (f *Firewall) AdjustQuota(a QuotaAdjust) { + f.mu.Lock() + defer f.mu.Unlock() + f.adjust = append(f.adjust, a) +} + +// apply takes what a's say off u. An entry with nothing left used is +// dropped, so its quota starts again with its next packet. +func (u *QuotaUsage) apply(adj []QuotaAdjust) { + less := func(used, give int64) int64 { + if give >= used { + return 0 + } + return used - give + } + for _, a := range adj { + if used, ok := u.Quotas[a.Name]; ok { + u.Quotas[a.Name] = less(used, a.Give) + } + for _, set := range []string{a.Name + "_v4", a.Name + "_v6"} { + var kept []render.Element + for _, e := range u.QuotaSets[set] { + if a.Addr == "" || e.Addr == a.Addr { + e.Used = less(e.Used, a.Give) + } + if e.Used > 0 { + kept = append(kept, e) + } + } + if _, ok := u.QuotaSets[set]; ok { + u.QuotaSets[set] = kept + } + } + } +} + // ResetQuota starts a rule's quota again: the named quota, or the // per-address quota sets (both families). func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error { @@ -531,6 +579,8 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error { usage = old.QuotaUsage } usage.merge(f.restore) + usage.apply(f.adjust) + f.adjust = nil opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets if old != nil { opt.DeviceSets = old.DeviceSets diff --git a/internal/nft/nft_test.go b/internal/nft/nft_test.go index b4ec22c..9ee965c 100644 --- a/internal/nft/nft_test.go +++ b/internal/nft/nft_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "sort" "strings" "testing" @@ -186,6 +187,16 @@ func TestQuotasInContainer(t *testing.T) { if t2, d2 := used(); t2 < total || d2 < dev { t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev) } + // Some given back, and one address started again, by the next apply. + total, dev = used() + fw.AdjustQuota(QuotaAdjust{Name: "quota_r1", Give: 1000}) + fw.AdjustQuota(QuotaAdjust{Name: "quota_r2", Addr: "127.0.0.3", Give: 1 << 62}) + if err := fw.Apply(ctx, st); err != nil { + t.Fatal(err) + } + if t2, d2 := used(); t2 > total-1000 || t2 < total-2000 || d2 != 0 { + t.Errorf("after giving back: total %d (was %d, gave 1000), per destination %d (was %d, reset)", t2, total, d2, dev) + } // Reset by hand. if err := fw.ResetQuota(ctx, 1, false); err != nil { t.Fatal(err) @@ -212,6 +223,23 @@ func TestQuotasInContainer(t *testing.T) { } } +// TestQuotaAdjust: giving back takes off what was used, never below +// zero; an entry with nothing left goes. +func TestQuotaAdjust(t *testing.T) { + u := QuotaUsage{Quotas: map[string]int64{"quota_r1": 5000, "quota_r9": 10}, + QuotaSets: map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 3000}, {Addr: "10.0.0.2", Used: 4000}}, + "quota_r2_v6": {{Addr: "2001:db8::", Used: 100}}}} + u.apply([]QuotaAdjust{{Name: "quota_r1", Give: 2000}, {Name: "quota_r9", Give: 2000}, {Name: "quota_r2", Addr: "10.0.0.2", Give: 1500}, + {Name: "quota_r2", Give: 100}}) + if u.Quotas["quota_r1"] != 3000 || u.Quotas["quota_r9"] != 0 { + t.Errorf("named: %v", u.Quotas) + } + want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.1", Used: 2900}, {Addr: "10.0.0.2", Used: 2400}}, "quota_r2_v6": nil} + if !reflect.DeepEqual(u.QuotaSets, want) { + t.Errorf("sets: %+v", u.QuotaSets) + } +} + // TestDeviceCountsInContainer: the per-device counters are read back and // survive a reload. func TestDeviceCountsInContainer(t *testing.T) { diff --git a/internal/server/quota.go b/internal/server/quota.go index 2dd4af2..188c26b 100644 --- a/internal/server/quota.go +++ b/internal/server/quota.go @@ -4,8 +4,11 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" + "net/netip" "sort" + "strings" "time" "housecarl/internal/api" @@ -237,7 +240,8 @@ func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Prin return nil } -// resetQuota starts a rule's quota again now. +// resetQuota starts a rule's quota again now: all of it, or one +// address's; or gives some of what it used back, until its next reset. func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.ResConfig, true); err != nil { return err @@ -250,14 +254,103 @@ func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Prin if rule.Quota == nil { return badRequest("%s has no quota", rule.Label()) } - if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil { + var in api.QuotaReset + if err := decodeOptional(r, &in); err != nil { return err } - resets := s.quotaResets(r.Context()) - resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn() - s.saveQuotaResets(r.Context(), resets) - s.forgetQuotaUsage(r.Context(), rule.ID) - s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "") - writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()}) + if in.Give < 0 { + return badRequest("give a number of bytes to give back, 1 or more") + } + if in.Address == "" && in.Give == 0 { + if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil { + return err + } + resets := s.quotaResets(r.Context()) + resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn() + s.saveQuotaResets(r.Context(), resets) + s.forgetQuotaUsage(r.Context(), rule.ID) + s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "") + writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()}) + return nil + } + name := render.QuotaName(rule.ID) + adj := nft.QuotaAdjust{Name: name, Give: in.Give} + if in.Address != "" { + if rule.Quota.PerAddress() == "" { + return badRequest("%s has one quota for everything it matches, not one per address: leave out the address", rule.Label()) + } + key, err := quotaKey(rule, in.Address) + if err != nil { + return err + } + if !s.quotaHas(r.Context(), name, key) { + return notFound("%s hasn't used the quota of %s since its last reset", in.Address, rule.Label()) + } + adj.Addr = key + } + if adj.Give == 0 { + adj.Give = math.MaxInt64 // all of it: the address starts again + } + // The next apply writes the quota's new usage into the ruleset. + s.fw.AdjustQuota(adj) + if err := s.eng.ApplyCurrent(r.Context()); err != nil { + return fmt.Errorf("putting the ruleset in force with the quota's new usage: %w", err) + } + s.SaveQuotaUsage(r.Context()) + detail, out := "", map[string]any{} + if in.Give > 0 { + detail = fmt.Sprintf("gave back %d bytes", in.Give) + out["given"] = in.Give + } + if in.Address != "" { + detail = strings.TrimSpace(detail + " " + in.Address) + out["address"] = in.Address + } + action := "quota.reset" + if in.Give > 0 { + action = "quota.give" + } + s.eng.Audit(r.Context(), p, action, rule.Label(), "success", detail) + out["reset"] = rule.Label() + writeJSON(w, http.StatusOK, out) return nil } + +// quotaKey is the entry of a per-address quota an address counts under: +// the address, or its group's first address (10.0.10.0 for a /24; an +// IPv6 address's /64). +func quotaKey(rule *model.Rule, s string) (string, error) { + var a netip.Addr + if p, err := netip.ParsePrefix(s); err == nil { + a = p.Addr() + } else if a, err = netip.ParseAddr(s); err != nil { + return "", badRequest("%q isn't an address", s) + } + a = a.Unmap() + v4, v6 := rule.Groups() + bits := v4 + if a.Is6() { + bits = v6 + } + p, err := a.Prefix(bits) + if err != nil { + return "", badRequest("%q: %v", s, err) + } + return p.Addr().String(), nil +} + +// quotaHas reports whether a per-address quota has an entry for key. +func (s *Server) quotaHas(ctx context.Context, name, key string) bool { + t, err := s.fw.Table(ctx) + if err != nil { + return false + } + for _, set := range []string{name + "_v4", name + "_v6"} { + for _, e := range t.QuotaSets[set] { + if e.Addr == key { + return true + } + } + } + return false +} diff --git a/internal/server/server.go b/internal/server/server.go index 0da4c84..9229813 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,6 +79,8 @@ type Firewall interface { SaveBoot(st *model.State) error // ResetQuota starts a rule's quota again (perAddress: its sets). ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error + // AdjustQuota has the next apply change what a quota used. + AdjustQuota(a nft.QuotaAdjust) // Trace follows packets (internal/trace) until ctx ends. Trace(ctx context.Context, script string, line func(string)) error } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3102592..9a5e1a3 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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: diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index 4682a88..216d6c3 100644 --- a/internal/web/static/assets/pages/rules.js +++ b/internal/web/static/assets/pages/rules.js @@ -175,24 +175,57 @@ function quotaLine(r, q) { return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets, used ? [" · ", used] : null, q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null, - can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null); + can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset"), + " ", h("button", { class: "small", onclick: () => giveBack(r) }, "Give back…")] : null); } async function usageDialog(r, q) { const names = await lookupNames(q.addresses.map((a) => a.address)); + // A row's action closes this dialog first: it opens one of its own. + const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button").click(); f(); }; + const write = can("config", true); await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null, h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."), table([ { label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) }, { label: "Used", render: (a) => fmtBytes(a.used) }, { label: "", render: (a) => usageBar(a.used, r.quota.bytes) }, - ], q.addresses)), [{ label: "Close", value: null }], { wide: true }); + write ? { label: "", render: (a) => h("span", { class: "nowrap" }, + h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ", + h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null, + ].filter(Boolean), q.addresses)), [{ label: "Close", value: null }], { wide: true }); } -async function resetQuota(r) { +// giveBack takes an amount off what a quota used (one address's, or +// everyone's): extra allowance until its next reset. +async function giveBack(r, address) { + const amount = input({ type: "number", min: 1, value: "1", class: "short" }); + const unit = select([["mb", "MB"], ["gb", "GB"]], "gb", { class: "inline" }); + const perAddress = r.quota.per_source || r.quota.per_destination; + const who = address ? " for " + address : perAddress ? " for every address" : ""; + await modal("Give back quota", h("div", null, + h("p", null, "Take this much off what " + (r.comment || "rule #" + r.id) + "'s quota used" + who + ": extra allowance until its next reset, without changing the rule. This is done at once, not staged."), + field("Amount", h("div", null, amount, " ", unit))), [ + { label: "Cancel", value: null }, + { label: "Give back", kind: "primary", onclick: async () => { + const n = parseInt(amount.value, 10); + if (!(n > 0)) { toast("Give an amount of 1 or more", "err"); return false; } + const give = n * (unit.value === "gb" ? 1 << 30 : 1 << 20); + try { + await api("POST", "/rules/" + r.id + "/reset-quota", { body: address ? { give, address } : { give } }); + toast("Gave back " + fmtBytes(give)); rerender(); + } catch (e) { modal("Not given back", errorBox(e)); return false; } + } }, + ]); +} + +async function resetQuota(r, address) { if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" + - (r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return; - try { await api("POST", "/rules/" + r.id + "/reset-quota"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); } + (address ? " for " + address : r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return; + try { + await api("POST", "/rules/" + r.id + "/reset-quota", address ? { body: { address } } : {}); + toast("Quota reset"); rerender(); + } catch (e) { modal("Not reset", errorBox(e)); } } async function move(r, where) { diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 2487bdc..74dcbe7 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -327,10 +327,21 @@ stop; start run "after a restart, still used up" 1 send 1000 run "the usage after a restart" 0 H quotas contains "$CLIENT4 (e2e-client) *1\.[0-9] MB" +# A top-up: some given back (more than the cut-off packets added), then +# used up again; one address started again; then the whole quota. +run "a top-up for the device" 0 H rule reset-quota "$QR" --give 10mbytes --address $CLIENT4 +contains "gave back 10.0 MB" +run "topped up: the device is back" 0 send 1000 +send 30000000 >/dev/null 2>&1 +run "used up again" 1 send 1000 +run "an address that used nothing" 4 H rule reset-quota "$QR" --address 10.88.10.99 +run "one address started again" 0 H rule reset-quota "$QR" --address $CLIENT4 +run "back again" 0 send 1000 run "reset by hand" 0 H rule reset-quota "$QR" run "the device is back" 0 send 1000 run "the reset is audited" 0 H audit --action quota. contains "quota.reset" +contains "quota.give" run "the quota rule goes" 0 H rule delete -y "$QR" # ---- the zone matrix, of the imported ruleset