Add changesets, bandwidth limits and quotas; generic examples

Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 11:18:54 +01:00
1 parent ebec2cfcca
commit 2880f7ddc9
63 files changed
+4018 -385

No files matched your search

+55 -2
View File
@@ -85,6 +85,23 @@ func Check(st *model.State, env *Env) []Problem {
return c.out
}
// maxByteRate is the largest byte rate per period nftables (1.1.3) can
// hold: the kernel's token bucket multiplies the rate by the period.
var maxByteRate = map[string]int64{"second": 4294967294, "minute": 307445734, "hour": 5124095, "day": 213503}
// byteSize writes bytes the way nftables reads them: "1 mbytes".
func byteSize(n int64) string {
for _, u := range []string{"mbytes", "kbytes"} {
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
return fmt.Sprintf("%d %s", n/model.ByteUnits[u], u)
}
}
if n >= 10*model.ByteUnits["kbytes"] {
return fmt.Sprintf("%d kbytes", n/model.ByteUnits["kbytes"])
}
return fmt.Sprintf("%d bytes", n)
}
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,47}$`)
// ifaceRe: Linux interface names (15 bytes), with an optional trailing *
@@ -348,13 +365,49 @@ func (c *checker) rule(r *model.Rule) {
c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)")
}
if l := r.Limit; l != nil {
if l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0 {
c.add(Error, "rule_limit", obj, "a rate is a number of packets per second, minute, hour or day, with an optional burst")
_, byteUnit := model.ByteUnits[l.Unit]
switch {
case l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0:
c.add(Error, "rule_limit", obj, "a rate is a number of packets (or bytes, kbytes, mbytes) per second, minute, hour or day, with an optional burst")
case l.Unit != "" && l.Unit != "packets" && (!byteUnit || l.Unit == "gbytes"):
c.add(Error, "rule_limit_unit", obj, "the rate's unit is %q; use packets, bytes, kbytes or mbytes", l.Unit)
case byteUnit && int64(l.Rate)*model.ByteUnits[l.Unit] > maxByteRate[l.Per]:
perSecond := int64(l.Rate) * model.ByteUnits[l.Unit] / map[string]int64{"second": 1, "minute": 60, "hour": 3600, "day": 86400}[l.Per]
c.add(Error, "rule_limit_bytes", obj, "nftables can't hold a byte rate of %d %s per %s (at most %s per %s); give it per second: %s/second",
l.Rate, l.Unit, l.Per, byteSize(maxByteRate[l.Per]), l.Per, byteSize(perSecond))
}
if l.PerSource && l.PerDestination {
c.add(Error, "rule_limit_key", obj, "a limit is per source or per destination, not both")
}
}
if r.ConnLimit < 0 {
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
}
if q := r.Quota; q != nil {
if q.Bytes < 1 {
c.add(Error, "rule_quota", obj, "a quota is an amount of bytes, 1 or more")
}
if q.PerSource && q.PerDestination {
c.add(Error, "rule_quota_key", obj, "a quota is per source or per destination, not both")
}
switch {
case q.Reset != "" && q.Reset != "daily" && q.Reset != "weekly" && q.Reset != "monthly":
c.add(Error, "rule_quota_reset", obj, "the quota resets %q; use daily, weekly, monthly, or nothing (only by hand)", q.Reset)
case q.Day != 0 && q.Reset != "monthly":
c.add(Error, "rule_quota_reset", obj, "a day of the month is only for a monthly reset")
case q.Day < 0 || q.Day > 31:
c.add(Error, "rule_quota_reset", obj, "the day of the month is 1 to 31 (a later day than a month has means its last day)")
}
}
if r.CountsTraffic() && r.Action == model.ActionAccept {
c.add(Error, "rule_traffic_accept", obj, "a rule with a quota or a byte rate counts every packet, before established connections are accepted, so it can only drop, reject or log; let the traffic in with a rule of its own")
}
if r.CountsTraffic() && r.Chain == model.ChainForward && len(c.st.Settings.FlowtableDevices) > 0 && !r.Disabled {
c.add(Warning, "flowtable_traffic", obj, "the flowtable's fast path skips the forward chain once a connection is established, so this quota or byte rate only counts each connection's first packets; turn the flowtable off (settings) to count all of it")
}
if r.GroupV4 < 0 || r.GroupV4 > 32 || r.GroupV6 < 0 || r.GroupV6 > 128 {
c.add(Error, "rule_group", obj, "group addresses by a prefix length: 1 to 32 for IPv4, 1 to 128 for IPv6")
}
if r.RejectWith != "" {
if r.RejectWith != "tcp-reset" {
c.add(Error, "reject_with", obj, "reject with is %q; use tcp-reset, or nothing for an ICMP error", r.RejectWith)
+15
View File
@@ -91,6 +91,21 @@ func TestCodes(t *testing.T) {
{"rule_hours", func(st *model.State) { rule(st).HourFrom = "08:00" }},
{"rule_limit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 10, Per: "week"} }},
{"rule_conn_limit", func(st *model.State) { rule(st).ConnLimit = -1 }},
{"rule_limit_unit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 1, Per: "second", Unit: "gbytes"} }},
{"rule_limit_bytes", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 100, Per: "hour", Unit: "mbytes"} }},
{"rule_limit_key", func(st *model.State) {
rule(st).Limit = &model.Limit{Rate: 1, Per: "second", PerSource: true, PerDestination: true}
}},
{"rule_quota", func(st *model.State) { rule(st).Quota = &model.Quota{} }},
{"rule_quota_key", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, PerSource: true, PerDestination: true} }},
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "yearly"} }},
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "daily", Day: 3} }},
{"rule_group", func(st *model.State) { rule(st).GroupV6 = 129 }},
{"rule_traffic_accept", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1 << 20} }},
{"flowtable_traffic", func(st *model.State) {
st.Settings.FlowtableDevices = []string{"wan"}
st.Rules[1].Action, st.Rules[1].Quota = model.ActionDrop, &model.Quota{Bytes: 1 << 20, Over: true}
}},
{"reject_with", func(st *model.State) { rule(st).RejectWith = "tcp-reset" }},
{"log_prefix", func(st *model.State) { rule(st).Log = true; rule(st).LogPrefix = strings.Repeat("x", 101) }},
{"forward_protocol", func(st *model.State) { st.Forwards[0].Protocols = []string{"icmp"} }},