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