model.QuotaCarry says what becomes of a quota's use when its rule changes how it counts; the firewall's apply follows it, comparing with the rules in force: per address to a total starts at the addresses' sum, wider groups add up their addresses, and what can't be carried (a total to per address, per source to per destination, finer groups) starts again, with the engine warning quota_restarts on that change. Before, per source to per destination kept the sources' use as destinations', and regrouping left old entries behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
13 KiB
Go
395 lines
13 KiB
Go
package model
|
|
|
|
import "fmt"
|
|
|
|
// Chains rules are placed in.
|
|
const (
|
|
// ChainEarly sees packets before connection tracking (prerouting,
|
|
// priority raw): cheap drops, antispoofing.
|
|
ChainEarly = "early"
|
|
// ChainInput is traffic to the firewall itself.
|
|
ChainInput = "input"
|
|
// ChainForward is traffic through the firewall.
|
|
ChainForward = "forward"
|
|
// ChainOutput is traffic from the firewall itself.
|
|
ChainOutput = "output"
|
|
)
|
|
|
|
// Chains lists the rule chains in display order.
|
|
var Chains = []string{ChainEarly, ChainInput, ChainForward, ChainOutput}
|
|
|
|
// ChainOrder is a chain's place in Chains.
|
|
func ChainOrder(c string) int {
|
|
for i, x := range Chains {
|
|
if x == c {
|
|
return i
|
|
}
|
|
}
|
|
return len(Chains)
|
|
}
|
|
|
|
// Rule actions.
|
|
const (
|
|
ActionAccept = "accept"
|
|
ActionDrop = "drop"
|
|
ActionReject = "reject"
|
|
// ActionLog only logs (and counts); the packet goes on to the next
|
|
// rule.
|
|
ActionLog = "log"
|
|
)
|
|
|
|
// Actions lists the rule actions.
|
|
var Actions = []string{ActionAccept, ActionDrop, ActionReject, ActionLog}
|
|
|
|
// Families a rule can be limited to; "" is both.
|
|
const (
|
|
FamilyIPv4 = "ipv4"
|
|
FamilyIPv6 = "ipv6"
|
|
)
|
|
|
|
// Zone is a group of interfaces (wildcards such as "incus*" allowed).
|
|
// External zones face the internet: built-ins such as antispoofing and
|
|
// MSS clamping apply to them.
|
|
type Zone struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Interfaces []string `json:"interfaces"`
|
|
External bool `json:"external,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
func (z *Zone) GetID() int64 { return z.ID }
|
|
func (z *Zone) Label() string { return z.Name }
|
|
|
|
// AddressSet is a named list of IPv4 and IPv6 addresses, prefixes and
|
|
// ranges. A dynamic set also takes entries at run time, each with a
|
|
// timeout, through its own API (a captive portal's signed-in clients).
|
|
type AddressSet struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Entries []string `json:"entries"`
|
|
Dynamic bool `json:"dynamic,omitempty"`
|
|
// Timeout is a dynamic set's default entry lifetime in seconds (0:
|
|
// entries stay until removed).
|
|
Timeout int `json:"timeout,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
func (s *AddressSet) GetID() int64 { return s.ID }
|
|
func (s *AddressSet) Label() string { return s.Name }
|
|
|
|
// Service is a named list of ports ("tcp/22", "udp/60000-61000") and ICMP
|
|
// types ("icmp/echo-request", "icmpv6/packet-too-big"). Rules name
|
|
// services, or give such entries directly.
|
|
type Service struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Entries []string `json:"entries"`
|
|
Comment string `json:"comment,omitempty"`
|
|
// Builtin marks the catalog's services (never stored).
|
|
Builtin bool `json:"builtin,omitempty"`
|
|
}
|
|
|
|
func (s *Service) GetID() int64 { return s.ID }
|
|
func (s *Service) Label() string { return s.Name }
|
|
|
|
// Match is what a rule, port forward, source NAT or redirect looks at.
|
|
// Lists are ORed; the fields together are ANDed.
|
|
type Match struct {
|
|
// InZones and OutZones are zone names; with NotIn/NotOut, every zone
|
|
// but those.
|
|
InZones []string `json:"in_zones,omitempty"`
|
|
NotIn bool `json:"not_in,omitempty"`
|
|
OutZones []string `json:"out_zones,omitempty"`
|
|
NotOut bool `json:"not_out,omitempty"`
|
|
// Sources and Destinations are set names or literal addresses,
|
|
// prefixes and ranges, IPv4 or IPv6.
|
|
Sources []string `json:"sources,omitempty"`
|
|
NotSources bool `json:"not_sources,omitempty"`
|
|
Destinations []string `json:"destinations,omitempty"`
|
|
NotDests bool `json:"not_destinations,omitempty"`
|
|
// Services are service names or entries ("tcp/22", "icmp/echo-request").
|
|
Services []string `json:"services,omitempty"`
|
|
// Family limits the rule to ipv4 or ipv6 ("": what its addresses
|
|
// and services allow).
|
|
Family string `json:"family,omitempty"`
|
|
}
|
|
|
|
// Rule is one line of a chain, checked in order.
|
|
type Rule struct {
|
|
ID int64 `json:"id"`
|
|
Chain string `json:"chain"`
|
|
Position float64 `json:"position"`
|
|
Match
|
|
// CtState: new, established, related, invalid, untracked.
|
|
CtState []string `json:"ct_state,omitempty"`
|
|
// CtStatus: dnat, snat (a connection that was NATed).
|
|
CtStatus []string `json:"ct_status,omitempty"`
|
|
// Days ("Monday", …) and hours ("08:00"-"17:30") the rule applies.
|
|
Days []string `json:"days,omitempty"`
|
|
HourFrom string `json:"hour_from,omitempty"`
|
|
HourTo string `json:"hour_to,omitempty"`
|
|
// Limit, if set, makes the rule match only packets within (or with
|
|
// LimitOver, beyond) a rate.
|
|
Limit *Limit `json:"limit,omitempty"`
|
|
// ConnLimit matches when a source already has more than this many
|
|
// connections (per source address, grouped as GroupV4/GroupV6 say).
|
|
ConnLimit int `json:"conn_limit,omitempty"`
|
|
// Quota, if set, is an allowance of bytes: the rule matches once it
|
|
// is used up (Over), or while it lasts.
|
|
Quota *Quota `json:"quota,omitempty"`
|
|
// GroupV4 and GroupV6 group addresses for the per-address limits,
|
|
// connection limit and quota: by prefix length (0: the default, each
|
|
// IPv4 address alone, IPv6 by /64, since one device has several).
|
|
GroupV4 int `json:"group_v4,omitempty"`
|
|
GroupV6 int `json:"group_v6,omitempty"`
|
|
// EveryPacket checks the rule against every packet, before established
|
|
// connections are accepted, so a drop or reject also stops connections
|
|
// already open, not just new ones.
|
|
EveryPacket bool `json:"every_packet,omitempty"`
|
|
Action string `json:"action"`
|
|
// RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset.
|
|
RejectWith string `json:"reject_with,omitempty"`
|
|
Log bool `json:"log,omitempty"`
|
|
LogPrefix string `json:"log_prefix,omitempty"`
|
|
// Raw, when set, is an nftables rule written by hand, used instead of
|
|
// the fields above (the escape hatch for what Housecarl can't say).
|
|
Raw string `json:"raw,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
func (r *Rule) GetID() int64 { return r.ID }
|
|
func (r *Rule) Label() string {
|
|
if r.Comment != "" {
|
|
return fmt.Sprintf("%s #%d (%s)", r.Chain, r.ID, r.Comment)
|
|
}
|
|
return fmt.Sprintf("%s #%d", r.Chain, r.ID)
|
|
}
|
|
|
|
// Limit is a rate: Rate packets (or bytes, with Unit) per Per (second,
|
|
// minute, hour, day), with a burst.
|
|
type Limit struct {
|
|
Rate int `json:"rate"`
|
|
Per string `json:"per"`
|
|
// Burst is in the rate's unit.
|
|
Burst int `json:"burst,omitempty"`
|
|
// Unit: "" for packets, or bytes, kbytes or mbytes for a byte rate
|
|
// (bandwidth).
|
|
Unit string `json:"unit,omitempty"`
|
|
// Over matches packets beyond the rate instead of within it (to drop
|
|
// floods).
|
|
Over bool `json:"over,omitempty"`
|
|
// PerSource counts each source address on its own (a meter), instead
|
|
// of all traffic the rule sees together; PerDestination each
|
|
// destination.
|
|
PerSource bool `json:"per_source,omitempty"`
|
|
PerDestination bool `json:"per_destination,omitempty"`
|
|
}
|
|
|
|
// Byte units, as nftables writes them.
|
|
var ByteUnits = map[string]int64{"bytes": 1, "kbytes": 1 << 10, "mbytes": 1 << 20, "gbytes": 1 << 30}
|
|
|
|
// Quota is an allowance of bytes. nftables counts it; Housecarl keeps the
|
|
// count across changes and restarts, and resets it each period.
|
|
type Quota struct {
|
|
Bytes int64 `json:"bytes"`
|
|
// Over matches once the allowance is used up (to drop); otherwise the
|
|
// rule matches while it lasts (to accept).
|
|
Over bool `json:"over,omitempty"`
|
|
// PerSource gives each source address (grouped as the rule's
|
|
// GroupV4/GroupV6 say) its own allowance; PerDestination each
|
|
// destination.
|
|
PerSource bool `json:"per_source,omitempty"`
|
|
PerDestination bool `json:"per_destination,omitempty"`
|
|
// Reset: "" (only by hand), daily, weekly (on Mondays) or monthly (on
|
|
// Day, 1-31: a short month's last day when it has no such day), at
|
|
// midnight.
|
|
Reset string `json:"reset,omitempty"`
|
|
Day int `json:"day,omitempty"`
|
|
}
|
|
|
|
// Quota resets.
|
|
var QuotaResets = []string{"daily", "weekly", "monthly"}
|
|
|
|
// PerAddress reports whether a quota (or limit) counts each address on its
|
|
// own, and which: "saddr", "daddr" or "".
|
|
func (q *Quota) PerAddress() string {
|
|
switch {
|
|
case q == nil:
|
|
return ""
|
|
case q.PerSource:
|
|
return "saddr"
|
|
case q.PerDestination:
|
|
return "daddr"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PerAddress: as Quota's.
|
|
func (l *Limit) PerAddress() string {
|
|
switch {
|
|
case l == nil:
|
|
return ""
|
|
case l.PerSource:
|
|
return "saddr"
|
|
case l.PerDestination:
|
|
return "daddr"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// CountsTraffic reports whether a rule measures traffic (a quota or a byte
|
|
// rate): it must see every packet of a connection, so it is checked before
|
|
// the built-in that accepts established connections.
|
|
func (r *Rule) CountsTraffic() bool {
|
|
if r.Raw != "" {
|
|
return false
|
|
}
|
|
if r.Quota != nil {
|
|
return true
|
|
}
|
|
if r.Limit == nil {
|
|
return false
|
|
}
|
|
_, bytes := ByteUnits[r.Limit.Unit]
|
|
return bytes
|
|
}
|
|
|
|
// ChecksEveryPacket reports whether a rule is checked before the built-in
|
|
// that accepts established connections: it counts traffic, or asks to.
|
|
func (r *Rule) ChecksEveryPacket() bool {
|
|
return r.Raw == "" && (r.EveryPacket || r.CountsTraffic())
|
|
}
|
|
|
|
// Groups returns a rule's address grouping, defaults applied.
|
|
func (r *Rule) Groups() (v4, v6 int) {
|
|
v4, v6 = r.GroupV4, r.GroupV6
|
|
if v4 == 0 {
|
|
v4 = 32
|
|
}
|
|
if v6 == 0 {
|
|
v6 = 64
|
|
}
|
|
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
|
|
// 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.
|
|
// To6 also opens the port to an IPv6 host (a pinhole; no translation).
|
|
type PortForward struct {
|
|
ID int64 `json:"id"`
|
|
Position float64 `json:"position"`
|
|
Name string `json:"name,omitempty"`
|
|
// Protocols: tcp, udp (default tcp).
|
|
Protocols []string `json:"protocols"`
|
|
// Ports is the public port or range ("8096", "7777-7780").
|
|
Ports string `json:"ports"`
|
|
// PublicAddresses are the IPv4 addresses (or set names) to match;
|
|
// none means any address on the external zones' interfaces (and then
|
|
// no hairpin).
|
|
PublicAddresses []string `json:"public_addresses,omitempty"`
|
|
// Sources limits who may connect (set names or addresses).
|
|
Sources []string `json:"sources,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
// ToPort is the inside port ("" keeps the public one; for a range, the
|
|
// first port of an equally long range).
|
|
ToPort string `json:"to_port,omitempty"`
|
|
To6 string `json:"to6,omitempty"`
|
|
// Hairpin lets inside clients use the public address: their
|
|
// connections are masqueraded so replies come back through the
|
|
// firewall. HairpinSources limits that to some sources (none: all
|
|
// inside traffic).
|
|
Hairpin bool `json:"hairpin,omitempty"`
|
|
HairpinSources []string `json:"hairpin_sources,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
func (f *PortForward) GetID() int64 { return f.ID }
|
|
func (f *PortForward) Label() string {
|
|
if f.Name != "" {
|
|
return f.Name
|
|
}
|
|
return fmt.Sprintf("port forward #%d (%s)", f.ID, f.Ports)
|
|
}
|
|
|
|
// SourceNAT rewrites the source address of outgoing traffic: masquerade
|
|
// (To empty: the outgoing interface's address) or SNAT to To.
|
|
type SourceNAT struct {
|
|
ID int64 `json:"id"`
|
|
Position float64 `json:"position"`
|
|
Match
|
|
To string `json:"to,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
func (s *SourceNAT) GetID() int64 { return s.ID }
|
|
func (s *SourceNAT) Label() string {
|
|
if s.Comment != "" {
|
|
return fmt.Sprintf("source NAT #%d (%s)", s.ID, s.Comment)
|
|
}
|
|
return fmt.Sprintf("source NAT #%d", s.ID)
|
|
}
|
|
|
|
// Redirect sends matching traffic to a port on the firewall itself (to
|
|
// make every client use its DNS server, say).
|
|
type Redirect struct {
|
|
ID int64 `json:"id"`
|
|
Position float64 `json:"position"`
|
|
Match
|
|
// ToPort is the local port (the services' first port when empty).
|
|
ToPort string `json:"to_port,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
func (r *Redirect) GetID() int64 { return r.ID }
|
|
func (r *Redirect) Label() string {
|
|
if r.Comment != "" {
|
|
return fmt.Sprintf("redirect #%d (%s)", r.ID, r.Comment)
|
|
}
|
|
return fmt.Sprintf("redirect #%d", r.ID)
|
|
}
|