go-firewall/firewall.go
James Coleman 073c9ad7f0 Add address-set support to APF/CSF and ipset reboot persistence to iptables
iptables: persist ipsets across reboot to match rule persistence.
- Detect the ipset save-file + restore unit (RHEL ipset.service /
  /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets),
  non-fatally.
- After each set mutation, `ipset save` into the layout's save-file and
  auto-enable a present-but-disabled restore unit; warn when no mechanism
  exists (sets stay live-only).
- Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230;
  CentOS 7 ships 219).

APF/CSF: gain address sets by persisting ipset commands in the pre-hook.
- The hook carries an `ipset create/flush/add` block ordered ahead of the
  `-m set --match-set` rule lines, so the firewall recreates the set on every
  (re)start before any rule references it.
- Route set-referencing rules (Source/Destination names an ipset) through the
  hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape).
- Implement the six address-set methods, advertise AddressSets, and wire sets
  into Backup/Restore via captureBackupState/restoreBackupSets.

Validated live: reboot simulation for iptables; generated-hook source for
APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and
set-ref routing; the capability-gated integration subtest now covers APF/CSF.
2026-07-08 17:20:38 -05:00

2268 lines
84 KiB
Go

package firewall
import (
"context"
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
)
// Sentinel errors a caller can match with errors.Is to tell apart a genuine
// failure from a feature the active backend cannot express. The helpers below
// (rejectLogAndLimit, unsupportedNAT, ...) wrap these, and per-backend marshal
// paths use fmt.Errorf("...: %w", err) so the message stays readable while the
// sentinel is preserved for programmatic handling.
var (
// ErrUnsupported is the common ancestor of every unsupported-feature error.
// errors.Is returns true for any of the more specific sentinels below.
ErrUnsupported = errors.New("feature unsupported by this firewall backend")
// ErrUnsupportedNAT is returned when a backend cannot express NAT.
ErrUnsupportedNAT = fmt.Errorf("%w: NAT", ErrUnsupported)
// ErrUnsupportedOrdering is returned by backends whose rule model is not
// ordered, for InsertRule/MoveRule.
ErrUnsupportedOrdering = fmt.Errorf("%w: explicit rule ordering", ErrUnsupported)
// ErrUnsupportedPolicy is returned when a backend cannot read or set a
// default policy.
ErrUnsupportedPolicy = fmt.Errorf("%w: default-policy management", ErrUnsupported)
// ErrUnsupportedSet is returned when a backend cannot manage address sets
// (ipset/nftset/tables).
ErrUnsupportedSet = fmt.Errorf("%w: address sets", ErrUnsupported)
// ErrUnsupportedLog is returned when per-rule logging cannot be expressed.
ErrUnsupportedLog = fmt.Errorf("%w: per-rule logging", ErrUnsupported)
// ErrUnsupportedRateLimit is returned when rate limiting cannot be expressed.
ErrUnsupportedRateLimit = fmt.Errorf("%w: rate limiting", ErrUnsupported)
// ErrUnsupportedConnLimit is returned when connection limiting cannot be
// expressed.
ErrUnsupportedConnLimit = fmt.Errorf("%w: connection limiting", ErrUnsupported)
// ErrUnsupportedState is returned when connection-state matching cannot be
// expressed.
ErrUnsupportedState = fmt.Errorf("%w: connection-state matching", ErrUnsupported)
// ErrUnsupportedInterface is returned when per-rule interface matching
// cannot be expressed.
ErrUnsupportedInterface = fmt.Errorf("%w: per-rule interface matching", ErrUnsupported)
// ErrUnsupportedSourcePort is returned when source-port matching cannot be
// expressed.
ErrUnsupportedSourcePort = fmt.Errorf("%w: source-port matching", ErrUnsupported)
// ErrUnsupportedForward is returned when a backend cannot express a rule in
// the forward (routing) chain.
ErrUnsupportedForward = fmt.Errorf("%w: forward-chain rules", ErrUnsupported)
)
// Action is the firewall action taken on a rule's matching packets.
type Action uint8
const (
ActionInvalid Action = iota
Accept
Reject
Drop
)
// String returns the canonical lower-case name of the action.
func (t Action) String() string {
switch t {
case Accept:
return "accept"
case Reject:
return "reject"
case Drop:
return "drop"
}
return "invalid"
}
// ParseAction parses a caller-supplied action token (case-insensitive),
// accepting only the concrete actions Accept, Reject and Drop. The sentinel
// "invalid" (ActionInvalid) is rejected here so callers cannot author a rule or
// policy with no real action; backup decoding round-trips it separately in
// Action.UnmarshalJSON.
func ParseAction(s string) (Action, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "accept":
return Accept, nil
case "reject":
return Reject, nil
case "drop":
return Drop, nil
}
return 0, fmt.Errorf("unknown action %q", s)
}
// Family is the IP family a rule targets.
type Family uint8
const (
FamilyAny Family = iota
IPv4
IPv6
)
// String returns the canonical lower-case name of the family.
func (t Family) String() string {
switch t {
case IPv4:
return "ipv4"
case IPv6:
return "ipv6"
}
return "any"
}
// ParseFamily parses a family token (case-insensitive), accepting the canonical
// name emitted by Family.String. An unknown value is an error.
func ParseFamily(s string) (Family, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "any":
return FamilyAny, nil
case "ipv4", "v4", "inet4":
return IPv4, nil
case "ipv6", "v6", "inet6":
return IPv6, nil
}
return 0, fmt.Errorf("unknown family %q", s)
}
// Protocol is the network protocol a rule matches.
type Protocol uint8
const (
ProtocolAny Protocol = iota
UDP
TCP
// ICMP and ICMPv6 are the control-message protocols. ICMP is inherently an
// IPv4 protocol and ICMPv6 an IPv6 one; a rule carrying one of these implies
// the matching family (see Rule.impliedFamily).
ICMP
ICMPv6
// SCTP is a transport protocol that, like TCP and UDP, carries ports.
SCTP
// GRE, ESP and AH are portless IP protocols (tunneling and IPsec). A rule
// carrying one of these cannot also match a port.
GRE
ESP
AH
)
// String returns the canonical lower-case name of the protocol.
func (t Protocol) String() string {
switch t {
case UDP:
return "udp"
case TCP:
return "tcp"
case ICMP:
return "icmp"
case ICMPv6:
return "icmpv6"
case SCTP:
return "sctp"
case GRE:
return "gre"
case ESP:
return "esp"
case AH:
return "ah"
}
return "any"
}
// IsICMP reports whether the protocol is ICMP or ICMPv6.
func (t Protocol) IsICMP() bool {
return t == ICMP || t == ICMPv6
}
// HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP or
// SCTP). A port match is only meaningful and only valid for these protocols.
func (t Protocol) HasPorts() bool {
return t == TCP || t == UDP || t == SCTP
}
// Ptr returns a pointer to v. It is a convenience for setting optional rule
// fields such as ICMPType, e.g. firewall.Ptr[uint8](8).
func Ptr[T any](v T) *T {
return &v
}
// icmpNameToNum maps the ICMP type names various tools accept (and their common
// aliases) to their numeric type. It is used when reading a rule whose ICMP type
// is written by name; rules this library writes always emit the number, which
// every backend accepts.
var icmpNameToNum = map[string]uint8{
"echo-reply": 0,
"pong": 0,
"destination-unreachable": 3,
"source-quench": 4,
"redirect": 5,
"echo-request": 8,
"ping": 8,
"router-advertisement": 9,
"router-solicitation": 10,
"time-exceeded": 11,
"ttl-exceeded": 11,
"parameter-problem": 12,
"timestamp-request": 13,
"timestamp-reply": 14,
"info-request": 15,
"info-reply": 16,
"address-mask-request": 17,
"address-mask-reply": 18,
"traceroute": 30,
}
// icmpv6NameToNum maps the ICMPv6 type names nftables and other tools print to
// their numeric type. ICMPv6 reuses several names from ICMPv4 (echo-request,
// destination-unreachable, ...) for *different* numbers, so a name read from an
// ICMPv6 rule must be resolved through this table rather than icmpNameToNum.
var icmpv6NameToNum = map[string]uint8{
"destination-unreachable": 1,
"packet-too-big": 2,
"time-exceeded": 3,
"ttl-exceeded": 3,
"parameter-problem": 4,
"echo-request": 128,
"ping": 128,
"echo-reply": 129,
"pong": 129,
"mld-listener-query": 130,
"mld-listener-report": 131,
"mld-listener-done": 132,
"mld-listener-reduction": 132,
"nd-router-solicit": 133,
"nd-router-advert": 134,
"nd-neighbor-solicit": 135,
"nd-neighbor-advert": 136,
"nd-redirect": 137,
"router-renumbering": 138,
"ind-neighbor-solicit": 141,
"ind-neighbor-advert": 142,
"mld2-listener-report": 143,
}
// parseICMPType parses an ICMP type token as either a number (0-255) or one of
// the well-known IPv4 names in icmpNameToNum.
func parseICMPType(tok string) (uint8, bool) {
return parseICMPTypeFamily(tok, false)
}
// parseICMPTypeFamily parses an ICMP type token like parseICMPType, but resolves
// names through the ICMPv6 table when v6 is true. Numbers parse identically in
// either family (and rules this library writes always emit the number), so only
// the name path is family-dependent.
func parseICMPTypeFamily(tok string, v6 bool) (uint8, bool) {
tok = strings.TrimSpace(tok)
if n, err := strconv.ParseUint(tok, 10, 8); err == nil {
return uint8(n), true
}
if v6 {
if n, ok := icmpv6NameToNum[strings.ToLower(tok)]; ok {
return n, true
}
return 0, false
}
if n, ok := icmpNameToNum[strings.ToLower(tok)]; ok {
return n, true
}
return 0, false
}
// ParseICMPType parses an ICMP type token as either a number (0-255) or a
// well-known type name, resolving names through the ICMPv6 table when v6 is true
// (the same name maps to a different number under ICMPv6 — e.g. echo-request is 8
// for ICMPv4 but 128 for ICMPv6). It is the exported form of the resolution the
// backends use internally, so a caller or CLI authoring a rule by name accepts
// exactly the spellings the library itself emits and reads back.
func ParseICMPType(tok string, v6 bool) (uint8, bool) {
return parseICMPTypeFamily(tok, v6)
}
// GetProtocol converts a string to the network protocol. The common spellings
// each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized.
func GetProtocol(proto string) Protocol {
switch {
case strings.EqualFold("udp", proto):
return UDP
case strings.EqualFold("tcp", proto):
return TCP
case strings.EqualFold("icmp", proto):
return ICMP
case strings.EqualFold("icmpv6", proto),
strings.EqualFold("ipv6-icmp", proto),
strings.EqualFold("icmp6", proto):
return ICMPv6
case strings.EqualFold("sctp", proto):
return SCTP
case strings.EqualFold("gre", proto):
return GRE
case strings.EqualFold("esp", proto),
strings.EqualFold("ipsec-esp", proto):
return ESP
case strings.EqualFold("ah", proto),
strings.EqualFold("ipsec-ah", proto):
return AH
}
return ProtocolAny
}
// PortRange is an inclusive range of ports. A single port is represented with
// End equal to Start (or End left zero, which normalizes to Start).
type PortRange struct {
Start uint16
End uint16
}
// normalized returns the range with a zero or inverted End collapsed to a single
// port at Start.
func (pr PortRange) normalized() PortRange {
if pr.End == 0 || pr.End < pr.Start {
pr.End = pr.Start
}
return pr
}
// String renders the range as "80" for a single port or "80-90" for a span.
func (pr PortRange) String() string {
pr = pr.normalized()
if pr.Start == pr.End {
return strconv.FormatUint(uint64(pr.Start), 10)
}
return fmt.Sprintf("%d-%d", pr.Start, pr.End)
}
// ParsePortRange parses a single "80" or "80-90"/"80:90" token into a PortRange.
func ParsePortRange(s string) (PortRange, error) {
s = strings.TrimSpace(s)
sep := "-"
if strings.Contains(s, ":") {
sep = ":"
}
lo, hi, isRange := strings.Cut(s, sep)
start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16)
if err != nil {
return PortRange{}, fmt.Errorf("invalid port %q", lo)
}
pr := PortRange{Start: uint16(start), End: uint16(start)}
if isRange {
end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16)
if err != nil {
return PortRange{}, fmt.Errorf("invalid port %q", hi)
}
pr.End = uint16(end)
if pr.End < pr.Start {
return PortRange{}, fmt.Errorf("port range end %d is below start %d", pr.End, pr.Start)
}
}
return pr, nil
}
// ParsePortRanges parses a separated list such as "80,443,1000-2000" into a slice
// of PortRange values. sep is the separator between entries (typically ",").
func ParsePortRanges(s, sep string) ([]PortRange, error) {
var out []PortRange
for _, tok := range strings.Split(s, sep) {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
pr, err := ParsePortRange(tok)
if err != nil {
return nil, err
}
out = append(out, pr)
}
return out, nil
}
// FormatPortRanges renders a slice of ranges as a separated list.
func FormatPortRanges(prs []PortRange, sep string) string {
parts := make([]string, len(prs))
for i, pr := range prs {
parts[i] = pr.String()
}
return strings.Join(parts, sep)
}
// ConnState is a set of connection-tracking states to match, combined as a
// bitmask (e.g. StateEstablished|StateRelated). The zero value matches no
// particular state (i.e. the rule is stateless).
type ConnState uint8
const (
StateNew ConnState = 1 << iota
StateEstablished
StateRelated
StateInvalid
)
// connStateNames lists the states in canonical rendering order.
var connStateNames = []struct {
bit ConnState
name string
}{
{StateNew, "new"},
{StateEstablished, "established"},
{StateRelated, "related"},
{StateInvalid, "invalid"},
}
// Strings returns the set states as lower-case names in canonical order.
func (s ConnState) Strings() []string {
var out []string
for _, cs := range connStateNames {
if s&cs.bit != 0 {
out = append(out, cs.name)
}
}
return out
}
// String renders the state set as a comma-separated list (e.g.
// "established,related"), or the empty string when no state is set.
func (s ConnState) String() string {
return strings.Join(s.Strings(), ",")
}
// ParseConnState parses state names (case-insensitive) into a ConnState bitmask.
// Each token may itself be a comma-separated list. An unknown name is an error.
func ParseConnState(tokens ...string) (ConnState, error) {
var state ConnState
for _, tok := range tokens {
for _, name := range strings.Split(tok, ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
matched := false
for _, cs := range connStateNames {
if strings.EqualFold(name, cs.name) {
state |= cs.bit
matched = true
break
}
}
if !matched {
return 0, fmt.Errorf("unknown connection state %q", name)
}
}
}
return state, nil
}
// RateUnit is the time unit a RateLimit is expressed over.
type RateUnit uint8
const (
PerSecond RateUnit = iota
PerMinute
PerHour
PerDay
)
// String returns the canonical (nftables-style) unit name.
func (u RateUnit) String() string {
switch u {
case PerMinute:
return "minute"
case PerHour:
return "hour"
case PerDay:
return "day"
}
return "second"
}
// ParseRateUnit parses a rate-unit token, accepting the long, short and
// single-letter spellings the various backends emit (e.g. second/sec/s).
func ParseRateUnit(s string) (RateUnit, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "s", "sec", "second", "seconds":
return PerSecond, nil
case "m", "min", "minute", "minutes":
return PerMinute, nil
case "h", "hour", "hours":
return PerHour, nil
case "d", "day", "days":
return PerDay, nil
}
return 0, fmt.Errorf("unknown rate unit %q", s)
}
// RateLimit caps the rate at which a rule matches packets: up to Rate packets
// per Unit, with an optional Burst allowance. A nil *RateLimit on a Rule means
// no rate limiting. Backends that cannot express a rate limit reject a rule
// carrying one rather than applying it unlimited.
type RateLimit struct {
Rate uint
Unit RateUnit
Burst uint // 0 leaves the burst at the backend default.
}
// String renders the limit as "<rate>/<unit>" (e.g. "10/minute").
func (rl RateLimit) String() string {
return fmt.Sprintf("%d/%s", rl.Rate, rl.Unit)
}
// parseRateToken parses a "<rate>/<unit>" token (e.g. "10/minute") into its
// numeric rate and unit. Backends use it when decoding a rule.
func parseRateToken(tok string) (uint, RateUnit, error) {
num, unitStr, ok := strings.Cut(strings.TrimSpace(tok), "/")
if !ok {
return 0, 0, fmt.Errorf("invalid rate %q", tok)
}
n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32)
if err != nil {
return 0, 0, fmt.Errorf("invalid rate %q", tok)
}
unit, err := ParseRateUnit(unitStr)
if err != nil {
return 0, 0, err
}
return uint(n), unit, nil
}
// ConnLimit caps the number of concurrent connections a rule matches. When
// PerSource is set the cap is applied per source address; otherwise it is a
// single global cap. A nil *ConnLimit means no connection limiting.
type ConnLimit struct {
Count uint
PerSource bool
}
// netfilterDefaultBurst is the burst the kernel's xt_limit applies when a rule
// names none (5). nft and iptables always print it back, and their read paths
// collapse it to 0 (unset), so a caller that sets Burst=5 is asking for exactly
// that default; normBurst folds the two spellings together.
const netfilterDefaultBurst = 5
// normBurst folds an explicit burst of the netfilter default (5) to 0 (unset)
// so a rule that names Burst=5 matches its own read-back, which reports the
// default as 0.
func normBurst(b uint) uint {
if b == netfilterDefaultBurst {
return 0
}
return b
}
// eqRateLimit reports whether two optional rate limits are equal, treating nil
// as a distinct "unset" value. The burst is compared through normBurst so an
// explicit default burst (5) and an unset burst (0) count as the same limit.
func eqRateLimit(a, b *RateLimit) bool {
if a == nil || b == nil {
return a == b
}
return a.Rate == b.Rate && a.Unit == b.Unit && normBurst(a.Burst) == normBurst(b.Burst)
}
// eqConnLimit reports whether two optional connection limits are equal, treating
// nil as a distinct "unset" value.
func eqConnLimit(a, b *ConnLimit) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// familyOfAddr infers the IP family of an address or CIDR string, ignoring a
// leading '!' negation. It returns FamilyAny when the family cannot be
// determined.
func familyOfAddr(addr string) Family {
addr = strings.TrimPrefix(strings.TrimSpace(addr), "!")
if addr == "" {
return FamilyAny
}
ip, _, err := net.ParseCIDR(addr)
if err != nil {
ip = net.ParseIP(addr)
}
if ip == nil {
return FamilyAny
}
if ip.To4() == nil {
return IPv6
}
return IPv4
}
// canonAddr canonicalizes an address match-string to a stable form and reports
// whether it parsed as an IP or CIDR. It exists because backends print the same
// address differently: nft and ufw strip a /32 (or /128) host prefix and
// zero-compress IPv6, while iptables-save adds the /32 — so the literal a rule
// was written with rarely matches the literal read back. A leading "!" negation
// is preserved; a host (bare or /32,/128) normalizes to its bare canonical form;
// a network keeps its masked base and prefix. Non-IP tokens (ipset/zone names,
// MAC addresses, "any", "") do not parse and are compared verbatim by addrEqual.
func canonAddr(s string) (string, bool) {
s = strings.TrimSpace(s)
neg := ""
if strings.HasPrefix(s, "!") {
neg = "!"
s = strings.TrimSpace(s[1:])
}
if s == "" {
return "", false
}
if ip, ipnet, err := net.ParseCIDR(s); err == nil {
if ones, bits := ipnet.Mask.Size(); ones == bits {
// A host prefix (/32 or /128) is the same address as the bare host.
return neg + ip.String(), true
}
return neg + ipnet.String(), true
}
if ip := net.ParseIP(s); ip != nil {
return neg + ip.String(), true
}
return "", false
}
// addrEqual reports whether two address match-strings denote the same address,
// treating a bare host and its /32 (or /128) form — and differing IPv6 spellings
// — as equal. It underpins rule identity so a rule survives the round-trip
// through a backend that re-spells addresses (see canonAddr). Tokens that are not
// IPs/CIDRs fall back to exact string comparison.
func addrEqual(a, b string) bool {
if a == b {
return true
}
ca, oka := canonAddr(a)
cb, okb := canonAddr(b)
if !oka || !okb {
return false
}
return ca == cb
}
// splitAddrNeg splits an optional leading "!" negation from an address or set
// match-string, returning whether it was negated and the bare remainder.
func splitAddrNeg(addr string) (neg bool, bare string) {
if strings.HasPrefix(addr, "!") {
return true, addr[1:]
}
return false, addr
}
// isSetRef reports whether a Source/Destination match-string names an address set
// (an ipset, an nft named set, a pf table) rather than an IP or CIDR. A set
// reference is any non-empty token that, after an optional leading "!" negation,
// does not parse as an address or subnet. An empty string means "any" and is not a
// set reference. Backends that support address sets (Capabilities().AddressSets)
// translate such a token into their native set-match syntax; the set itself is
// family-typed, so a set-referencing rule should carry a concrete Family.
func isSetRef(addr string) bool {
_, bare := splitAddrNeg(strings.TrimSpace(addr))
// An empty token and the literal "any" both mean the address wildcard, not a
// named set; canonAddr cannot parse "any", so guard it explicitly.
if bare == "" || bare == "any" {
return false
}
_, ok := canonAddr(addr)
return !ok
}
// portSpecsFor normalizes a (Port, Ports) pair into a list of port ranges:
// Ports when set, otherwise the single Port, otherwise nil.
func portSpecsFor(port uint16, ports []PortRange) []PortRange {
if len(ports) > 0 {
out := make([]PortRange, len(ports))
for i, pr := range ports {
out[i] = pr.normalized()
}
return out
}
if port != 0 {
return []PortRange{{Start: port, End: port}}
}
return nil
}
// portSpecsToRule is the inverse of portSpecsFor: it writes a parsed set of port
// ranges onto a rule's destination-port fields, using the single Port field for
// one discrete port and the Ports slice otherwise.
func portSpecsToRule(r *Rule, specs []PortRange) {
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.Port = specs[0].Start
return
}
r.Ports = specs
}
// sourcePortSpecsToRule writes a parsed set of port ranges onto a rule's
// source-port fields, mirroring portSpecsToRule for the source side.
func sourcePortSpecsToRule(r *Rule, specs []PortRange) {
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.SourcePort = specs[0].Start
return
}
r.SourcePorts = specs
}
// portNeedsConcreteProtocol reports whether a (Port, Ports) pair specifies a
// port without a concrete port-carrying protocol (tcp/udp/sctp).
func portNeedsConcreteProtocol(port uint16, ports []PortRange, proto Protocol) bool {
return (port != 0 || len(ports) > 0) && !proto.HasPorts()
}
// Rule is a firewall rule.
type Rule struct {
// Direction places the rule in the input, output or forward chain. The zero
// value (DirInput) is an input rule. A forward rule filters traffic routed
// through the host and may match both an inbound and an outbound interface;
// backends that cannot express a forward chain reject such a rule with
// ErrUnsupportedForward (see Capabilities.Forward).
Direction Direction
Priority int
Family Family
Source string
Destination string
// Port is the single destination port to match. For a port list or range,
// use Ports instead; when Ports is non-empty it takes precedence over Port.
Port uint16
// Ports is a list of destination port ranges to match. A single port is a
// range whose Start and End are equal. Backends that cannot express a
// multi-port match reject such a rule rather than silently narrowing it.
Ports []PortRange
// SourcePort is the single source port to match. For a port list or range,
// use SourcePorts instead; when SourcePorts is non-empty it takes precedence
// over SourcePort.
SourcePort uint16
// SourcePorts is a list of source port ranges to match. Backends that cannot
// express source-port matching reject a rule carrying one rather than silently
// omit the match.
SourcePorts []PortRange
Proto Protocol
// ICMPType, when set, restricts an ICMP/ICMPv6 rule to a single ICMP message
// type (e.g. 8 for echo-request). A nil pointer matches every ICMP type. It
// is only meaningful when Proto is ICMP or ICMPv6.
ICMPType *uint8
// State restricts the rule to the given connection-tracking states (e.g.
// StateEstablished|StateRelated). The zero value applies no state match.
State ConnState
// InInterface matches the inbound interface; OutInterface matches the outbound
// interface. An input rule matches only InInterface, an output rule only
// OutInterface, and a forward rule may match either or both (ingress and
// egress). Empty means any interface.
InInterface string
OutInterface string
Action Action
// Log, when set, logs each matched packet before the Action is applied.
// LogPrefix is an optional label attached to the log line. Backends that
// cannot express per-rule logging reject a rule carrying it.
Log bool
LogPrefix string
// RateLimit caps the packet rate the rule matches; ConnLimit caps the
// concurrent connections it matches. A nil pointer applies no limit. Both
// are rejected by backends that cannot express them.
RateLimit *RateLimit
ConnLimit *ConnLimit
// Packets and Bytes are per-rule counters, populated by GetRules on backends
// that can read them (nftables, iptables and pf). They are zero on backends
// that cannot read counters, and are ignored when adding a rule. They are
// not part of rule identity, so two rules that differ only in counters are
// considered equal.
Packets uint64
Bytes uint64
// Comment is an optional human-readable label carried alongside the rule on
// backends that can store one (an iptables/nftables comment, a pf label, a
// CSF/APF trailing comment, a WFP description). Like Packets/Bytes it is
// informational: it is not part of rule identity, so two rules that differ
// only in their comment are considered equal, and backends that cannot store
// a comment ignore it when adding a rule and report it empty on read. A
// caller can check Capabilities().Comments to learn whether it round-trips.
Comment string
// HasPrefix reports whether this rule carries the library's configured prefix.
// For backends that tag rules with the configured prefix — in a comment, or (for
// name-based backends) in the rule name — it reports whether that prefix is
// present. For container backends (nft, pf, firewalld) that isolate rules in a
// private table/anchor/zone, it reports whether the rule lives in that container
// (recorded in the unexported table field). It is derived on read and purely
// informational — the library never branches on it: GetRules returns every rule
// the backend can see, and HasPrefix lets callers tell which ones the library
// created. An empty rule prefix gives a tag-based backend no namespace of its
// own, so its rules report HasPrefix=false.
// Like Comment and Packets/Bytes it is not part of rule identity, so two rules
// that differ only in HasPrefix are considered equal, and it is ignored when
// adding a rule.
HasPrefix bool
// Number is the rule's 1-based position within its chain in the backend's native
// ordering, populated by GetRules on backends that support explicit ordering
// (Capabilities().RuleOrdering). It mirrors the position argument of InsertRule
// and MoveRule, so a caller can read a rule's Number and pass it back to reorder.
// It is zero on backends without ordering and on rules outside the backend's
// ordered set (foreign rules in a container backend's read). For a rule merged
// across IPv4 and IPv6 it reflects the IPv4 chain. Like HasPrefix and
// Packets/Bytes it is derived on read, ignored when adding a rule, and not part
// of rule identity.
Number int
// table records the backend container a container backend (an nft table, a pf
// anchor, a firewalld zone) read this rule from; it is empty for tag-based
// backends. It is unexported and informational — it backs HasPrefix for the
// container backends and, like HasPrefix, is not part of rule identity.
table string
}
// directionFromOutput maps an input/output boolean to a Direction. Backends whose
// native config distinguishes only inbound from outbound use it when decoding a
// rule (a routed/forward rule is expressed through their raw-iptables hook, not
// their native config, so it is never decoded here). It only ever yields a
// concrete DirInput or DirOutput; DirAny is synthesized later by the read-side
// direction merge (see mergeDirections), never by a single-row decode.
func directionFromOutput(output bool) Direction {
if output {
return DirOutput
}
return DirInput
}
// IsInput reports whether the rule is an input (inbound) rule. A DirAny rule is
// not an input rule; use AppliesInput to ask whether a rule materializes into the
// input chain.
func (r *Rule) IsInput() bool { return r.Direction == DirInput }
// IsOutput reports whether the rule is an output (outbound) rule. A DirAny rule is
// not an output rule; use AppliesOutput for chain materialization.
func (r *Rule) IsOutput() bool { return r.Direction == DirOutput }
// IsForward reports whether the rule is a forward (routing) rule.
func (r *Rule) IsForward() bool { return r.Direction == DirForward }
// IsAny reports whether the rule applies to both directions (DirAny).
func (r *Rule) IsAny() bool { return r.Direction == DirAny }
// AppliesInput reports whether the rule materializes into the input chain — a
// concrete input rule or a both-directions DirAny rule.
func (r *Rule) AppliesInput() bool { return r.Direction == DirInput || r.Direction == DirAny }
// AppliesOutput reports whether the rule materializes into the output chain — a
// concrete output rule or a both-directions DirAny rule.
func (r *Rule) AppliesOutput() bool { return r.Direction == DirOutput || r.Direction == DirAny }
// PortSpecs returns the rule's destination ports as a normalized list of
// ranges: Ports when set, otherwise the single Port, otherwise nil.
func (r *Rule) PortSpecs() []PortRange {
return portSpecsFor(r.Port, r.Ports)
}
// SourcePortSpecs returns the rule's source ports as a normalized list of
// ranges: SourcePorts when set, otherwise the single SourcePort, otherwise nil.
func (r *Rule) SourcePortSpecs() []PortRange {
return portSpecsFor(r.SourcePort, r.SourcePorts)
}
// HasPorts reports whether the rule matches on any destination port.
func (r *Rule) HasPorts() bool {
return r.Port != 0 || len(r.Ports) > 0
}
// HasSourcePorts reports whether the rule matches on any source port.
func (r *Rule) HasSourcePorts() bool {
return r.SourcePort != 0 || len(r.SourcePorts) > 0
}
// HasPortSet reports whether the rule matches more than a single discrete port
// (a list, or a range spanning more than one port). Backends limited to a single
// port use this to reject rules they cannot represent.
func (r *Rule) HasPortSet() bool {
specs := r.PortSpecs()
if len(specs) > 1 {
return true
}
if len(specs) == 1 && specs[0].Start != specs[0].End {
return true
}
return false
}
// HasSourcePortSet reports whether the rule matches more than a single discrete
// source port.
func (r *Rule) HasSourcePortSet() bool {
specs := r.SourcePortSpecs()
if len(specs) > 1 {
return true
}
if len(specs) == 1 && specs[0].Start != specs[0].End {
return true
}
return false
}
// PortNeedsConcreteProtocol reports whether the rule specifies a destination or
// source port without a concrete port-carrying protocol (TCP/UDP/SCTP). Most
// firewall backends cannot express a port match without such a protocol, so they
// use this to reject such a rule rather than silently widening it (matching every
// protocol) or emitting an invalid rule. UFW is the exception and can express a
// bare port across any protocol.
func (r *Rule) PortNeedsConcreteProtocol() bool {
return portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) || portNeedsConcreteProtocol(r.SourcePort, r.SourcePorts, r.Proto)
}
// impliedFamily returns the family a rule effectively targets, resolving
// FamilyAny from an ICMP protocol (ICMP => IPv4, ICMPv6 => IPv6) or, failing
// that, from a concrete source/destination address. A rule that names an IPv4
// address is an IPv4 rule even when its Family was left unset, so inferring it
// keeps the rule out of the wrong-family save file (an IPv4 address in an
// ip6tables ruleset is rejected on load).
func (r *Rule) impliedFamily() Family {
if r.Family != FamilyAny {
return r.Family
}
switch r.Proto {
case ICMP:
return IPv4
case ICMPv6:
return IPv6
}
for _, a := range []string{r.Source, r.Destination} {
if fam := familyOfAddr(a); fam != FamilyAny {
return fam
}
}
return FamilyAny
}
// directionSwapped returns a copy of r with its source and destination roles
// swapped: the transform between a rule's inbound and outbound materialization.
// A rule that matches inbound traffic from a host (Source=X, dport=P, in-iface)
// matches the same flow outbound as traffic to that host (Destination=X, sport=P,
// out-iface), so the source/destination address, the source/destination ports and
// the in/out interface all swap sides. Everything protocol- or policy-bound (Proto,
// ICMPType, State, Action, Log, rate/conn limits, Family, Priority, Comment,
// counters, Number, table) is direction-independent and is left untouched. It backs
// both the DirAny write-side fan-out (expandDirections) and the read-side merge
// (mergeDirections). The Ports/SourcePorts slice headers are swapped, not their
// elements; callers do not mutate them, matching splitDualRow's shallow-copy style.
func (r *Rule) directionSwapped() *Rule {
s := *r
s.Source, s.Destination = r.Destination, r.Source
s.Port, s.SourcePort = r.SourcePort, r.Port
s.Ports, s.SourcePorts = r.SourcePorts, r.Ports
s.InInterface, s.OutInterface = r.OutInterface, r.InInterface
return &s
}
// canonicalMatch expresses a rule's match fields in the inbound (input-chain)
// frame so two rules stored in opposite directions can be compared like-for-like.
// An output rule is role-swapped into the inbound frame; input, forward and DirAny
// rules are already inbound-framed (a DirAny rule is authored inbound). It does not
// change Direction — direction coverage is decided separately by coversDirection.
func (r *Rule) canonicalMatch() *Rule {
if r.Direction == DirOutput {
return r.directionSwapped()
}
return r
}
// portRangeInSpecs reports whether pr equals any range in specs (normalized).
func portRangeInSpecs(pr PortRange, specs []PortRange) bool {
pr = pr.normalized()
for _, sp := range specs {
if sp.normalized() == pr {
return true
}
}
return false
}
// coalescePortRanges normalizes a port-range list to its minimal canonical form:
// each range is normalized, the list is sorted, and overlapping or directly
// contiguous ranges are merged. It exists because several backends re-spell a
// port set on read — nft in particular lists an anonymous set back with adjacent
// and overlapping ranges merged, so "{22,23,24-30}" comes back as "{22-30}". Rule
// identity compares port sets through this canonical form (see portRangesEqual)
// so such a rule still matches its own read-back and Sync does not churn.
func coalescePortRanges(prs []PortRange) []PortRange {
if len(prs) == 0 {
return nil
}
cp := make([]PortRange, len(prs))
for i, pr := range prs {
cp[i] = pr.normalized()
}
sort.Slice(cp, func(i, j int) bool {
if cp[i].Start != cp[j].Start {
return cp[i].Start < cp[j].Start
}
return cp[i].End < cp[j].End
})
out := []PortRange{cp[0]}
for _, pr := range cp[1:] {
last := &out[len(out)-1]
// Merge when the next range overlaps the current one or begins exactly one
// past its end (a contiguous span). The End<65535 guard avoids a uint16
// wrap when the current range already reaches the maximum port.
if pr.Start <= last.End || (last.End < 65535 && pr.Start == last.End+1) {
if pr.End > last.End {
last.End = pr.End
}
continue
}
out = append(out, pr)
}
return out
}
// portRangesEqual compares two port-range lists as sets, treating overlapping or
// contiguous ranges that cover the same ports as equal (see coalescePortRanges).
func portRangesEqual(a, b []PortRange) bool {
ac := coalescePortRanges(a)
bc := coalescePortRanges(b)
if len(ac) != len(bc) {
return false
}
for i := range ac {
if ac[i] != bc[i] {
return false
}
}
return true
}
// eqU8Ptr reports whether two optional uint8 values are equal, treating nil as a
// distinct "unset" value.
func eqU8Ptr(a, b *uint8) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
// matchFields reports whether the non-family match fields of two rules are
// equal. It is the shared core of Equal and EqualBase.
func (r *Rule) matchFields(rule *Rule, directionHonored bool) bool {
if r.Direction != rule.Direction && directionHonored {
return false
}
// Priority orders a rule relative to the others (firewalld rich rules), so two
// rules that differ only in priority are distinct — otherwise a reconcile could
// never change a rule's priority. Backends without per-rule priority leave it 0,
// so this never affects them.
if r.Priority != rule.Priority {
return false
}
if !addrEqual(r.Source, rule.Source) || !addrEqual(r.Destination, rule.Destination) {
return false
}
if !portRangesEqual(r.PortSpecs(), rule.PortSpecs()) {
return false
}
if !portRangesEqual(r.SourcePortSpecs(), rule.SourcePortSpecs()) {
return false
}
if r.Proto != rule.Proto {
return false
}
if !eqU8Ptr(r.ICMPType, rule.ICMPType) {
return false
}
if r.State != rule.State {
return false
}
if r.InInterface != rule.InInterface || r.OutInterface != rule.OutInterface {
return false
}
if r.Action != rule.Action {
return false
}
// Logging and rate/connection limits change the rule's effect, so two rules
// that differ only in these are distinct (they are not deduplicated, and a
// removal must name the same modifiers it was added with).
if r.Log != rule.Log || r.LogPrefix != rule.LogPrefix {
return false
}
if !eqRateLimit(r.RateLimit, rule.RateLimit) {
return false
}
if !eqConnLimit(r.ConnLimit, rule.ConnLimit) {
return false
}
return true
}
// Equal reports whether two rules are the same. Family is compared through
// impliedFamily so a FamilyAny rule matches the concrete family its own content
// forces (an ICMP rule is IPv4, an ICMPv6 rule IPv6, an addressed rule its
// address's family).
func (r *Rule) Equal(rule *Rule, outputHonored bool) bool {
if r.impliedFamily() != rule.impliedFamily() {
return false
}
return r.matchFields(rule, outputHonored)
}
// EqualBase reports whether two rules are the same, ignoring family.
func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool {
return r.matchFields(rule, outputHonored)
}
// coversDirection reports whether an existing rule in direction have already
// covers a caller rule in direction want (the asymmetric add/dedup form). DirAny
// spans input and output, so it covers either; it never covers DirForward (a
// routed rule has no in/out twin). When outputHonored is false the backend has no
// output concept, so direction never distinguishes two rules and coverage is
// unconditional — behavior identical to the pre-DirAny code.
func coversDirection(have, want Direction, outputHonored bool) bool {
if !outputHonored {
return true
}
if have == want {
return true
}
return have == DirAny && (want == DirInput || want == DirOutput)
}
// coversDirectionRemoval reports whether two rules touch a common direction (the
// symmetric remove/move form): a DirAny on either side spans input and output, so
// it touches any concrete direction and vice versa. DirForward stands alone.
func coversDirectionRemoval(a, b Direction, outputHonored bool) bool {
if !outputHonored {
return true
}
if a == b {
return true
}
if a == DirAny && (b == DirInput || b == DirOutput) {
return true
}
if b == DirAny && (a == DirInput || a == DirOutput) {
return true
}
return false
}
// EqualForDedup reports whether the receiver (an existing rule) already makes o
// redundant on add: the same base rule, and the receiver's family and direction
// both cover o's. It is the family- and direction-aware add guard the container
// backends need because EqualBase ignores Family — without the coverage check,
// adding an IPv6 rule whose IPv4 twin already exists would be dropped as a false
// duplicate, leaving that family unprotected. Family and direction are checked
// first so a non-covering row skips the field compare. Coverage: a FamilyAny
// receiver covers either family, a DirAny receiver covers either direction; a
// concrete value covers only its own. The match fields are compared in the inbound
// frame (canonicalMatch) so a DirAny row and a concrete DirOutput target line up,
// with direction excluded from the field compare since coversDirection already
// gated it.
func (r *Rule) EqualForDedup(o *Rule, outputHonored bool) bool {
fe, fr := r.impliedFamily(), o.impliedFamily()
if !(fe == FamilyAny || fe == fr) {
return false
}
if !coversDirection(r.Direction, o.Direction, outputHonored) {
return false
}
return r.canonicalMatch().EqualBase(o.canonicalMatch(), false)
}
// EqualForRemoval reports whether the receiver (an existing row) should be acted
// on when the caller targets o: the same base rule, and o's family and direction
// may touch the row's. It is the family- and direction-aware remove/move guard for
// the backends whose GetRules merges a v4/v6 pair into one FamilyAny rule or an
// in/out pair into one DirAny rule — a FamilyAny/DirAny target matches every row on
// that axis, a FamilyAny/DirAny row matches any target, and otherwise the values
// must match so acting on one twin never disturbs the other. Family and direction
// are checked first so a non-matching row skips the field compare, which runs in
// the inbound frame (canonicalMatch) with direction excluded.
func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool {
ft, fr := o.impliedFamily(), r.impliedFamily()
if !(ft == FamilyAny || fr == FamilyAny || ft == fr) {
return false
}
if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) {
return false
}
return r.canonicalMatch().EqualBase(o.canonicalMatch(), false)
}
// oppositeFamily returns the other concrete IP family: IPv4 for IPv6 and vice
// versa. FamilyAny has no opposite and returns FamilyAny. It supports the
// dual-row split on removal, where deleting a single dual-family row to satisfy a
// concrete-family target must re-add the family the caller did not target.
func oppositeFamily(f Family) Family {
switch f {
case IPv4:
return IPv6
case IPv6:
return IPv4
default:
return FamilyAny
}
}
// splitDualRow returns the rule a backend must re-add after deleting a genuine
// dual-family row — a single stored object with no family pin that covers both
// families — to satisfy a concrete-family removal: a copy of the stored row
// pinned to the family the caller did NOT target, so that family's coverage
// survives the delete. It returns nil when no split applies: the target is
// family-agnostic (the whole rule is meant to go), or the matched row is itself
// concrete-family (it removes only its own family and never a twin). Backends
// whose model cannot express the surviving single-family rule reject the removal
// with ErrUnsupported instead of calling this.
func splitDualRow(matched, target *Rule) *Rule {
tf := target.impliedFamily()
if tf == FamilyAny || matched.impliedFamily() != FamilyAny {
return nil
}
opp := *matched
opp.Family = oppositeFamily(tf)
return &opp
}
// oppositeDirection returns the other concrete traffic direction: DirOutput for
// DirInput and vice versa. DirForward and DirAny have no opposite and return
// DirAny (the sentinel the direction merge treats as "no pair"). It supports the
// direction merge and the dual-row split on removal.
func oppositeDirection(d Direction) Direction {
switch d {
case DirInput:
return DirOutput
case DirOutput:
return DirInput
default:
return DirAny
}
}
// expandDirections returns the concrete-direction rows a rule materializes into on
// write: itself for a concrete direction, or an inbound (DirInput) row plus its
// role-swapped outbound (DirOutput) twin for a DirAny rule. Backends call it before
// their existing per-family fan-out so per-chain marshalling never has to reason
// about DirAny. The returned rows are copies; the caller's rule is untouched.
func expandDirections(r *Rule) []*Rule {
if r.Direction != DirAny {
return []*Rule{r}
}
in := *r
in.Direction = DirInput
out := r.directionSwapped()
out.Direction = DirOutput
return []*Rule{&in, out}
}
// splitDualRowDirection returns the rule a backend must re-add after deleting one
// direction of a genuine DirAny row — a single stored object covering both the
// input and output directions — to satisfy a concrete-direction removal: the
// surviving opposite-direction rule, materialized in its natural frame. It mirrors
// splitDualRow for the direction axis. It returns nil when no split applies: the
// matched row is itself a concrete direction (it removes only itself, its twin
// living in a separate physical row), or the target is direction-agnostic
// (DirAny/DirForward, so the whole rule is meant to go). Backends whose model
// cannot express the surviving single-direction rule reject the removal with
// ErrUnsupported instead of calling this.
func splitDualRowDirection(matched, target *Rule) *Rule {
if matched.Direction != DirAny {
return nil
}
switch target.Direction {
case DirInput:
// The input cell is removed; the output cell survives, in its natural
// outbound frame (the stored DirAny row is inbound-framed).
s := matched.directionSwapped()
s.Direction = DirOutput
return s
case DirOutput:
// The output cell is removed; the input cell survives unchanged in frame.
s := *matched
s.Direction = DirInput
return &s
default:
return nil
}
}
// rejectLogAndLimit reports a logging or rate/connection-limit request on a
// backend whose per-rule model cannot express it. Such a rule is rejected
// rather than applied without the modifier. It returns nil when the rule asks
// for none of these. backend names the backend for the error message.
func (r *Rule) rejectLogAndLimit(backend string) error {
switch {
case r.Log:
return fmt.Errorf("%s does not support per-rule logging in this model: %w", backend, ErrUnsupportedLog)
case r.RateLimit != nil:
return fmt.Errorf("%s does not support rate limiting in this model: %w", backend, ErrUnsupportedRateLimit)
case r.ConnLimit != nil:
return fmt.Errorf("%s does not support connection limiting in this model: %w", backend, ErrUnsupportedConnLimit)
}
return nil
}
// unsupportedNAT is the error a backend returns from its NAT methods when its
// model cannot express network address translation. backend names the backend.
func unsupportedNAT(backend string) error {
return fmt.Errorf("%s does not support NAT in this model: %w", backend, ErrUnsupportedNAT)
}
// unsupportedForward is the error a backend returns when it cannot express a
// rule in the forward (routing) chain. backend names the backend.
func unsupportedForward(backend string) error {
return fmt.Errorf("%s does not support forward-chain rules in this model: %w", backend, ErrUnsupportedForward)
}
// dirAnyInputFallback maps a DirAny rule to its input half on a backend that has no
// output concept (Capabilities().Output is false), where the two directions cannot
// be distinguished: a both-directions rule degrades to an input rule rather than
// being rejected. Such a backend applies it at the top of AddRule/RemoveRule. The
// input half keeps every field (DirAny is authored in the inbound frame), only the
// direction changes. On an output-capable backend DirAny is fanned out via
// expandDirections instead, so a non-DirAny rule — or a rule on a backend that does
// distinguish output — is returned unchanged.
func dirAnyInputFallback(r *Rule, outputSupported bool) *Rule {
if r.Direction == DirAny && !outputSupported {
in := *r
in.Direction = DirInput
return &in
}
return r
}
// checkICMPType reports an ICMP type set on a non-ICMP rule, which is
// meaningless. Backends that honor ICMPType call it to reject such a rule.
func (r *Rule) checkICMPType() error {
if r.ICMPType != nil && !r.Proto.IsICMP() {
return fmt.Errorf("an icmp type requires the icmp or icmpv6 protocol")
}
return nil
}
// familyMergePairs computes the cross-family pairing shared by every merge
// helper below (filter and NAT, both the collapse and the anchor-index forms), so
// the pairing rule lives in exactly one place. For n rows it scans in order and,
// for each concrete-family row not already absorbed, finds the first later
// opposite-family row whose base is equal and marks it absorbed — each anchor
// absorbs at most one twin. family reports a row's IP family; equalBase reports
// whether rows i and j are equal ignoring family. It returns, per index, whether
// that row was absorbed into an earlier anchor, and per anchor the index of the
// twin it absorbed (-1 if none). It mutates nothing.
func familyMergePairs(n int, family func(int) Family, equalBase func(i, j int) bool) (absorbed []bool, twin []int) {
absorbed = make([]bool, n)
twin = make([]int, n)
for i := range twin {
twin[i] = -1
}
for i := 0; i < n; i++ {
if absorbed[i] || family(i) == FamilyAny {
continue
}
for j := i + 1; j < n; j++ {
if absorbed[j] || family(j) == FamilyAny || family(i) == family(j) {
continue
}
if equalBase(i, j) {
absorbed[j] = true
twin[i] = j
break
}
}
}
return absorbed, twin
}
// mergeFamilies collapses pairs of otherwise-identical rules that differ only in
// IP family (one IPv4, one IPv6) into a single FamilyAny rule. Rules that are
// already FamilyAny, or two rules of the same family, are left untouched so that
// a duplicate within a single family is never mistaken for cross-family
// coverage. The surviving anchor of each merged pair is flipped to FamilyAny.
func mergeFamilies(rules []*Rule) []*Rule {
absorbed, twin := familyMergePairs(len(rules),
func(i int) Family { return rules[i].Family },
func(i, j int) bool { return rules[i].EqualBase(rules[j], true) })
out := make([]*Rule, 0, len(rules))
for i, r := range rules {
if absorbed[i] {
continue
}
if twin[i] >= 0 {
r.Family = FamilyAny
}
out = append(out, r)
}
return out
}
// mergeFamiliesCopy is mergeFamilies over value-copies of the input rules, so the
// caller's rules (and their Family fields) are left untouched. Sync uses it to
// canonicalize a desired set the same way GetRules canonicalizes existing rules,
// without mutating the caller's slice.
func mergeFamiliesCopy(rules []*Rule) []*Rule {
cp := make([]*Rule, len(rules))
for i, r := range rules {
rc := *r
cp[i] = &rc
}
return mergeFamilies(cp)
}
// directionMergePairs computes the input/output pairing for the direction merge,
// mirroring familyMergePairs on the direction axis. For n rows it scans in order
// and, for each concrete-direction (input or output) row not already absorbed,
// finds the first later row of the opposite direction that is equal once both are
// put in the inbound frame and marks it absorbed — each anchor absorbs at most one
// twin. Forward and already-merged DirAny rows never pair. dir reports a row's
// direction; equalSwapped reports whether rows i and j are equal in the inbound
// frame. It returns, per index, whether that row was absorbed into an earlier
// anchor, and per anchor the index of the twin it absorbed (-1 if none).
func directionMergePairs(n int, dir func(int) Direction, equalSwapped func(i, j int) bool) (absorbed []bool, twin []int) {
absorbed = make([]bool, n)
twin = make([]int, n)
for i := range twin {
twin[i] = -1
}
for i := 0; i < n; i++ {
if absorbed[i] || (dir(i) != DirInput && dir(i) != DirOutput) {
continue
}
for j := i + 1; j < n; j++ {
if absorbed[j] || dir(j) != oppositeDirection(dir(i)) {
continue
}
if equalSwapped(i, j) {
absorbed[j] = true
twin[i] = j
break
}
}
}
return absorbed, twin
}
// mergeDirections collapses pairs of otherwise-identical rules that differ only in
// traffic direction — an input rule and its role-swapped output twin — into a
// single DirAny rule. It is the direction analog of mergeFamilies and is applied
// after it in GetRules, so a rule present as {v4-in, v6-in, v4-out, v6-out} first
// collapses per family to one input and one output row, then merges here to one
// FamilyAny+DirAny rule. Pairing compares in the inbound frame (canonicalMatch) and
// honors family through Equal, so an input rule never merges with an opposite-
// family output rule. The surviving anchor is normalized to the inbound frame and
// flipped to DirAny. Forward rules never merge.
func mergeDirections(rules []*Rule) []*Rule {
absorbed, twin := directionMergePairs(len(rules),
func(i int) Direction { return rules[i].Direction },
func(i, j int) bool {
return rules[i].canonicalMatch().Equal(rules[j].canonicalMatch(), false)
})
out := make([]*Rule, 0, len(rules))
for i, r := range rules {
if absorbed[i] {
continue
}
if twin[i] >= 0 {
// Present the merged rule in the inbound frame; an output anchor is
// role-swapped so DirAny{Source:X} reads back consistently.
if r.Direction == DirOutput {
r = r.directionSwapped()
}
r.Direction = DirAny
}
out = append(out, r)
}
return out
}
// mergeDirectionsCopy is mergeDirections over value-copies of the input rules, so
// the caller's rules (and their Direction fields) are left untouched. Sync uses it
// alongside mergeFamiliesCopy to canonicalize a desired set the same way GetRules
// canonicalizes existing rules, without mutating the caller's slice.
func mergeDirectionsCopy(rules []*Rule) []*Rule {
cp := make([]*Rule, len(rules))
for i, r := range rules {
rc := *r
cp[i] = &rc
}
return mergeDirections(cp)
}
// mergedFamilyAnchors returns the indices of the rules that survive mergeFamilies:
// the anchor (kept) row of each logical rule, in order. mergeFamilies collapses an
// otherwise-identical IPv4/IPv6 pair into the earlier of the two and drops the
// later, so a rule's merged 1-based Number is its position in this anchor sequence.
// A backend whose read merges families but whose chain edits address physical rows
// (nftables) uses this to map a caller-supplied Number/position back to a physical
// chain index. It shares familyMergePairs with mergeFamilies, so the pairing can
// never drift; unlike mergeFamilies it leaves the rules' Family fields untouched,
// since callers pass a physical list they only want to index into. When no pair
// merges, every index is its own anchor.
func mergedFamilyAnchors(rules []*Rule) []int {
absorbed, _ := familyMergePairs(len(rules),
func(i int) Family { return rules[i].Family },
func(i, j int) bool { return rules[i].EqualBase(rules[j], true) })
return survivingAnchors(absorbed)
}
// survivingAnchors returns the indices not marked absorbed, in order — the anchor
// row of each merged pair. Shared by the filter and NAT anchor helpers.
func survivingAnchors(absorbed []bool) []int {
anchors := make([]int, 0, len(absorbed))
for i, gone := range absorbed {
if !gone {
anchors = append(anchors, i)
}
}
return anchors
}
// mergedInsertIndex maps a 1-based merged position (a rule's Number, as GetRules
// reports it) to the 0-based physical index to insert before, given the merged
// anchors of the physical list and its row count. A position past the last logical
// rule appends (returns physicalLen). When no IPv4/IPv6 pair has merged, every row
// is its own anchor and this reduces to position-1 — the plain physical index — so
// the common single-representation case is unaffected. It exists because the read
// path merges v4/v6 pairs (so Number counts logical rules) while physical edits act
// on physical rows; without this mapping an insert lands too early by the number of
// merged pairs preceding it. Backends that edit a physical, merge-collapsed list
// (nftables chains, pf's anchor) share it.
func mergedInsertIndex(anchors []int, physicalLen, position int) int {
if position < 1 {
position = 1
}
if position-1 >= len(anchors) {
return physicalLen
}
return anchors[position-1]
}
// numberByDirection assigns each rule a 1-based Number within its direction
// (input, output or forward), in slice order. Backends whose input, output and
// forward chains are ordered independently (iptables, nftables) number rules
// this way so a rule's Number matches the InsertRule/MoveRule position for its
// chain. A DirAny rule counts in the input bucket — its Number reflects the input
// chain, as a FamilyAny rule's Number reflects the IPv4 chain. It must run before
// mergeDirections so the surviving pure-output rows keep the physical output
// position of their still-present twin. It is derived on read and, like HasPrefix,
// ignored on add and not part of rule identity.
func numberByDirection(rules []*Rule) {
var in, out, fwd int
for _, r := range rules {
switch r.Direction {
case DirOutput:
out++
r.Number = out
case DirForward:
fwd++
r.Number = fwd
default:
// DirInput and DirAny both number in the input chain.
in++
r.Number = in
}
}
}
// numberSequential assigns each rule a 1-based Number in slice order, for a backend
// that evaluates all its filter rules as one ordered list whose position spans
// directions (pf's anchor, ufw's numbered list).
func numberSequential(rules []*Rule) {
for i, r := range rules {
r.Number = i + 1
}
}
// NATKind is the kind of network address translation a NATRule performs.
type NATKind uint8
const (
NATInvalid NATKind = iota
// DNAT rewrites the destination of matching inbound packets to ToAddress
// (and ToPort when set) — a classic port-forward to another host.
DNAT
// Redirect sends matching inbound packets to a port on the local host
// (ToPort). It is destination NAT to this machine and takes no ToAddress.
Redirect
// SNAT rewrites the source of matching outbound packets to the fixed
// ToAddress.
SNAT
// Masquerade rewrites the source of matching outbound packets to the
// address of the outgoing interface, chosen dynamically at send time.
Masquerade
)
// String returns the canonical lower-case name of the NAT kind.
func (k NATKind) String() string {
switch k {
case DNAT:
return "dnat"
case Redirect:
return "redirect"
case SNAT:
return "snat"
case Masquerade:
return "masquerade"
}
return "invalid"
}
// ParseNATKind parses a NAT-kind token (case-insensitive), accepting the canonical
// name emitted by NATKind.String. An unknown value is an error.
func ParseNATKind(s string) (NATKind, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "dnat":
return DNAT, nil
case "redirect":
return Redirect, nil
case "snat":
return SNAT, nil
case "masquerade":
return Masquerade, nil
case "invalid":
return NATInvalid, nil
}
return 0, fmt.Errorf("unknown nat kind %q", s)
}
// isSource reports whether the kind performs source NAT (applied to outbound
// traffic in the postrouting stage) as opposed to destination NAT (inbound,
// prerouting).
func (k NATKind) isSource() bool {
return k == SNAT || k == Masquerade
}
// A NATRule describes a network-address-translation rule: a port-forward
// (DNAT/Redirect) applied to inbound traffic, or source NAT (SNAT/Masquerade)
// applied to outbound traffic. NAT rules are managed separately from filter
// Rules through the Manager's AddNATRule/RemoveNATRule/GetNATRules methods.
type NATRule struct {
Kind NATKind
Family Family
Proto Protocol
// Interface matches the inbound interface for DNAT/Redirect and the
// outbound interface for SNAT/Masquerade. Empty means any interface.
Interface string
Source string
Destination string
// Port is the single matched destination port; Ports is a list/range that
// overrides Port when non-empty. A port match requires a concrete tcp/udp
// protocol, as with filter rules.
Port uint16
Ports []PortRange
// ToAddress is the translation target: the new destination for DNAT, the
// new source for SNAT. It is empty for Redirect and Masquerade.
ToAddress string
// ToPort is the translation target port for DNAT/Redirect (0 leaves the
// port unchanged). It is unused for SNAT/Masquerade.
ToPort uint16
// HasPrefix reports whether this NAT rule is one the library recognizes as its
// own — the configured prefix in its comment for tag-based backends, or
// membership in the library's private table/anchor/zone for container backends
// (recorded in the unexported table field). Like Rule.HasPrefix it is derived on
// read, informational, not part of rule identity, and ignored when adding a rule.
HasPrefix bool
// Number is the NAT rule's 1-based position within its nat chain in the backend's
// native ordering, populated by GetNATRules on backends that support explicit
// ordering (Capabilities().RuleOrdering). It mirrors the position argument of
// InsertNATRule. It is zero on backends without ordering and on rules outside the
// backend's ordered set. Like HasPrefix it is derived on read, ignored when adding
// a rule, and not part of rule identity.
Number int
// table records the container a container backend read this NAT rule from (empty
// for tag-based backends); unexported and informational, backing HasPrefix.
table string
}
// PortSpecs returns the rule's matched destination ports as a normalized list.
func (r *NATRule) PortSpecs() []PortRange {
return portSpecsFor(r.Port, r.Ports)
}
// HasPorts reports whether the rule matches on any destination port.
func (r *NATRule) HasPorts() bool {
return r.Port != 0 || len(r.Ports) > 0
}
// HasPortSet reports whether the rule matches more than a single discrete port.
func (r *NATRule) HasPortSet() bool {
specs := r.PortSpecs()
if len(specs) > 1 {
return true
}
return len(specs) == 1 && specs[0].Start != specs[0].End
}
// impliedFamily returns the family the rule effectively targets, inferring it
// from the translation, destination or source address when unspecified.
func (r *NATRule) impliedFamily() Family {
if r.Family != FamilyAny {
return r.Family
}
for _, a := range []string{r.ToAddress, r.Destination, r.Source} {
if f := familyOfAddr(a); f != FamilyAny {
return f
}
}
return FamilyAny
}
// validate reports whether the rule is well formed for its kind. It is called
// by every backend before marshaling so an ill-formed rule fails uniformly.
func (r *NATRule) validate() error {
switch r.Kind {
case DNAT:
if r.ToAddress == "" {
return fmt.Errorf("dnat requires a translation address")
}
case Redirect:
if r.ToAddress != "" {
return fmt.Errorf("redirect translates to a local port, not an address")
}
if r.ToPort == 0 {
return fmt.Errorf("redirect requires a translation port")
}
case SNAT:
if r.ToAddress == "" {
return fmt.Errorf("snat requires a translation address")
}
if r.ToPort != 0 {
return fmt.Errorf("snat does not translate the port")
}
case Masquerade:
if r.ToAddress != "" || r.ToPort != 0 {
return fmt.Errorf("masquerade takes no translation target")
}
default:
return fmt.Errorf("invalid nat kind")
}
if portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) {
return fmt.Errorf("a port requires a tcp or udp protocol")
}
// A translation port (DNAT/Redirect ToPort) is only valid when the rule
// carries a port-bearing protocol: iptables' DNAT/REDIRECT/SNAT targets and
// nft's dnat/redirect reject a target port without tcp/udp/sctp. Redirect
// always sets ToPort, so this also guards a bare Redirect left ProtocolAny.
if r.ToPort != 0 && !r.Proto.HasPorts() {
return fmt.Errorf("a translation port requires a tcp or udp protocol")
}
return nil
}
// EqualBase reports whether two NAT rules describe the same translation and
// match, ignoring the IP family (mirroring Rule.EqualBase). Backends use it to
// deduplicate and remove rules regardless of a FamilyAny/concrete distinction.
func (r *NATRule) EqualBase(o *NATRule) bool {
if r.Kind != o.Kind || r.Proto != o.Proto {
return false
}
if r.Interface != o.Interface || !addrEqual(r.Source, o.Source) || !addrEqual(r.Destination, o.Destination) {
return false
}
if !portRangesEqual(r.PortSpecs(), o.PortSpecs()) {
return false
}
// ToAddress is compared through addrEqual, like Source/Destination: a backend
// re-spells the translation target on read (IPv6 zero-compression/case, a /32
// host prefix), so a byte-for-byte compare would fail to dedup an existing NAT
// rule and RemoveNATRule would fail to find it.
return addrEqual(r.ToAddress, o.ToAddress) && r.ToPort == o.ToPort
}
// Equal reports whether two NAT rules are the same, including the IP family.
// Family is compared through impliedFamily so a FamilyAny rule matches the
// concrete family a backend stores it under (mirroring Rule.Equal).
func (r *NATRule) Equal(o *NATRule) bool {
return r.impliedFamily() == o.impliedFamily() && r.EqualBase(o)
}
// EqualForDedup is the NAT-rule add guard mirroring Rule.EqualForDedup: the same
// base translation, and the receiver's family covers o's.
func (r *NATRule) EqualForDedup(o *NATRule) bool {
fe, fr := r.impliedFamily(), o.impliedFamily()
return (fe == FamilyAny || fe == fr) && r.EqualBase(o)
}
// EqualForRemoval is the NAT-rule remove guard mirroring Rule.EqualForRemoval:
// the same base translation, and o's family may touch the receiver row's.
func (r *NATRule) EqualForRemoval(o *NATRule) bool {
ft, fr := o.impliedFamily(), r.impliedFamily()
return (ft == FamilyAny || fr == FamilyAny || ft == fr) && r.EqualBase(o)
}
// mergeNATFamilies collapses IPv4/IPv6 pairs of otherwise-identical NAT rules
// into a single FamilyAny rule when reading rules back, mirroring mergeFamilies
// for filter rules.
func mergeNATFamilies(rules []*NATRule) []*NATRule {
absorbed, twin := familyMergePairs(len(rules),
func(i int) Family { return rules[i].Family },
func(i, j int) bool { return rules[i].EqualBase(rules[j]) })
out := make([]*NATRule, 0, len(rules))
for i, r := range rules {
if absorbed[i] {
continue
}
if twin[i] >= 0 {
r.Family = FamilyAny
}
out = append(out, r)
}
return out
}
// mergedNATFamilyAnchors returns the indices of the NAT rules that survive
// mergeNATFamilies, mirroring mergedFamilyAnchors for NAT rules so a merged NAT
// rule's Number maps back to a physical nat-chain index for InsertNATRule.
func mergedNATFamilyAnchors(rules []*NATRule) []int {
absorbed, _ := familyMergePairs(len(rules),
func(i int) Family { return rules[i].Family },
func(i, j int) bool { return rules[i].EqualBase(rules[j]) })
return survivingAnchors(absorbed)
}
// numberNATByChain assigns each NAT rule a 1-based Number within its nat chain —
// prerouting for destination NAT, postrouting for source NAT — matching the
// InsertNATRule position on chain-ordered backends (iptables, nftables).
func numberNATByChain(rules []*NATRule) {
var pre, post int
for _, r := range rules {
if r.Kind.isSource() {
post++
r.Number = post
} else {
pre++
r.Number = pre
}
}
}
// numberNATSequential assigns each NAT rule a 1-based Number in slice order, for a
// backend that evaluates its translation rules as one ordered list (pf).
func numberNATSequential(rules []*NATRule) {
for i, r := range rules {
r.Number = i + 1
}
}
// Backup is a portable snapshot of the state a backend manages. It can be used
// to restore that state later via Manager.Restore.
type Backup struct {
// Filter rules saved in the order they were returned by GetRules.
Rules []*Rule
// NAT rules saved in the order they were returned by GetNATRules.
NATRules []*NATRule
// DefaultPolicy is the per-direction default action captured at backup time,
// on backends that advertise Capabilities().DefaultPolicy; nil otherwise. A
// direction the backend does not expose is ActionInvalid and is left unchanged
// on Restore. Capturing it lets Restore re-assert a restrictive policy (e.g. a
// default DROP) rather than silently inherit the restore host's current one.
DefaultPolicy *DefaultPolicy
// AddressSets are the named address sets (ipsets, nftables sets, pf tables) the
// backend manages, captured on backends that advertise
// Capabilities().AddressSets; nil otherwise. Restore recreates them before the
// filter rules so a set-referencing rule (@set) resolves on a host that does
// not yet have the set.
AddressSets []*AddressSet
}
// Direction names the traffic direction a default policy or rule applies to.
type Direction uint8
const (
// DirInput is the inbound (input) direction. It must remain the zero value so
// a rule with no explicit direction is an input rule.
DirInput Direction = iota
// DirOutput is the outbound (output) direction.
DirOutput
// DirForward is the routing (forward) direction, where a backend models it.
DirForward
// DirAny applies to both the input and output directions. It is the direction
// analog of FamilyAny: a read-side merge collapses an input rule and its
// role-swapped output twin into one DirAny rule, and a write-side fan-out
// expands a DirAny rule back into a concrete input row plus a swapped output
// row. It never covers DirForward (a routed rule has no input/output twin) and
// must be declared last so DirInput stays the zero value.
DirAny
)
// String returns the canonical lower-case name of the direction.
func (d Direction) String() string {
switch d {
case DirOutput:
return "output"
case DirForward:
return "forward"
case DirAny:
return "any"
}
return "input"
}
// ParseDirection parses a direction token (case-insensitive), accepting the
// canonical name emitted by Direction.String. An unknown value is an error.
func ParseDirection(s string) (Direction, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "input", "in":
return DirInput, nil
case "output", "out":
return DirOutput, nil
case "forward", "fwd":
return DirForward, nil
case "any", "both":
return DirAny, nil
}
return 0, fmt.Errorf("unknown direction %q", s)
}
// DefaultPolicy describes the default action a firewall applies to packets that
// match no rule, per direction. A field left as ActionInvalid has backend-
// defined meaning: on Get it means the backend does not expose that direction,
// and on Set it means the direction should be left unchanged.
type DefaultPolicy struct {
Input Action
Output Action
Forward Action
}
// SetType names the kind of entries an AddressSet holds.
type SetType uint8
const (
// SetHashIP is a set of individual IP addresses.
SetHashIP SetType = iota
// SetHashNet is a set of CIDR network ranges.
SetHashNet
)
// String returns the ipset-style name of the set type.
func (t SetType) String() string {
switch t {
case SetHashNet:
return "hash:net"
}
return "hash:ip"
}
// ParseSetType parses a set-type token (case-insensitive), accepting the
// canonical name emitted by SetType.String ("hash:ip"/"hash:net") plus the short
// aliases "ip"/"net". An unknown value is an error.
func ParseSetType(s string) (SetType, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "hash:ip", "ip":
return SetHashIP, nil
case "hash:net", "net":
return SetHashNet, nil
}
return 0, fmt.Errorf("unknown set type %q", s)
}
// AddressSet is a named collection of addresses (an ipset, an nftables set or a
// pf table) that rules can match against. It is managed separately from filter
// and NAT rules through the Manager's address-set methods.
type AddressSet struct {
// Name of the set. Backends that namespace sets (nftables table, pf anchor)
// keep it within their own container.
Name string
// Family restricts the set to an IP family. Some backends require a concrete
// family (nftables inet sets carry a single address type); FamilyAny is
// resolved to IPv4 by those backends.
Family Family
// Type is the entry kind, defaulting to SetHashIP when zero.
Type SetType
// Entries are the addresses or CIDRs in the set.
Entries []string
}
// Capabilities advertises which features a backend can express. It lets a
// caller detect support before trial-and-error: a false field means the
// corresponding operation returns an unsupported error (or, for RuleCounters,
// simply reports zero).
type Capabilities struct {
// Output is true when the backend distinguishes input from output rules.
Output bool
// Forward is true when the backend can express a rule in the forward (routing)
// chain. A false Forward means a rule with Direction DirForward is rejected
// with ErrUnsupportedForward.
Forward bool
// Zones is true when the backend maps interfaces to zones.
Zones bool
// Priority is true when per-rule priority is honored.
Priority bool
// ICMPv6 is true when ICMPv6 protocol/type matching is honored. (Plain ICMP
// and ICMP-type matching are supported by every backend, so they are not
// advertised as capabilities.)
ICMPv6 bool
// PortList is true when multi-port (comma list) matching is honored. (Port
// ranges are supported by every backend, so they are not advertised.)
PortList bool
// ConnState is true when connection-tracking state can be matched.
ConnState bool
// InterfaceMatch is true when a rule can bind to a per-rule interface (as
// opposed to a zone).
InterfaceMatch bool
// Logging/RateLimit/ConnLimit describe the per-rule modifiers.
Logging bool
RateLimit bool
ConnLimit bool
// NAT is true when AddNATRule/RemoveNATRule/GetNATRules are supported.
NAT bool
// RuleOrdering is true when InsertRule/MoveRule are supported, and when NAT is
// also true, InsertNATRule.
RuleOrdering bool
// DefaultPolicy is true when GetDefaultPolicy/SetDefaultPolicy are supported.
DefaultPolicy bool
// RuleCounters is true when GetRules populates the Packets/Bytes fields.
RuleCounters bool
// AddressSets is true when the address-set methods are supported.
AddressSets bool
// Comments is true when a rule's Comment field round-trips: it is stored on
// AddRule and populated by GetRules. A false Comments means the backend
// silently ignores the Comment field.
Comments bool
}
// Manager is the standard firewall manager interface.
//
// Every method that performs I/O (shelling out, D-Bus, or the Windows API)
// takes a context.Context as its first argument so callers can apply timeouts
// and cancellation. Type and Capabilities are pure and take none.
type Manager interface {
// Type returns the manager type.
Type() string
// Capabilities returns the set of features this backend can express.
Capabilities() Capabilities
// GetZone returns the zone for the specified interface.
GetZone(ctx context.Context, iface string) (string, error)
// GetRules returns the existing filter rules from the zone.
GetRules(ctx context.Context, zoneName string) ([]*Rule, error)
// AddRule adds a rule to the zone.
AddRule(ctx context.Context, zoneName string, rule *Rule) error
// InsertRule adds rule at the given position. position uses 1-based indexing
// (1 = first rule); a non-positive position is treated as 1, and a position
// larger than the current rule count appends the rule. Backends that do not
// support ordered rules return an error.
InsertRule(ctx context.Context, zoneName string, position int, rule *Rule) error
// MoveRule moves an existing rule to the given position. position uses 1-based
// indexing; a non-positive position is treated as 1, and a position larger
// than the current rule count moves the rule to the end. Backends that do not
// support ordered rules return an error.
MoveRule(ctx context.Context, zoneName string, rule *Rule, position int) error
// RemoveRule removes a rule from the zone.
RemoveRule(ctx context.Context, zoneName string, rule *Rule) error
// GetNATRules returns the existing NAT rules from the zone. Backends without
// NAT support return an unsupported error.
GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error)
// AddNATRule adds a NAT rule to the zone.
AddNATRule(ctx context.Context, zoneName string, rule *NATRule) error
// InsertNATRule adds a NAT rule at the given position within its nat chain.
// position uses 1-based indexing (1 = first rule in that chain); a non-positive
// position is treated as 1, and a position larger than the chain's current rule
// count appends the rule. Backends that do not support ordered rules return an
// error; backends without NAT support return the NAT sentinel.
InsertNATRule(ctx context.Context, zoneName string, position int, rule *NATRule) error
// RemoveNATRule removes a NAT rule from the zone.
RemoveNATRule(ctx context.Context, zoneName string, rule *NATRule) error
// GetDefaultPolicy returns the default action applied to packets that match
// no rule. A direction the backend cannot express is returned as
// ActionInvalid. Backends that cannot manage a default policy at all return
// an unsupported error.
GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error)
// SetDefaultPolicy sets the default action for the directions named in
// policy. A direction left as ActionInvalid is left unchanged. Backends that
// cannot manage a default policy return an unsupported error.
SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error
// GetAddressSets returns the address sets managed by this backend.
GetAddressSets(ctx context.Context) ([]*AddressSet, error)
// GetAddressSet returns a single address set by name, or an error if it does
// not exist. Backends that cannot manage address sets return an unsupported
// error.
GetAddressSet(ctx context.Context, name string) (*AddressSet, error)
// AddAddressSet creates an address set. Adding a set that already exists (by
// name) is a no-op. Backends that cannot manage address sets return an
// unsupported error.
AddAddressSet(ctx context.Context, set *AddressSet) error
// RemoveAddressSet removes an address set by name. Backends that cannot
// manage address sets return an unsupported error.
RemoveAddressSet(ctx context.Context, name string) error
// AddAddressSetEntry adds an entry to the named set. Backends that cannot
// manage address sets return an unsupported error.
AddAddressSetEntry(ctx context.Context, name, entry string) error
// RemoveAddressSetEntry removes an entry from the named set. Backends that
// cannot manage address sets return an unsupported error.
RemoveAddressSetEntry(ctx context.Context, name, entry string) error
// Backup captures the current filter and NAT rules the manager reports, plus —
// on backends that advertise them — the default policy and the managed address
// sets. On container backends (nftables table, pf anchor, firewalld zone) this
// is scoped to the library's container by construction; on tag/comment backends
// it is the whole chain, foreign rules included. It does not filter on the
// HasPrefix flag.
Backup(ctx context.Context, zoneName string) (*Backup, error)
// Restore reconciles the firewall to the contents of a Backup. The captured
// address sets are recreated first (so a set-referencing rule resolves), then
// existing filter and NAT rules the backend acts on are removed and the backup
// rules added, and finally the captured default policy is re-asserted. Like Sync
// it reconciles the actual state and does not filter on HasPrefix.
Restore(ctx context.Context, zoneName string, backup *Backup) error
// Reload reloads the manager to activate new rules.
Reload(ctx context.Context) error
// Close closes the connection to the manager.
Close(ctx context.Context) error
}
// RuleBatcher is an optional interface a Manager may implement to apply many
// rules in a single atomic operation instead of one AddRule per rule. Backends
// whose commit is a whole-file or whole-ruleset load (iptables-restore, nft -f,
// pfctl -f) implement it so a batch is one syscall storm rather than N, and so a
// partial failure leaves the managed set unchanged. The package-level AddRules,
// ReplaceRules and Sync helpers use it when the backend provides it and fall
// back to per-rule operations otherwise, so callers need not type-assert.
type RuleBatcher interface {
// AddRulesBatch adds every rule in one atomic operation. Rules that already
// exist are skipped, mirroring AddRule. Either all applicable rules are added
// or, on error, none are.
AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error
// ReplaceRulesBatch atomically replaces the backend's filter rules with
// exactly rules: any rule the backend acts on that is not present in rules is
// removed (it does not filter on HasPrefix) and any missing rule is added, in
// one operation.
ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error
}
// AddRules adds every rule in rules to the zone. When mgr implements RuleBatcher
// the rules are applied in a single atomic operation; otherwise AddRules falls
// back to calling AddRule for each rule in turn (stopping at the first error).
func AddRules(ctx context.Context, mgr Manager, zoneName string, rules []*Rule) error {
if b, ok := mgr.(RuleBatcher); ok {
return b.AddRulesBatch(ctx, zoneName, rules)
}
for _, r := range rules {
if err := mgr.AddRule(ctx, zoneName, r); err != nil {
return err
}
}
return nil
}
// ReplaceRules makes the zone's filter rules equal to rules. It reconciles the
// actual firewall state (it does not filter on HasPrefix), so a rule not in rules
// is removed whether or not it carries the configured prefix. When mgr implements
// RuleBatcher the replacement is atomic; otherwise ReplaceRules falls back to Sync,
// which applies a minimal add/remove diff (a rule unchanged between the existing
// and desired sets is never removed and re-added). On a non-batching backend, Sync
// still removes unwanted existing rules before adding new ones, so replacing with a
// fully disjoint rule set leaves a real window with none of the desired rules
// present.
func ReplaceRules(ctx context.Context, mgr Manager, zoneName string, rules []*Rule) error {
if b, ok := mgr.(RuleBatcher); ok {
return b.ReplaceRulesBatch(ctx, zoneName, rules)
}
_, _, err := Sync(ctx, mgr, zoneName, rules)
return err
}
// Sync reconciles the zone's filter rules toward desired: it removes any rule
// the backend reports that is not in desired and adds desired rules that are not
// yet present, leaving rules already in place untouched. It reconciles the
// actual firewall state and does not filter on HasPrefix — a rule without the
// configured prefix (HasPrefix=false) is reconciled like any other, so a foreign
// rule not in desired is removed. A rule unchanged between existing and desired is
// never removed and re-added, but removal still runs as its own pass before
// additions, so a desired set that shares nothing with the existing rules is not
// applied atomically. Sync reports how many rules were added and removed. Rule
// identity is compared with Rule.Equal honoring
// the backend's Output capability, so the Comment, HasPrefix and Packets/Bytes
// fields do not affect the diff.
func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (added, removed int, err error) {
existing, err := mgr.GetRules(ctx, zoneName)
if err != nil {
return 0, 0, err
}
outputHonored := mgr.Capabilities().Output
// Canonicalize desired the same way GetRules canonicalizes existing: collapse an
// otherwise-identical IPv4/IPv6 pair into one FamilyAny rule, then collapse an
// input rule and its role-swapped output twin into one DirAny rule. GetRules
// merges such pairs on read, so a caller that lists the families or directions
// separately would never match the single merged rule and Sync would
// remove-and-re-add it on every run (a transient gap). Copy first — the merges
// mutate their input's fields and slice. Family before direction, matching the
// order in every backend's GetRules.
desired = mergeFamiliesCopy(desired)
desired = mergeDirectionsCopy(desired)
// Remove any existing rule that is not wanted. Sync reconciles the actual
// firewall state toward desired, so any rule the backend reports and can act on
// is fair game; backends whose mutations are scoped to a private table/anchor
// simply no-op on rules outside it.
for _, e := range existing {
keep := false
for _, d := range desired {
if e.Equal(d, outputHonored) {
keep = true
break
}
}
if !keep {
if err := mgr.RemoveRule(ctx, zoneName, e); err != nil {
return added, removed, err
}
removed++
}
}
// Add any wanted rule that is not already present.
var toAdd []*Rule
for _, d := range desired {
present := false
for _, e := range existing {
// A rule already in the firewall — whoever created it — counts as present,
// so Sync does not add a duplicate of an identical existing rule.
if e.Equal(d, outputHonored) {
present = true
break
}
}
// Skip a duplicate within desired itself: if an equal rule is already
// queued, adding it again would double-apply it on a RuleBatcher backend
// (which sees both as new) and over-count added on the others.
if !present {
for _, a := range toAdd {
if a.Equal(d, outputHonored) {
present = true
break
}
}
}
if !present {
toAdd = append(toAdd, d)
}
}
if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil {
return added, removed, err
}
added = len(toAdd)
return added, removed, nil
}
// unsupportedOrdering is returned by backends that do not support explicit
// rule ordering for InsertRule or MoveRule.
func unsupportedOrdering(backend string) error {
return fmt.Errorf("%s does not support explicit rule ordering in this model: %w", backend, ErrUnsupportedOrdering)
}
// unsupportedPolicy is returned by backends that cannot read or set a default
// policy through this model.
func unsupportedPolicy(backend string) error {
return fmt.Errorf("%s does not support default-policy management in this model: %w", backend, ErrUnsupportedPolicy)
}
// unsupportedSet is returned by backends that cannot manage address sets
// (ipset/nftset/tables) through this model.
func unsupportedSet(backend string) error {
return fmt.Errorf("%s does not support address sets in this model: %w", backend, ErrUnsupportedSet)
}