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>
495 lines
16 KiB
Go
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
|
|
}
|