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:
1 parent
ba2de59263
commit
4c13297f27
14 files changed
+339
-23
No files matched your search
@@ -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:
|
||||
|
||||
+40
-5
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+1
-1
@@ -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`):
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+101
-8
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user