Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f56eefae2 | ||
|
|
aaf0ac5206 | ||
|
|
8fa28d01c2 | ||
|
|
7d23fb13ed | ||
|
|
a169e9aa96 | ||
|
|
9863dd1cdd | ||
|
|
46c319eced | ||
|
|
24dbc0a869 |
No files matched your search
@@ -8,6 +8,9 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Quota usage as a share.** What a quota used shows its percentage
|
||||||
|
(`12.3 GB of 50.0 GB used (24%)`, each address's in the quota
|
||||||
|
dialog), on the Rules page, the dashboard and in `hc quotas`.
|
||||||
- **Reset one address's quota, or give some back.** `hc rule
|
- **Reset one address's quota, or give some back.** `hc rule
|
||||||
reset-quota ID --address ADDR` starts only that device's allowance
|
reset-quota ID --address ADDR` starts only that device's allowance
|
||||||
again; `--give 5gbytes` takes that much off what was used instead (one
|
again; `--give 5gbytes` takes that much off what was used instead (one
|
||||||
@@ -76,8 +79,30 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
||||||
several ports becomes one port forward per port.
|
several ports becomes one port forward per port.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Rules read as a direction.** A match's zones are written `lan → wan`,
|
||||||
|
`any but wan → any`, or with the firewall itself as the other end
|
||||||
|
(`lan → firewall` in input, `firewall → wan` in output), instead of
|
||||||
|
`in lan · out wan` and `in not wan`; exclusions read `from any but X`.
|
||||||
|
On the Rules and NAT pages and in `hc`; the flags and JSON stay as
|
||||||
|
they were.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Changing how a quota counts lost or muddled what it used: per address
|
||||||
|
to a total started at nothing, per source to per destination kept the
|
||||||
|
sources' use as if it were the destinations', and other groups left
|
||||||
|
the old entries behind. Now a total starts at the addresses' sum,
|
||||||
|
wider groups add up their addresses, and what can't be carried starts
|
||||||
|
again with a warning (`quota_restarts`) on the change.
|
||||||
|
- What the quotas used is saved after every change, not only every five
|
||||||
|
minutes: a reboot soon after a change that counts a quota differently
|
||||||
|
could bring back its usage counted the old way.
|
||||||
|
- Statistics' Quotas card linked every entry to the Rules page, not to
|
||||||
|
its rule; each now opens its rule, named. Per-address quotas were
|
||||||
|
missing from it (and from the `quota:rN` series): they count their
|
||||||
|
addresses' sum.
|
||||||
- `hc stats top devices` listed each device twice (once for what it
|
- `hc stats top devices` listed each device twice (once for what it
|
||||||
received, once for what it sent); it is one row with both again.
|
received, once for what it sent); it is one row with both again.
|
||||||
- `hc rule add --before/--after/--first` made two changes (create at the
|
- `hc rule add --before/--after/--first` made two changes (create at the
|
||||||
|
|||||||
@@ -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.9): zones, address sets and
|
Housecarl 0.1.1 is in testing (0.1.1-rc.13): 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
|
||||||
|
|||||||
+17
-2
@@ -322,9 +322,13 @@ func quotasCmd() *cobra.Command {
|
|||||||
if q.Quota.PerSource || q.Quota.PerDestination {
|
if q.Quota.PerSource || q.Quota.PerDestination {
|
||||||
of += " each"
|
of += " each"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
|
used := humanBytes(q.Used)
|
||||||
|
if !q.Quota.PerSource && !q.Quota.PerDestination {
|
||||||
|
used += " (" + percent(q.Used, q.Quota.Bytes) + ")"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, used, of, resets, next)
|
||||||
for _, a := range q.Addresses {
|
for _, a := range q.Addresses {
|
||||||
fmt.Fprintf(w, " %s\t%s\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used))
|
fmt.Fprintf(w, " %s\t%s (%s)\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used), percent(a.Used, q.Quota.Bytes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -332,6 +336,17 @@ func quotasCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// percent writes used as a share of total: "25%", "<1%" for a little.
|
||||||
|
func percent(used, total int64) string {
|
||||||
|
if total <= 0 || used <= 0 {
|
||||||
|
return "0%"
|
||||||
|
}
|
||||||
|
if used*100 < total {
|
||||||
|
return "<1%"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d%%", used*100/total)
|
||||||
|
}
|
||||||
|
|
||||||
func logCmd() *cobra.Command {
|
func logCmd() *cobra.Command {
|
||||||
var follow bool
|
var follow bool
|
||||||
var limit int
|
var limit int
|
||||||
|
|||||||
+22
-3
@@ -35,9 +35,28 @@ func TestLimitAndDescribe(t *testing.T) {
|
|||||||
if _, err := parseLimit("fast"); err == nil {
|
if _, err := parseLimit("fast"); err == nil {
|
||||||
t.Error("a bad limit accepted")
|
t.Error("a bad limit accepted")
|
||||||
}
|
}
|
||||||
m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}
|
for _, c := range []struct {
|
||||||
if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" {
|
m model.Match
|
||||||
t.Errorf("describe: %q", got)
|
chain string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}, "input",
|
||||||
|
"wan → firewall from any but trusted ssh,tcp/8080"},
|
||||||
|
{model.Match{InZones: []string{"wan"}, NotIn: true, Destinations: []string{"trusted_ips"}}, "forward", "any but wan → any to trusted_ips"},
|
||||||
|
{model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, "forward", "lan → wan"},
|
||||||
|
{model.Match{OutZones: []string{"wan"}}, "output", "firewall → wan"},
|
||||||
|
{model.Match{OutZones: []string{"wan"}, Sources: []string{"10.0.0.0/8"}}, "", "any → wan from 10.0.0.0/8"},
|
||||||
|
{model.Match{Sources: []string{"trusted"}}, "input", "from trusted"},
|
||||||
|
{model.Match{}, "forward", "everything"},
|
||||||
|
} {
|
||||||
|
if got := describe(c.m, c.chain); got != c.want {
|
||||||
|
t.Errorf("describe(%+v, %s) = %q, want %q", c.m, c.chain, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for used, want := range map[int64]string{0: "0%", 5: "<1%", 250: "25%", 1000: "100%", 1300: "130%"} {
|
||||||
|
if got := percent(used, 1000); got != want {
|
||||||
|
t.Errorf("percent(%d, 1000) = %q, want %q", used, got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
||||||
t.Errorf("editList: %v", got)
|
t.Errorf("editList: %v", got)
|
||||||
|
|||||||
+34
-16
@@ -64,22 +64,40 @@ func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) {
|
|||||||
set("family", "family", m.family)
|
set("family", "family", m.family)
|
||||||
}
|
}
|
||||||
|
|
||||||
// describe sums up a match: "in lan from trusted ssh, dns".
|
// describe sums up a match in a chain ("" for NAT): "lan → firewall
|
||||||
func describe(m model.Match) string {
|
// from trusted ssh,dns". Zones are a direction; the firewall itself is
|
||||||
|
// the other end of the input and output chains.
|
||||||
|
func describe(m model.Match, chain string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
side := func(word string, not bool, list []string) {
|
list := func(not bool, xs []string) string {
|
||||||
if len(list) == 0 {
|
s := strings.Join(xs, ",")
|
||||||
return
|
|
||||||
}
|
|
||||||
if not {
|
if not {
|
||||||
word += " not"
|
return "any but " + s
|
||||||
}
|
}
|
||||||
parts = append(parts, word+" "+strings.Join(list, ","))
|
return s
|
||||||
|
}
|
||||||
|
if len(m.InZones) > 0 || len(m.OutZones) > 0 {
|
||||||
|
in, out := "any", "any"
|
||||||
|
if len(m.InZones) > 0 {
|
||||||
|
in = list(m.NotIn, m.InZones)
|
||||||
|
}
|
||||||
|
if len(m.OutZones) > 0 {
|
||||||
|
out = list(m.NotOut, m.OutZones)
|
||||||
|
}
|
||||||
|
switch chain {
|
||||||
|
case model.ChainInput:
|
||||||
|
out = "firewall"
|
||||||
|
case model.ChainOutput:
|
||||||
|
in = "firewall"
|
||||||
|
}
|
||||||
|
parts = append(parts, in+" → "+out)
|
||||||
|
}
|
||||||
|
if len(m.Sources) > 0 {
|
||||||
|
parts = append(parts, "from "+list(m.NotSources, m.Sources))
|
||||||
|
}
|
||||||
|
if len(m.Destinations) > 0 {
|
||||||
|
parts = append(parts, "to "+list(m.NotDests, m.Destinations))
|
||||||
}
|
}
|
||||||
side("in", m.NotIn, m.InZones)
|
|
||||||
side("out", m.NotOut, m.OutZones)
|
|
||||||
side("from", m.NotSources, m.Sources)
|
|
||||||
side("to", m.NotDests, m.Destinations)
|
|
||||||
if len(m.Services) > 0 {
|
if len(m.Services) > 0 {
|
||||||
parts = append(parts, strings.Join(m.Services, ","))
|
parts = append(parts, strings.Join(m.Services, ","))
|
||||||
}
|
}
|
||||||
@@ -281,7 +299,7 @@ func ruleCmd() *cobra.Command {
|
|||||||
return output(rs, func(w io.Writer) {
|
return output(rs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
what := describe(r.Match)
|
what := describe(r.Match, r.Chain)
|
||||||
if r.Raw != "" {
|
if r.Raw != "" {
|
||||||
what = "raw: " + r.Raw
|
what = "raw: " + r.Raw
|
||||||
}
|
}
|
||||||
@@ -304,7 +322,7 @@ func ruleCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(ls)
|
cmd.AddCommand(ls)
|
||||||
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
||||||
if r.Raw == "" {
|
if r.Raw == "" {
|
||||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match, r.Chain), ruleExtras(r), r.Action)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
||||||
}
|
}
|
||||||
@@ -700,7 +718,7 @@ func snatCmd() *cobra.Command {
|
|||||||
if to == "" {
|
if to == "" {
|
||||||
to = "masquerade"
|
to = "masquerade"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -780,7 +798,7 @@ func redirectCmd() *cobra.Command {
|
|||||||
return output(xs, func(w io.Writer) {
|
return output(xs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
||||||
for _, x := range xs {
|
for _, x := range xs {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-3
@@ -164,9 +164,9 @@ start again. A disabled rule keeps its count for when it is enabled.
|
|||||||
resets; `hc rule reset-quota ID` starts one again (`--address` one device's,
|
resets; `hc rule reset-quota ID` starts one again (`--address` one device's,
|
||||||
`--give 5gbytes` a top-up until the next reset). housecarld resets
|
`--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, after every change and when
|
||||||
reboot it puts it back. A reboot without a clean stop loses at most the
|
it stops; after a reboot it puts it back. A reboot without a clean stop
|
||||||
last five minutes' count.
|
loses at most the last five minutes' count.
|
||||||
|
|
||||||
## The log
|
## The log
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -209,12 +209,26 @@ Statistics**.
|
|||||||
|
|
||||||
nftables counts a quota but never resets it, and forgets it when its
|
nftables counts a quota but never resets it, and forgets it when its
|
||||||
table is replaced: Housecarl carries what each quota used across every
|
table is replaced: Housecarl carries what each quota used across every
|
||||||
change and restart, saves it every five minutes (and when housecarld
|
change and restart, saves it every five minutes (and after each change,
|
||||||
stops) for after a reboot, and resets quotas when their period ends.
|
and when housecarld stops) for after a reboot, and resets quotas when their period ends.
|
||||||
A per-address quota counts an address: a device that gets a new one
|
A per-address quota counts an address: a device that gets a new one
|
||||||
starts again. With Steward's reservations IPv4 addresses stay; IPv6 is
|
starts again. With Steward's reservations IPv4 addresses stay; IPv6 is
|
||||||
grouped by /64 (below).
|
grouped by /64 (below).
|
||||||
|
|
||||||
|
Changing the amount, `over` or the resets keeps what was used. Changing
|
||||||
|
how the quota counts carries what can be carried:
|
||||||
|
|
||||||
|
| Change | What was used |
|
||||||
|
|---|---|
|
||||||
|
| per address to one total | the addresses' sum |
|
||||||
|
| to wider groups (IPv6 /64 to /56) | added up per new group |
|
||||||
|
| a total to per address | starts again: a total can't be split |
|
||||||
|
| per source to per destination (or back) | starts again |
|
||||||
|
| to finer groups (/64 to /128) | starts again: a group can't be split |
|
||||||
|
|
||||||
|
A change that starts a quota again says so (warning `quota_restarts`),
|
||||||
|
when it is staged for review and in `hc` (also with `-n`).
|
||||||
|
|
||||||
**Rules with a quota or a byte rate count every packet.** The built-in
|
**Rules with a quota or a byte rate count every packet.** The built-in
|
||||||
that accepts established connections comes before your rules, so a rule
|
that accepts established connections comes before your rules, so a rule
|
||||||
there only sees each connection's first packet. Rules that count traffic
|
there only sees each connection's first packet. Rules that count traffic
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ These apply to rules, source NAT and redirects.
|
|||||||
| `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) |
|
| `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) |
|
||||||
| `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log |
|
| `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log |
|
||||||
| `rule_every_packet_accept` | error | a rule checked against every packet (`every_packet`) that accepts: it comes before established connections are accepted, so it can only drop, reject or log |
|
| `rule_every_packet_accept` | error | a rule checked against every packet (`every_packet`) that accepts: it comes before established connections are accepted, so it can only drop, reject or log |
|
||||||
|
| `quota_restarts` | warning | a change makes a rule count its quota so that what it used can't carry over (a total to per address, per source to per destination, finer groups): it starts again. Only on the change that does it |
|
||||||
| `flowtable_traffic` | warning | a forward rule checked against every packet (a quota, a byte rate, `every_packet`) while the flowtable is on: it only sees each connection's first packets |
|
| `flowtable_traffic` | warning | a forward rule checked against every packet (a quota, a byte rate, `every_packet`) while the flowtable is on: it only sees each connection's first packets |
|
||||||
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
||||||
| `log_prefix` | error | a log prefix over 100 characters |
|
| `log_prefix` | error | a log prefix over 100 characters |
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
|||||||
// Only report warnings the change brings; old ones are on the status
|
// Only report warnings the change brings; old ones are on the status
|
||||||
// page.
|
// page.
|
||||||
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
||||||
|
res.Warnings = append(res.Warnings, quotaRestarts(e.state, next)...)
|
||||||
if e.Firewall != nil {
|
if e.Firewall != nil {
|
||||||
if err := e.Firewall.Check(ctx, next); err != nil {
|
if err := e.Firewall.Check(ctx, next); err != nil {
|
||||||
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
||||||
@@ -230,6 +231,23 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quotaRestarts warns of the rules whose quota starts again because they
|
||||||
|
// now count it in a way what they used can't be carried to.
|
||||||
|
func quotaRestarts(before, after *model.State) []validate.Problem {
|
||||||
|
was := map[int64]*model.Rule{}
|
||||||
|
for _, r := range before.Rules {
|
||||||
|
was[r.ID] = r
|
||||||
|
}
|
||||||
|
var out []validate.Problem
|
||||||
|
for _, r := range after.Rules {
|
||||||
|
if carry, why := model.QuotaCarry(was[r.ID], r); carry == model.QuotaRestarts {
|
||||||
|
out = append(out, validate.Problem{Severity: validate.Warning, Code: "quota_restarts", Object: r.Label(),
|
||||||
|
Message: "its quota starts again: " + why})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func newProblems(before, after []validate.Problem) []validate.Problem {
|
func newProblems(before, after []validate.Problem) []validate.Problem {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, p := range before {
|
for _, p := range before {
|
||||||
|
|||||||
@@ -173,3 +173,49 @@ func TestPositionsStayStableUntilCrowded(t *testing.T) {
|
|||||||
t.Errorf("order %v", order)
|
t.Errorf("order %v", order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuotaRestartsWarns: a change that counts a quota so what it used
|
||||||
|
// can't carry over warns; one that can (to a total, wider groups) doesn't.
|
||||||
|
func TestQuotaRestartsWarns(t *testing.T) {
|
||||||
|
e := newEngine(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
var id int64
|
||||||
|
if _, err := e.Apply(ctx, admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||||
|
id = newID()
|
||||||
|
st.Rules = append(st.Rules, &model.Rule{ID: id, Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 1 << 30, Over: true, PerSource: true}})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
change := func(f func(r *model.Rule)) []string {
|
||||||
|
res, err := e.Apply(ctx, admin, "", Options{DryRun: true}, func(st *model.State, _ func() int64) error {
|
||||||
|
f(st.ByID(model.KindRule, id).(*model.Rule))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
for _, w := range res.Warnings {
|
||||||
|
if w.Code == "quota_restarts" {
|
||||||
|
codes = append(codes, w.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }); len(w) != 1 || !strings.Contains(w[0], "per source can't be counted per destination") {
|
||||||
|
t.Errorf("per source to per destination: %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.Quota.PerSource = false }); len(w) != 0 {
|
||||||
|
t.Errorf("to a total (summed): %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.GroupV6 = 56 }); len(w) != 0 {
|
||||||
|
t.Errorf("wider groups: %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.GroupV6 = 128 }); len(w) != 1 {
|
||||||
|
t.Errorf("finer groups: %v", w)
|
||||||
|
}
|
||||||
|
if w := change(func(r *model.Rule) { r.Quota.Bytes = 2 << 30 }); len(w) != 0 {
|
||||||
|
t.Errorf("another amount: %v", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,6 +274,45 @@ func (r *Rule) Groups() (v4, v6 int) {
|
|||||||
return v4, v6
|
return v4, v6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What becomes of what a rule's quota used when the rule changes
|
||||||
|
// (QuotaCarry).
|
||||||
|
const (
|
||||||
|
QuotaKept = "" // counted as before: carried as it is
|
||||||
|
QuotaSummed = "summed" // per address to a total: the addresses' sum
|
||||||
|
QuotaRegrouped = "regrouped" // per address in wider groups: added up per group
|
||||||
|
QuotaRestarts = "restarts" // can't be carried: starts again
|
||||||
|
)
|
||||||
|
|
||||||
|
// QuotaCarry says what becomes of what old's quota used when the rule
|
||||||
|
// becomes new (both the same rule, with quotas), and for QuotaRestarts
|
||||||
|
// why. The amount, over and the resets don't matter: only how it counts.
|
||||||
|
func QuotaCarry(old, new *Rule) (carry, why string) {
|
||||||
|
if old == nil || new == nil || old.Quota == nil || new.Quota == nil {
|
||||||
|
return QuotaKept, ""
|
||||||
|
}
|
||||||
|
was, is := old.Quota.PerAddress(), new.Quota.PerAddress()
|
||||||
|
words := map[string]string{"saddr": "per source", "daddr": "per destination", "": "in total"}
|
||||||
|
switch {
|
||||||
|
case was == "" && is == "":
|
||||||
|
return QuotaKept, ""
|
||||||
|
case is == "":
|
||||||
|
return QuotaSummed, ""
|
||||||
|
case was == "":
|
||||||
|
return QuotaRestarts, "a total can't be split by address, so each address starts at nothing"
|
||||||
|
case was != is:
|
||||||
|
return QuotaRestarts, "what was counted " + words[was] + " can't be counted " + words[is]
|
||||||
|
}
|
||||||
|
o4, o6 := old.Groups()
|
||||||
|
n4, n6 := new.Groups()
|
||||||
|
switch {
|
||||||
|
case o4 == n4 && o6 == n6:
|
||||||
|
return QuotaKept, ""
|
||||||
|
case n4 > o4 || n6 > o6:
|
||||||
|
return QuotaRestarts, "addresses are grouped more finely now, and a group's use can't be split"
|
||||||
|
}
|
||||||
|
return QuotaRegrouped, ""
|
||||||
|
}
|
||||||
|
|
||||||
// PortForward publishes a service on an inside host: incoming connections
|
// PortForward publishes a service on an inside host: incoming connections
|
||||||
// to a public address and port are sent on (DNAT) to To[:ToPort], let
|
// to a public address and port are sent on (DNAT) to To[:ToPort], let
|
||||||
// through the forward chain, and, with Hairpin, also work from inside.
|
// through the forward chain, and, with Hairpin, also work from inside.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestQuotaCarry(t *testing.T) {
|
||||||
|
rule := func(per string, v4, v6 int) *Rule {
|
||||||
|
return &Rule{GroupV4: v4, GroupV6: v6, Quota: &Quota{Bytes: 1, PerSource: per == "src", PerDestination: per == "dst"}}
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
old, new *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{rule("", 0, 0), rule("", 0, 0), QuotaKept},
|
||||||
|
{rule("src", 0, 0), rule("src", 32, 64), QuotaKept}, // the defaults, written out
|
||||||
|
{rule("src", 0, 0), rule("", 0, 0), QuotaSummed},
|
||||||
|
{rule("", 0, 0), rule("src", 0, 0), QuotaRestarts},
|
||||||
|
{rule("src", 0, 0), rule("dst", 0, 0), QuotaRestarts},
|
||||||
|
{rule("src", 0, 0), rule("src", 24, 56), QuotaRegrouped},
|
||||||
|
{rule("src", 0, 0), rule("src", 24, 128), QuotaRestarts},
|
||||||
|
{nil, rule("src", 0, 0), QuotaKept},
|
||||||
|
} {
|
||||||
|
if got, _ := QuotaCarry(c.old, c.new); got != c.want {
|
||||||
|
t.Errorf("%+v -> %+v: %q, want %q", c.old, c.new, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -424,6 +425,9 @@ type Firewall struct {
|
|||||||
restore *QuotaUsage
|
restore *QuotaUsage
|
||||||
// adjust are quota changes the next apply makes (AdjustQuota).
|
// adjust are quota changes the next apply makes (AdjustQuota).
|
||||||
adjust []QuotaAdjust
|
adjust []QuotaAdjust
|
||||||
|
// quotaRules are the rules with a quota in the ruleset in force, by
|
||||||
|
// ID: what their usage was counted by (nil before the first apply).
|
||||||
|
quotaRules map[int64]*model.Rule
|
||||||
// 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
|
||||||
@@ -510,6 +514,73 @@ func (u *QuotaUsage) apply(adj []QuotaAdjust) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// carry keeps what quotas used when their rules now count differently
|
||||||
|
// (model.QuotaCarry): per address to a total, the sum; to wider groups,
|
||||||
|
// the groups' sums; what can't be carried starts again. was are the
|
||||||
|
// rules in force before (nil: nothing to compare with).
|
||||||
|
func (u *QuotaUsage) carry(was map[int64]*model.Rule, st *model.State) {
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
old := was[r.ID]
|
||||||
|
carry, _ := model.QuotaCarry(old, r)
|
||||||
|
if carry == model.QuotaKept {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := render.QuotaName(r.ID)
|
||||||
|
sets := []string{name + "_v4", name + "_v6"}
|
||||||
|
switch carry {
|
||||||
|
case model.QuotaSummed:
|
||||||
|
var sum int64
|
||||||
|
for _, set := range sets {
|
||||||
|
for _, e := range u.QuotaSets[set] {
|
||||||
|
sum += e.Used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Quotas == nil {
|
||||||
|
u.Quotas = map[string]int64{}
|
||||||
|
}
|
||||||
|
u.Quotas[name] = sum
|
||||||
|
case model.QuotaRegrouped:
|
||||||
|
v4, v6 := r.Groups()
|
||||||
|
for _, set := range sets {
|
||||||
|
byKey := map[string]int64{}
|
||||||
|
var order []string
|
||||||
|
for _, e := range u.QuotaSets[set] {
|
||||||
|
a, err := netip.ParseAddr(e.Addr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits := v4
|
||||||
|
if a.Is6() {
|
||||||
|
bits = v6
|
||||||
|
}
|
||||||
|
p, _ := a.Prefix(bits)
|
||||||
|
k := p.Addr().String()
|
||||||
|
if _, ok := byKey[k]; !ok {
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
byKey[k] += e.Used
|
||||||
|
}
|
||||||
|
if len(order) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var es []render.Element
|
||||||
|
for _, k := range order {
|
||||||
|
es = append(es, render.Element{Addr: k, Used: byKey[k]})
|
||||||
|
}
|
||||||
|
u.QuotaSets[set] = es
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// What was counted the other way goes: a total, or the entries.
|
||||||
|
if carry != model.QuotaSummed {
|
||||||
|
delete(u.Quotas, name)
|
||||||
|
}
|
||||||
|
for _, set := range sets {
|
||||||
|
delete(u.QuotaSets, set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -579,6 +650,7 @@ 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.carry(f.quotaRules, st)
|
||||||
usage.apply(f.adjust)
|
usage.apply(f.adjust)
|
||||||
f.adjust = nil
|
f.adjust = nil
|
||||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||||
@@ -589,6 +661,14 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.restore = nil
|
f.restore = nil
|
||||||
|
f.quotaRules = map[int64]*model.Rule{}
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
if r.Quota != nil {
|
||||||
|
c, q := *r, *r.Quota // only the quota and the grouping are read
|
||||||
|
c.Quota = &q
|
||||||
|
f.quotaRules[r.ID] = &c
|
||||||
|
}
|
||||||
|
}
|
||||||
f.flowDevs = map[string]int{}
|
f.flowDevs = map[string]int{}
|
||||||
for _, d := range st.Settings.FlowtableDevices {
|
for _, d := range st.Settings.FlowtableDevices {
|
||||||
if opt.Interfaces == nil || opt.Interfaces[d] {
|
if opt.Interfaces == nil || opt.Interfaces[d] {
|
||||||
|
|||||||
@@ -197,6 +197,21 @@ func TestQuotasInContainer(t *testing.T) {
|
|||||||
if t2, d2 := used(); t2 > total-1000 || t2 < total-2000 || d2 != 0 {
|
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)
|
t.Errorf("after giving back: total %d (was %d, gave 1000), per destination %d (was %d, reset)", t2, total, d2, dev)
|
||||||
}
|
}
|
||||||
|
// Counted in total now: what the addresses used carries over.
|
||||||
|
ping("127.0.0.3")
|
||||||
|
_, dev = used()
|
||||||
|
st.Rules[1].Quota.PerDestination = false
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tb, _ := fw.Table(ctx)
|
||||||
|
if tb.Quotas["quota_r2"] < dev || dev == 0 {
|
||||||
|
t.Errorf("per destination to a total: %d, was %d", tb.Quotas["quota_r2"], dev)
|
||||||
|
}
|
||||||
|
st.Rules[1].Quota.PerDestination = true
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
// 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)
|
||||||
@@ -211,7 +226,7 @@ func TestQuotasInContainer(t *testing.T) {
|
|||||||
// saved usage comes back.
|
// saved usage comes back.
|
||||||
ping("127.0.0.2")
|
ping("127.0.0.2")
|
||||||
ping("127.0.0.3")
|
ping("127.0.0.3")
|
||||||
tb, _ := fw.Table(ctx)
|
tb, _ = fw.Table(ctx)
|
||||||
saved := tb.QuotaUsage
|
saved := tb.QuotaUsage
|
||||||
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
|
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
|
||||||
fw.RestoreQuotas(&saved)
|
fw.RestoreQuotas(&saved)
|
||||||
@@ -240,6 +255,45 @@ func TestQuotaAdjust(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuotaCarry: usage follows a rule counting its quota differently.
|
||||||
|
func TestQuotaCarry(t *testing.T) {
|
||||||
|
perSource := &model.Rule{ID: 2, Quota: &model.Quota{Bytes: 1 << 30, PerSource: true}}
|
||||||
|
usage := func() QuotaUsage {
|
||||||
|
return QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{
|
||||||
|
"quota_r2_v4": {{Addr: "10.0.0.1", Used: 100}, {Addr: "10.0.0.2", Used: 50}},
|
||||||
|
"quota_r2_v6": {{Addr: "2001:db8:0:1::", Used: 7}, {Addr: "2001:db8:0:2::", Used: 3}}}}
|
||||||
|
}
|
||||||
|
was := map[int64]*model.Rule{2: perSource}
|
||||||
|
with := func(f func(r *model.Rule)) *model.State {
|
||||||
|
r := *perSource
|
||||||
|
q := *perSource.Quota
|
||||||
|
r.Quota = &q
|
||||||
|
f(&r)
|
||||||
|
return &model.State{Rules: []*model.Rule{&r}}
|
||||||
|
}
|
||||||
|
u := usage()
|
||||||
|
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource = false }))
|
||||||
|
if u.Quotas["quota_r2"] != 160 || len(u.QuotaSets["quota_r2_v4"]) != 0 {
|
||||||
|
t.Errorf("to a total: %+v", u)
|
||||||
|
}
|
||||||
|
u = usage()
|
||||||
|
u.carry(was, with(func(r *model.Rule) { r.GroupV4, r.GroupV6 = 24, 48 }))
|
||||||
|
want := map[string][]render.Element{"quota_r2_v4": {{Addr: "10.0.0.0", Used: 150}}, "quota_r2_v6": {{Addr: "2001:db8::", Used: 10}}}
|
||||||
|
if !reflect.DeepEqual(u.QuotaSets, want) {
|
||||||
|
t.Errorf("wider groups: %+v", u.QuotaSets)
|
||||||
|
}
|
||||||
|
u = usage()
|
||||||
|
u.carry(was, with(func(r *model.Rule) { r.Quota.PerSource, r.Quota.PerDestination = false, true }))
|
||||||
|
if len(u.QuotaSets) != 0 {
|
||||||
|
t.Errorf("per destination now: %+v", u.QuotaSets)
|
||||||
|
}
|
||||||
|
u = usage()
|
||||||
|
u.carry(nil, with(func(r *model.Rule) { r.Quota.PerSource = false }))
|
||||||
|
if len(u.QuotaSets["quota_r2_v4"]) != 2 || u.Quotas["quota_r2"] != 0 {
|
||||||
|
t.Errorf("nothing to compare with (the first apply): %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -141,6 +141,16 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
|||||||
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
|
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
|
||||||
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks}
|
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks}
|
||||||
s.routes()
|
s.routes()
|
||||||
|
// What the quotas used is saved after every change too, not only every
|
||||||
|
// five minutes: a change that counts a quota differently converts its
|
||||||
|
// usage, and a restart must not bring back the old kind.
|
||||||
|
prev := eng.OnChange
|
||||||
|
eng.OnChange = func(st *model.State) {
|
||||||
|
if prev != nil {
|
||||||
|
prev(st)
|
||||||
|
}
|
||||||
|
s.SaveQuotaUsage(context.Background())
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -792,6 +792,21 @@ func TestQuotaResets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuotaUsageSavedOnChange: a change saves what the quotas used at
|
||||||
|
// once (a restart must not bring back usage counted the old way).
|
||||||
|
func TestQuotaUsageSavedOnChange(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||||
|
e.fw.mu.Lock()
|
||||||
|
e.fw.quotas = nft.QuotaUsage{Quotas: map[string]int64{"quota_r7": 4242}}
|
||||||
|
e.fw.mu.Unlock()
|
||||||
|
e.call(e.token, "POST", "/zones", model.Zone{Name: "dmz", Interfaces: []string{"eth2"}}, nil)
|
||||||
|
v, err := e.eng.Store().GetMeta(context.Background(), QuotaUsageKey)
|
||||||
|
if err != nil || !strings.Contains(v, `"quota_r7":4242`) {
|
||||||
|
t.Errorf("saved usage %q %v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAutoConfirm: the client that made a change confirms it by itself:
|
// TestAutoConfirm: the client that made a change confirms it by itself:
|
||||||
// with its token, from the same address, over a new connection. Nobody
|
// with its token, from the same address, over a new connection. Nobody
|
||||||
// else can, an older token can't, and with manual_confirm there is none.
|
// else can, an older token can't, and with manual_confirm there is none.
|
||||||
|
|||||||
@@ -402,7 +402,10 @@ func (c *Collector) sample(ctx context.Context, minute int64) map[string]int64 {
|
|||||||
for set, es := range t.QuotaSets {
|
for set, es := range t.QuotaSets {
|
||||||
kind := strings.TrimSuffix(strings.TrimSuffix(set, "_v4"), "_v6")
|
kind := strings.TrimSuffix(strings.TrimSuffix(set, "_v4"), "_v6")
|
||||||
for _, e := range es {
|
for _, e := range es {
|
||||||
addTop(kind, e.Addr, c.table.of(set+"/"+e.Addr, e.Used))
|
d := c.table.of(set+"/"+e.Addr, e.Used)
|
||||||
|
addTop(kind, e.Addr, d)
|
||||||
|
// A per-address quota's series is its addresses' sum.
|
||||||
|
add("quota:"+strings.TrimPrefix(kind, "quota_"), d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for set, es := range t.DeviceSets {
|
for set, es := range t.DeviceSets {
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ func TestCollector(t *testing.T) {
|
|||||||
h.table.Counters["r1"] = nft.Counter{Packets: 10, Bytes: 1000}
|
h.table.Counters["r1"] = nft.Counter{Packets: 10, Bytes: 1000}
|
||||||
h.table.Counters["r2"] = nft.Counter{Packets: 100, Bytes: 9000}
|
h.table.Counters["r2"] = nft.Counter{Packets: 100, Bytes: 9000}
|
||||||
h.table.Quotas["quota_r5"] = 1 << 20
|
h.table.Quotas["quota_r5"] = 1 << 20
|
||||||
|
if h.table.QuotaSets == nil {
|
||||||
|
h.table.QuotaSets = map[string][]render.Element{}
|
||||||
|
}
|
||||||
|
h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 1000}}
|
||||||
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}}
|
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}}
|
||||||
|
|
||||||
store, err := OpenStore(":memory:")
|
store, err := OpenStore(":memory:")
|
||||||
@@ -104,6 +108,7 @@ func TestCollector(t *testing.T) {
|
|||||||
h.table.Counters["r1"] = nft.Counter{Packets: 25, Bytes: 2500}
|
h.table.Counters["r1"] = nft.Counter{Packets: 25, Bytes: 2500}
|
||||||
h.table.Counters["p-input"] = nft.Counter{Packets: 3, Bytes: 180} // a new counter counts whole
|
h.table.Counters["p-input"] = nft.Counter{Packets: 3, Bytes: 180} // a new counter counts whole
|
||||||
h.table.Quotas["quota_r5"] = 3 << 20
|
h.table.Quotas["quota_r5"] = 3 << 20
|
||||||
|
h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 2000}, {Addr: "10.0.10.6", Used: 500}} // a new entry counts whole
|
||||||
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 8000}}
|
h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 8000}}
|
||||||
h.table.DeviceSets["stats_down_v6"] = []render.Element{{Addr: "2001:db8:1:2::", Bytes: 70000}}
|
h.table.DeviceSets["stats_down_v6"] = []render.Element{{Addr: "2001:db8:1:2::", Bytes: 70000}}
|
||||||
for range 2 {
|
for range 2 {
|
||||||
@@ -142,6 +147,7 @@ func TestCollector(t *testing.T) {
|
|||||||
check("drop:forward", sum("drop:")["forward"], 15)
|
check("drop:forward", sum("drop:")["forward"], 15)
|
||||||
check("drop:input", sum("drop:")["input"], 3)
|
check("drop:input", sum("drop:")["input"], 3)
|
||||||
check("quota:r5", sum("quota:")["r5"], 2<<20)
|
check("quota:r5", sum("quota:")["r5"], 2<<20)
|
||||||
|
check("quota:r6 (per address: the sum)", sum("quota:")["r6"], 1500)
|
||||||
|
|
||||||
s, err := q.Summary(ctx, time.Hour, 10)
|
s, err := q.Summary(ctx, time.Hour, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ export function heatmapTable(hm, unit) {
|
|||||||
// so the list is its own table view.
|
// so the list is its own table view.
|
||||||
// extra(key), when given, adds something after an entry's label (e.g. a
|
// extra(key), when given, adds something after an entry's label (e.g. a
|
||||||
// link that excludes it).
|
// link that excludes it).
|
||||||
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact } = {}) {
|
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact, share: showShare = true } = {}) {
|
||||||
if (!entries.length) return h("div", { class: "empty" }, empty);
|
if (!entries.length) return h("div", { class: "empty" }, empty);
|
||||||
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
// Entries may be in a natural order (e.g. latency buckets), not by size.
|
||||||
const peak = Math.max(1, ...entries.map((e) => e.count));
|
const peak = Math.max(1, ...entries.map((e) => e.count));
|
||||||
@@ -345,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
|
|||||||
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" },
|
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" },
|
||||||
h("div", { class: "bar-text" },
|
h("div", { class: "bar-text" },
|
||||||
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
|
||||||
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
|
h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), showShare ? h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%") : null)),
|
||||||
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { h, mount, table, badge, problemsList, time, plural } from "../ui.js";
|
import { h, mount, table, badge, problemsList, time, plural } from "../ui.js";
|
||||||
import { get, can } from "../api.js";
|
import { get, can } from "../api.js";
|
||||||
import { onLeave } from "../app.js";
|
import { onLeave } from "../app.js";
|
||||||
import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named } from "../widgets.js";
|
import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named, pct } from "../widgets.js";
|
||||||
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
|
import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
|
||||||
import { bytes } from "./stats.js";
|
import { bytes } from "./stats.js";
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ function quotaPanel(quotas, names) {
|
|||||||
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) },
|
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) },
|
||||||
{ label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") },
|
{ label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") },
|
||||||
{ label: "Used", render: (q) => perAddress(q)
|
{ label: "Used", render: (q) => perAddress(q)
|
||||||
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? [", busiest ", named(names, q.addresses[0].address), " " + fmtBytes(used(q))] : ""]
|
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? [", busiest ", named(names, q.addresses[0].address), " " + fmtBytes(used(q)) + " (" + pct(used(q), q.quota.bytes) + ")"] : ""]
|
||||||
: fmtBytes(q.used) },
|
: fmtBytes(q.used) + " (" + pct(q.used, q.quota.bytes) + ")" },
|
||||||
{ label: "", render: (q) => usageBar(used(q), q.quota.bytes) },
|
{ label: "", render: (q) => usageBar(used(q), q.quota.bytes) },
|
||||||
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
|
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
|
||||||
], quotas));
|
], quotas));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
||||||
import { get, api, change, can } from "../api.js";
|
import { get, api, change, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named } from "../widgets.js";
|
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named, pct } from "../widgets.js";
|
||||||
|
|
||||||
const chains = [
|
const chains = [
|
||||||
["input", "Input", "Traffic to the firewall itself."],
|
["input", "Input", "Traffic to the firewall itself."],
|
||||||
@@ -165,10 +165,10 @@ function quotaLine(r, q) {
|
|||||||
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" : qt.reset || "reset by hand";
|
const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand";
|
||||||
let used = fmtBytes(qt.bytes) + each;
|
let used = fmtBytes(qt.bytes) + each;
|
||||||
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used";
|
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")";
|
||||||
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 " + fmtBytes(q.addresses[0].used) : "");
|
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) + " (" + pct(q.addresses[0].used, qt.bytes) + ")" : "");
|
||||||
}
|
}
|
||||||
const text = "quota " + used + " · " + resets;
|
const text = "quota " + used + " · " + resets;
|
||||||
return h("div", { class: "muted small" }, q && r.pending !== "new"
|
return h("div", { class: "muted small" }, q && r.pending !== "new"
|
||||||
@@ -193,12 +193,13 @@ async function quotaDialog(r, q) {
|
|||||||
perAddress ? table([
|
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: "", class: "num", tdClass: "num", render: (a) => pct(a.used, qt.bytes) },
|
||||||
{ label: "", render: (a) => usageBar(a.used, qt.bytes) },
|
{ label: "", render: (a) => usageBar(a.used, qt.bytes) },
|
||||||
write ? { label: "", render: (a) => h("span", { class: "nowrap" },
|
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(() => resetQuota(r, a.address)) }, "Reset"), " ",
|
||||||
h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null,
|
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." })
|
].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))),
|
: h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")", usageBar(q.used, qt.bytes))),
|
||||||
[
|
[
|
||||||
...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []),
|
...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []),
|
||||||
{ label: "Close", value: null },
|
{ label: "Close", value: null },
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export async function render(el, params, query) {
|
|||||||
listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })),
|
listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })),
|
||||||
listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint),
|
listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint),
|
||||||
listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)),
|
listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)),
|
||||||
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => "rule #" + k.slice(1), href: () => "#/rules" })) : null,
|
d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => (d.hits.find((x) => x.key === k) || {}).name || "rule #" + k.slice(1), href: ruleHref, share: false })) : null,
|
||||||
listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove",
|
listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove",
|
||||||
unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))),
|
unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))),
|
||||||
unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something."))));
|
unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something."))));
|
||||||
|
|||||||
@@ -98,11 +98,16 @@ export function matchEditor(m, n, opts = {}) {
|
|||||||
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
||||||
export function describe(m) {
|
export function describe(m) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); };
|
const list = (not, xs) => (not ? "any but " : "") + xs.join(", ");
|
||||||
side("in", m.not_in, m.in_zones);
|
// Zones are a direction, "lan → wan"; the firewall itself is the other
|
||||||
side("out", m.not_out, m.out_zones);
|
// end of the input and output chains.
|
||||||
side("from", m.not_sources, m.sources);
|
const ins = m.in_zones || [], outs = m.out_zones || [];
|
||||||
side("to", m.not_destinations, m.destinations);
|
if (ins.length || outs.length) {
|
||||||
|
parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " +
|
||||||
|
(m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any"));
|
||||||
|
}
|
||||||
|
if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources));
|
||||||
|
if (m.destinations && m.destinations.length) parts.push("to " + list(m.not_destinations, m.destinations));
|
||||||
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
||||||
if (m.family) parts.push(m.family + " only");
|
if (m.family) parts.push(m.family + " only");
|
||||||
return parts.length ? parts.join(" · ") : "everything";
|
return parts.length ? parts.join(" · ") : "everything";
|
||||||
@@ -156,6 +161,13 @@ export function fmtBytes(n) {
|
|||||||
return n + " bytes";
|
return n + " bytes";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pct writes used as a share of total: "25%", "<1%" for a little.
|
||||||
|
export function pct(used, total) {
|
||||||
|
if (!total || used <= 0) return "0%";
|
||||||
|
const p = used / total * 100;
|
||||||
|
return p < 1 ? "<1%" : Math.floor(p) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
// usageBar draws used out of total as a bar, red once it is all used.
|
// usageBar draws used out of total as a bar, red once it is all used.
|
||||||
export function usageBar(used, total) {
|
export function usageBar(used, total) {
|
||||||
const seg = h("div", { class: "seg leased" });
|
const seg = h("div", { class: "seg leased" });
|
||||||
|
|||||||
+10
-1
@@ -322,7 +322,7 @@ send 2000000 >/dev/null 2>&1
|
|||||||
run "used up: the device is cut off" 1 send 1000
|
run "used up: the device is cut off" 1 send 1000
|
||||||
run "the usage" 0 H quotas
|
run "the usage" 0 H quotas
|
||||||
contains "e2e quota.*1.0 MB each.*by hand"
|
contains "e2e quota.*1.0 MB each.*by hand"
|
||||||
contains "$CLIENT4 (e2e-client) *1\.[0-9] MB"
|
contains "$CLIENT4 (e2e-client) *1\.[0-9] MB (1[0-9][0-9]%)"
|
||||||
stop; start
|
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
|
||||||
@@ -342,6 +342,15 @@ 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"
|
contains "quota.give"
|
||||||
|
# Counted in total now: what the device used carries over; per
|
||||||
|
# destination can't take it, and says so.
|
||||||
|
run "within the quota" 0 send 300000
|
||||||
|
run "to one total" 0 H rule update "$QR" --quota 1mbytes,over
|
||||||
|
not_contains "quota_restarts"
|
||||||
|
run "the total carried over" 0 H quotas
|
||||||
|
contains "e2e quota) *[0-9.]* KB ([0-9]*%)"
|
||||||
|
run "per destination starts again" 0 H rule update "$QR" --quota 1mbytes,over,per-destination
|
||||||
|
contains "quota_restarts"
|
||||||
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