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

+237 -27
View File
@@ -13,6 +13,7 @@ package importer
import (
"encoding/json"
"fmt"
"net/netip"
"regexp"
"sort"
"strconv"
@@ -115,6 +116,7 @@ type importer struct {
portNames map[string]string // canonical port list → define name
ifNames map[string]string // interface → define name
used map[string]bool // sets created
meters map[string]bool // sets used as meters
noted map[string]bool
positions map[string]float64
}
@@ -199,6 +201,7 @@ func Import(in Input) (*Result, error) {
}
im.readText()
im.readDefines()
im.findMeters()
im.importSets()
im.run()
im.nameForwards()
@@ -313,6 +316,27 @@ func validIface(s string) bool {
// ---- sets
// importSets turns the table's named address sets into sets.
// findMeters notes the sets rules use as meters (set statements: "update
// @m { ip saddr limit ... }"): they hold per-address state, not
// addresses, and become the rules' per-address limits and quotas.
func (im *importer) findMeters() {
im.meters = map[string]bool{}
for _, rules := range im.rules {
for _, r := range rules {
for _, e := range r.Expr {
var x struct {
Set *struct {
Set string `json:"set"`
} `json:"set"`
}
if json.Unmarshal(e, &x) == nil && x.Set != nil {
im.meters[strings.TrimPrefix(x.Set.Set, "@")] = true
}
}
}
}
}
func (im *importer) importSets() {
names := make([]string, 0, len(im.sets))
for n := range im.sets {
@@ -320,6 +344,9 @@ func (im *importer) importSets() {
}
sort.Strings(names)
for _, n := range names {
if im.meters[n] {
continue
}
s := im.sets[n]
var typ string
json.Unmarshal(s.Type, &typ)
@@ -456,19 +483,24 @@ func values(raw json.RawMessage) []string {
// parsed is one rule, read.
type parsed struct {
inIf, outIf []string
notIn, notOut bool
src, dst []string // entries, or "@set"
notSrc, notDst bool
family string
proto string
dports []string
icmp []string
icmpProto string
ctState, ctStatus []string
hop255 bool
tcpSyn bool
limit *model.Limit
inIf, outIf []string
notIn, notOut bool
src, dst []string // entries, or "@set"
notSrc, notDst bool
family string
proto string
dports []string
icmp []string
icmpProto string
ctState, ctStatus []string
hop255 bool
tcpSyn bool
limit *model.Limit
quota *model.Quota
connLimit int
// group4, group6: a meter's key grouped by a prefix (ip saddr and
// 255.255.255.0); 0 for none.
group4, group6 int
log bool
logPrefix string
verdict string // accept, drop, reject, jump, goto, return
@@ -547,19 +579,21 @@ func (p *parsed) expr(k string, v json.RawMessage) {
json.Unmarshal(v, &x)
p.log, p.logPrefix = true, x.Prefix
case "limit":
var x struct {
Rate int `json:"rate"`
Per string `json:"per"`
Burst int `json:"burst"`
Inv bool `json:"inv"`
Unit string `json:"rate_unit"`
}
json.Unmarshal(v, &x)
if x.Unit != "" && x.Unit != "packets" {
p.bad("a byte rate limit")
l, ok := limitJSON(v)
if !ok {
p.bad("a limit Housecarl can't read")
return
}
p.limit = &model.Limit{Rate: x.Rate, Per: x.Per, Burst: x.Burst, Over: x.Inv}
p.limit = l
case "quota":
q, ok := quotaJSON(v)
if !ok {
p.bad("a quota Housecarl can't read")
return
}
p.quota = q
case "set":
p.meter(v)
case "dnat", "snat":
var x struct {
Family string `json:"family"`
@@ -625,6 +659,172 @@ func (p *parsed) expr(k string, v json.RawMessage) {
}
}
// limitJSON reads a limit: packets, or bytes (rate_unit, burst_unit).
func limitJSON(v json.RawMessage) (*model.Limit, bool) {
var x struct {
Rate int64 `json:"rate"`
Per string `json:"per"`
Burst int64 `json:"burst"`
Inv bool `json:"inv"`
Unit string `json:"rate_unit"`
BurstUnit string `json:"burst_unit"`
}
if json.Unmarshal(v, &x) != nil {
return nil, false
}
l := &model.Limit{Rate: int(x.Rate), Per: x.Per, Burst: int(x.Burst), Over: x.Inv}
if x.Unit == "" || x.Unit == "packets" {
return l, true
}
// Bytes: both in the largest unit that holds them exactly.
rm, ok1 := model.ByteUnits[x.Unit]
bu := x.BurstUnit
if bu == "" {
bu = "bytes"
}
bm, ok2 := model.ByteUnits[bu]
if !ok1 || !ok2 {
return nil, false
}
rate, burst := x.Rate*rm, x.Burst*bm
for _, u := range []string{"mbytes", "kbytes", "bytes"} {
m := model.ByteUnits[u]
if rate%m == 0 && burst%m == 0 {
l.Rate, l.Burst, l.Unit = int(rate/m), int(burst/m), u
break
}
}
return l, true
}
// quotaJSON reads a quota: {"val": 100, "val_unit": "mbytes", "inv": true}.
func quotaJSON(v json.RawMessage) (*model.Quota, bool) {
var x struct {
Val int64 `json:"val"`
Unit string `json:"val_unit"`
Inv bool `json:"inv"`
}
if json.Unmarshal(v, &x) != nil {
return nil, false
}
m, ok := model.ByteUnits[x.Unit]
if x.Unit == "" {
m, ok = 1, true
}
if !ok || x.Val < 1 {
return nil, false
}
return &model.Quota{Bytes: x.Val * m, Over: x.Inv}, true
}
// meter reads a set statement that keeps a limit, connection count or
// quota per address (a meter: "update @m { ip saddr limit rate over
// 3/minute }"), keyed by source or destination, perhaps grouped by a
// prefix.
func (p *parsed) meter(v json.RawMessage) {
var x struct {
Op string `json:"op"`
Elem json.RawMessage `json:"elem"`
Stmt []json.RawMessage `json:"stmt"`
}
if json.Unmarshal(v, &x) != nil || (x.Op != "add" && x.Op != "update") || len(x.Stmt) != 1 {
p.bad("a set statement")
return
}
// The key: ip saddr, or ip saddr and MASK.
var key map[string]json.RawMessage
json.Unmarshal(x.Elem, &key)
bits := 0
if and, ok := key["&"]; ok {
var pair []json.RawMessage
var mask string
if json.Unmarshal(and, &pair) != nil || len(pair) != 2 || json.Unmarshal(pair[1], &mask) != nil {
p.bad("a meter key")
return
}
a, err := netip.ParseAddr(mask)
if err != nil {
p.bad("a meter key")
return
}
pfx, ok := maskBits(a)
if !ok {
p.bad("a meter key with a mask that isn't a prefix")
return
}
bits = pfx
key = nil
json.Unmarshal(pair[0], &key)
}
var pl struct{ Protocol, Field string }
if raw, ok := key["payload"]; !ok || json.Unmarshal(raw, &pl) != nil || (pl.Protocol != "ip" && pl.Protocol != "ip6") || (pl.Field != "saddr" && pl.Field != "daddr") {
p.bad("a meter keyed by something other than an address")
return
}
if bits > 0 {
if pl.Protocol == "ip" {
p.group4 = bits
} else {
p.group6 = bits
}
}
if p.family == "" {
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[pl.Protocol]
}
var stmt map[string]json.RawMessage
json.Unmarshal(x.Stmt[0], &stmt)
switch {
case stmt["limit"] != nil:
l, ok := limitJSON(stmt["limit"])
if !ok {
p.bad("a meter's limit")
return
}
l.PerSource, l.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
p.limit = l
case stmt["ct count"] != nil:
var c struct {
Val int `json:"val"`
Inv bool `json:"inv"`
}
if json.Unmarshal(stmt["ct count"], &c) != nil || !c.Inv || pl.Field != "saddr" {
p.bad("a connection count other than over N per source")
return
}
p.connLimit = c.Val
case stmt["quota"] != nil:
q, ok := quotaJSON(stmt["quota"])
if !ok {
p.bad("a meter's quota")
return
}
q.PerSource, q.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
p.quota = q
default:
p.bad("a set statement")
}
}
// maskBits is a mask's prefix length (255.255.255.0: 24), if it is one:
// ones, then only zeros.
func maskBits(a netip.Addr) (int, bool) {
n, zero := 0, false
for _, x := range a.AsSlice() {
for bit := 7; bit >= 0; bit-- {
one := x&(1<<bit) != 0
switch {
case one && zero:
return 0, false
case one:
n++
default:
zero = true
}
}
}
return n, true
}
func (p *parsed) match(m match) {
var left map[string]json.RawMessage
json.Unmarshal(m.Left, &left)
@@ -972,7 +1172,7 @@ var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-b
var nd6 = map[string]bool{"nd-router-solicit": true, "nd-router-advert": true, "nd-neighbor-solicit": true, "nd-neighbor-advert": true}
func plain(p *parsed) bool {
return len(p.unsupported) == 0 && p.limit == nil && !p.log && len(p.src) == 0 && len(p.dst) == 0 && len(p.outIf) == 0
return len(p.unsupported) == 0 && p.limit == nil && p.quota == nil && p.connLimit == 0 && !p.log && len(p.src) == 0 && len(p.dst) == 0 && len(p.outIf) == 0
}
// builtinOf names the built-in a rule is, or "".
@@ -1203,7 +1403,8 @@ func (im *importer) rule(chain string, p *parsed) {
return
}
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain), Comment: p.comment, CtState: p.ctState, CtStatus: p.ctStatus,
Limit: p.limit, Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
Limit: p.limit, Quota: p.quota, ConnLimit: p.connLimit, GroupV4: p.group4, GroupV6: p.group6,
Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
switch p.verdict {
case "accept", "drop", "reject":
r.Action = p.verdict
@@ -1222,6 +1423,15 @@ func (im *importer) rule(chain string, p *parsed) {
p.bad(p.verdict)
}
m, ok := im.toMatch(p)
if r.CountsTraffic() && r.Action == model.ActionAccept {
// Housecarl checks these before established connections are
// accepted, where accepting would skip the later rules: as written.
p.bad("an accept with a quota or a byte rate")
ok = false
}
if ok && r.CountsTraffic() && chain != model.ChainEarly {
im.note("%s rule handle %d has a quota or a byte rate: Housecarl checks it before established connections are accepted, so it counts every packet of a connection", chain, p.handle)
}
if ok && !p.hop255 && !p.mss && !p.tcpSyn && !p.flowAdd && p.dnatAddr == "" && !p.masq {
r.Match = m
im.st.Rules = append(im.st.Rules, r)
@@ -1315,7 +1525,7 @@ func forwardName(comment string) string {
}
// nameForwards makes the forwards' names (from their comments) valid and
// unique: "8096 to jellyfin" becomes "fwd-8096-to-jellyfin", a second
// unique: "8080 to web" becomes "fwd-8080-to-web", a second
// "web" becomes "web-2".
func (im *importer) nameForwards() {
seen := map[string]bool{}