Files
housecarl/internal/model/objects.go
T
abrown38andClaude Opus 5.5 500bfc7bdd Ban lists: where Guard and fail2ban ban addresses
A program bans an address (every port, or a protocol's ports) for a
time; the ban list says what it is kept from: the firewall, some hosts
behind it, or everything. Bans drop ahead of every rule, close the
address's connections, group IPv6 by /64, are kept in the database
across restarts and audited. Never banned: the inside, the firewall's
own addresses, the caller, the list's never. bans grant (bans:write:LIST),
hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/,
and failed logins and refused tokens logged for Guard and fail2ban.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:29:37 +01:00

495 lines
16 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)
}
// Ban list scopes: what a list's bans keep the banned addresses from.
const (
// BanScopeFirewall: the firewall itself (the input chain).
BanScopeFirewall = "firewall"
// BanScopeHosts: the list's hosts, behind the firewall (the forward
// chain, where port forwards' addresses are already translated).
BanScopeHosts = "hosts"
// BanScopeEverything: the firewall and everything behind it.
BanScopeEverything = "everything"
)
// BanScopes lists the scopes.
var BanScopes = []string{BanScopeFirewall, BanScopeHosts, BanScopeEverything}
// Ban lists' defaults.
const (
DefaultBanTime = 3600
DefaultBanMaxTime = 7 * 24 * 3600
DefaultBanEntries = 10000
DefaultBanGroupV6 = 64
)
// BanList is where a program such as Guard, or fail2ban, bans addresses
// (docs/bans.md). The program names only the address, and perhaps a
// protocol and ports; the list says what the address is kept from. The
// bans themselves are kept apart from the configuration, like a dynamic
// set's entries.
type BanList struct {
ID int64 `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
// Hosts are set names or addresses: what scope hosts protects.
Hosts []string `json:"hosts,omitempty"`
// Never are set names or addresses the list never bans.
Never []string `json:"never,omitempty"`
// DefaultTime is a ban's length in seconds when none is asked for
// (0: an hour); MaxTime the longest (0: a week; -1: no limit, so
// bans may be permanent).
DefaultTime int `json:"default_time,omitempty"`
MaxTime int `json:"max_time,omitempty"`
// MaxEntries limits the bans the list holds (0: 10000).
MaxEntries int `json:"max_entries,omitempty"`
// GroupV6 is the IPv6 prefix length banned for an address (0: /64,
// what one customer usually gets); IPv4 addresses are banned alone.
GroupV6 int `json:"group_v6,omitempty"`
Comment string `json:"comment,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
func (b *BanList) GetID() int64 { return b.ID }
func (b *BanList) Label() string { return b.Name }
// BanTime is the default ban length in seconds.
func (b *BanList) BanTime() int {
if b.DefaultTime > 0 {
return b.DefaultTime
}
return DefaultBanTime
}
// BanMaxTime is the longest ban in seconds, 0 for no limit.
func (b *BanList) BanMaxTime() int {
switch {
case b.MaxTime < 0:
return 0
case b.MaxTime > 0:
return b.MaxTime
}
return DefaultBanMaxTime
}
// Entries is the most bans the list holds.
func (b *BanList) Entries() int {
if b.MaxEntries > 0 {
return b.MaxEntries
}
return DefaultBanEntries
}
// V6Group is the IPv6 prefix length an address is banned with.
func (b *BanList) V6Group() int {
if b.GroupV6 > 0 {
return b.GroupV6
}
return DefaultBanGroupV6
}
// Covers reports whether the list's bans apply to a chain.
func (b *BanList) Covers(chain string) bool {
switch b.Scope {
case BanScopeFirewall:
return chain == ChainInput
case BanScopeHosts:
return chain == ChainForward
case BanScopeEverything:
return chain == ChainInput || chain == ChainForward
}
return false
}