Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b90bd770 | ||
|
|
ff03e76925 | ||
|
|
53cd43187e | ||
|
|
4c13297f27 |
No files matched your search
@@ -8,6 +8,14 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### 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, a rule's quota is one
|
||||||
|
line (what it used, how it resets) that opens a dialog with the rest:
|
||||||
|
usage per address, last and next reset, and Reset and Give back for
|
||||||
|
everyone or one address. `POST /rules/{ref}/reset-quota` takes `address` and `give`.
|
||||||
- **Names beside inside addresses.** Connections, the log, quota usage,
|
- **Names beside inside addresses.** Connections, the log, quota usage,
|
||||||
the dashboard's quotas and the statistics' devices and blocked
|
the dashboard's quotas and the statistics' devices and blocked
|
||||||
sources show each inside address's name, found by a reverse lookup:
|
sources show each inside address's name, found by a reverse lookup:
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Housecarl 0.1.1 is in testing (0.1.1-rc.7): zones, address sets and
|
Housecarl 0.1.1 is in testing (0.1.1-rc.9): zones, address sets and
|
||||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||||
network changes (confirmed by the client that made them), the ruleset
|
network changes (confirmed by the client that made them), the ruleset
|
||||||
|
|||||||
+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().BoolVar(&last, "last", false, "last in its chain")
|
||||||
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
||||||
cmd.AddCommand(move)
|
cmd.AddCommand(move)
|
||||||
cmd.AddCommand(&cobra.Command{
|
var resetAddr, give string
|
||||||
Use: "reset-quota ID", Short: "Start a rule's quota again now (every address's, for a per-address quota)", Args: cobra.ExactArgs(1),
|
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 {
|
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()
|
c, err := newClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
if !flagQuiet {
|
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
|
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} {
|
for _, on := range []bool{true, false} {
|
||||||
verb, short := "disable", "Keep a rule but don't use it"
|
verb, short := "disable", "Keep a rule but don't use it"
|
||||||
if on {
|
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 |
|
| `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) |
|
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
|
||||||
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
| `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 |
|
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
||||||
|
|
||||||
The objects:
|
The objects:
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ See [rules.md](rules.md).
|
|||||||
| `rule update ID [flags]` | only the flags given |
|
| `rule update ID [flags]` | only the flags given |
|
||||||
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
||||||
| `rule enable\|disable ID` | |
|
| `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` | |
|
| `rule delete ID` | |
|
||||||
|
|
||||||
The match flags (`rule add`, `snat add`, `redirect add`):
|
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
|
## Quotas
|
||||||
|
|
||||||
`hc quotas` (and the rules page) show what each quota used and when it
|
`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),
|
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
|
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
|
reboot it puts it back. A reboot without a clean stop loses at most the
|
||||||
|
|||||||
@@ -186,8 +186,18 @@ 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 rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop
|
||||||
hc quotas # what each used, and the next reset
|
hc quotas # what each used, and the next reset
|
||||||
hc rule reset-quota 31 # start again now
|
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. In the web UI, the quota's line under the rule
|
||||||
|
opens its dialog: usage (each address's), resets, and **Reset** and
|
||||||
|
**Give back…** for everyone or one address. 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
|
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 →
|
them together on the dashboard, and their history under **Traffic →
|
||||||
Statistics**.
|
Statistics**.
|
||||||
|
|||||||
@@ -326,6 +326,16 @@ type CertificateResponse struct {
|
|||||||
Status *CertStatus `json:"status,omitempty"`
|
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.
|
// QuotaInfo is a rule's quota and what it used.
|
||||||
type QuotaInfo struct {
|
type QuotaInfo struct {
|
||||||
RuleID int64 `json:"rule_id"`
|
RuleID int64 `json:"rule_id"`
|
||||||
|
|||||||
@@ -422,6 +422,8 @@ type Firewall struct {
|
|||||||
// restore is the quota usage saved before housecarld (or the machine)
|
// restore is the quota usage saved before housecarld (or the machine)
|
||||||
// stopped, put back by the next apply.
|
// stopped, put back by the next apply.
|
||||||
restore *QuotaUsage
|
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
|
// flowDevs are the flowtable's devices in the ruleset in force, by
|
||||||
// name, with their interface index then (0: left out, missing).
|
// name, with their interface index then (0: left out, missing).
|
||||||
flowDevs map[string]int
|
flowDevs map[string]int
|
||||||
@@ -462,6 +464,52 @@ func (f *Firewall) RestoreQuotas(u *QuotaUsage) {
|
|||||||
f.restore = u
|
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
|
// ResetQuota starts a rule's quota again: the named quota, or the
|
||||||
// per-address quota sets (both families).
|
// per-address quota sets (both families).
|
||||||
func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error {
|
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 = old.QuotaUsage
|
||||||
}
|
}
|
||||||
usage.merge(f.restore)
|
usage.merge(f.restore)
|
||||||
|
usage.apply(f.adjust)
|
||||||
|
f.adjust = nil
|
||||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||||
if old != nil {
|
if old != nil {
|
||||||
opt.DeviceSets = old.DeviceSets
|
opt.DeviceSets = old.DeviceSets
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -186,6 +187,16 @@ func TestQuotasInContainer(t *testing.T) {
|
|||||||
if t2, d2 := used(); t2 < total || d2 < dev {
|
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)
|
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.
|
// Reset by hand.
|
||||||
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
||||||
t.Fatal(err)
|
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
|
// TestDeviceCountsInContainer: the per-device counters are read back and
|
||||||
// survive a reload.
|
// survive a reload.
|
||||||
func TestDeviceCountsInContainer(t *testing.T) {
|
func TestDeviceCountsInContainer(t *testing.T) {
|
||||||
|
|||||||
+101
-8
@@ -4,8 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"housecarl/internal/api"
|
"housecarl/internal/api"
|
||||||
@@ -237,7 +240,8 @@ func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
return nil
|
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 {
|
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if err := require(p, auth.ResConfig, true); err != nil {
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -250,14 +254,103 @@ func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
if rule.Quota == nil {
|
if rule.Quota == nil {
|
||||||
return badRequest("%s has no quota", rule.Label())
|
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
|
return err
|
||||||
}
|
}
|
||||||
resets := s.quotaResets(r.Context())
|
if in.Give < 0 {
|
||||||
resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn()
|
return badRequest("give a number of bytes to give back, 1 or more")
|
||||||
s.saveQuotaResets(r.Context(), resets)
|
}
|
||||||
s.forgetQuotaUsage(r.Context(), rule.ID)
|
if in.Address == "" && in.Give == 0 {
|
||||||
s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "")
|
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()})
|
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
|
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
|
SaveBoot(st *model.State) error
|
||||||
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
||||||
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
|
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 follows packets (internal/trace) until ctx ends.
|
||||||
Trace(ctx context.Context, script string, line func(string)) error
|
Trace(ctx context.Context, script string, line func(string)) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -45,6 +46,8 @@ type fakeFW struct {
|
|||||||
fallback bool // the boot fallback is in force
|
fallback bool // the boot fallback is in force
|
||||||
shadow bool
|
shadow bool
|
||||||
ruleset string // nft -j list ruleset ("": a default)
|
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 }
|
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 {
|
if len(f.applied) == 0 {
|
||||||
return nil, errors.New("No such file or directory: list table inet housecarl")
|
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 {
|
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
||||||
f.mu.Lock()
|
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 {
|
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
|
||||||
t.Errorf("quotas %+v", qs)
|
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)
|
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:
|
// TestAutoConfirm: the client that made a change confirms it by itself:
|
||||||
|
|||||||
@@ -158,41 +158,85 @@ function extras(r, q) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// quotaLine shows a quota, and what it used: in total, or per address
|
// quotaLine sums up a quota on the rule's row: what it used (in total,
|
||||||
// (the busiest, and the rest in a dialog).
|
// or how many addresses and the busiest); the rest is in its dialog.
|
||||||
function quotaLine(r, q) {
|
function quotaLine(r, q) {
|
||||||
const qt = r.quota;
|
const qt = r.quota;
|
||||||
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
||||||
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand";
|
const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand";
|
||||||
let used = null;
|
let used = fmtBytes(qt.bytes) + each;
|
||||||
if (q && !qt.per_source && !qt.per_destination) {
|
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used";
|
||||||
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
else if (q) {
|
||||||
} else if (q) {
|
|
||||||
const n = (q.addresses || []).length;
|
const n = (q.addresses || []).length;
|
||||||
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) : "");
|
||||||
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
|
|
||||||
}
|
}
|
||||||
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets,
|
const text = "quota " + used + " · " + resets;
|
||||||
used ? [" · ", used] : null,
|
return h("div", { class: "muted small" }, q && r.pending !== "new"
|
||||||
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text);
|
||||||
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function usageDialog(r, q) {
|
// quotaDialog shows a quota in full: what it used (each address's, for a
|
||||||
const names = await lookupNames(q.addresses.map((a) => a.address));
|
// per-address one), its resets, and resetting or giving back.
|
||||||
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
async function quotaDialog(r, q) {
|
||||||
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
const qt = r.quota;
|
||||||
table([
|
const perAddress = qt.per_source || qt.per_destination;
|
||||||
|
const write = can("config", true);
|
||||||
|
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand";
|
||||||
|
const when = (t) => t ? new Date(t).toLocaleString() : "–";
|
||||||
|
const names = perAddress ? 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:last-child").click(); f(); }; // Close
|
||||||
|
const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||||
|
h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " +
|
||||||
|
(qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."),
|
||||||
|
h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)),
|
||||||
|
perAddress ? table([
|
||||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
|
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
|
||||||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
||||||
{ label: "", render: (a) => usageBar(a.used, r.quota.bytes) },
|
{ label: "", render: (a) => usageBar(a.used, qt.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 || [], { empty: "No address has used it since the last reset." })
|
||||||
|
: h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used", usageBar(q.used, qt.bytes))),
|
||||||
|
[
|
||||||
|
...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []),
|
||||||
|
{ label: "Close", value: null },
|
||||||
|
], { wide: perAddress });
|
||||||
|
if (v === "reset") resetQuota(r);
|
||||||
|
if (v === "give") giveBack(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
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" +
|
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;
|
(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"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); }
|
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) {
|
async function move(r, where) {
|
||||||
|
|||||||
@@ -327,10 +327,21 @@ stop; start
|
|||||||
run "after a restart, still used up" 1 send 1000
|
run "after a restart, still used up" 1 send 1000
|
||||||
run "the usage after a restart" 0 H quotas
|
run "the usage after a restart" 0 H quotas
|
||||||
contains "$CLIENT4 (e2e-client) *1\.[0-9] MB"
|
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 "reset by hand" 0 H rule reset-quota "$QR"
|
||||||
run "the device is back" 0 send 1000
|
run "the device is back" 0 send 1000
|
||||||
run "the reset is audited" 0 H audit --action quota.
|
run "the reset is audited" 0 H audit --action quota.
|
||||||
contains "quota.reset"
|
contains "quota.reset"
|
||||||
|
contains "quota.give"
|
||||||
run "the quota rule goes" 0 H rule delete -y "$QR"
|
run "the quota rule goes" 0 H rule delete -y "$QR"
|
||||||
|
|
||||||
# ---- the zone matrix, of the imported ruleset
|
# ---- the zone matrix, of the imported ruleset
|
||||||
|
|||||||
Reference in new issue
Block a user