go-firewall/firewall.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

2352 lines
89 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// TCPUDP matches TCP and UDP together. It is the protocol analog of FamilyAny
// and DirAny: a backend whose native syntax carries both transports in one row
// stores it as written and reads it back as TCPUDP, while one that cannot fans it
// into a TCP row and a UDP row on write (expandProtocols). It is distinct from
// ProtocolAny, which matches *every* IP protocol — a tcp/udp rule reported as
// "any protocol" would silently widen to ICMP, GRE and the rest. Only TCPUDP
// carries ports.
TCPUDP
)
// 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 TCPUDP:
return "tcpudp"
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, SCTP or
// the merged TCPUDP). A port match is only meaningful and only valid for these
// protocols.
func (t Protocol) HasPorts() bool {
return t == TCP || t == UDP || t == SCTP || t == TCPUDP
}
// oppositeProtocol returns the other transport of the TCP/UDP pair a TCPUDP rule
// fans out to: UDP for TCP and vice versa. Every other protocol has no twin and
// returns ProtocolAny (the sentinel meaning "no pair"). It is the protocol analog of
// oppositeFamily, and supports the dual-row split on removal.
func oppositeProtocol(p Protocol) Protocol {
switch p {
case TCP:
return UDP
case UDP:
return TCP
default:
return ProtocolAny
}
}
// 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("tcpudp", proto):
return TCPUDP
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). A rule the backend
// stores as one row per IP family is reported once per row, each with its own
// Number. 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; a backend reports DirAny only for an entry whose native
// form covers both directions, which it decodes itself.
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
// the DirAny write-side fan-out (expandDirections), the direction split on removal,
// and the inbound-frame comparison rule identity uses. 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 {
return r.covers(o, outputHonored)
}
// coversFamily reports whether an existing rule's family have already covers a caller
// rule's family want. FamilyAny spans both IP families, so it covers either; a
// concrete family covers only itself. Both sides are implied families, so an
// address- or ICMP-pinned rule is compared by the family it actually targets.
func coversFamily(have, want Family) bool {
return have == FamilyAny || have == want
}
// covers is the coverage relation behind Covers and EqualForDedup: r's match is the
// same as o's in every ordinary field, and r's family, transport and direction each
// span o's. outputHonored is false on a backend with no output concept, where
// direction never distinguishes two rules.
func (r *Rule) covers(o *Rule, outputHonored bool) bool {
if !coversFamily(r.impliedFamily(), o.impliedFamily()) {
return false
}
if !coversDirection(r.Direction, o.Direction, outputHonored) {
return false
}
if !coversProtocol(r.Proto, o.Proto) {
return false
}
// Protocol is gated above, so neutralize it on the tcp/udp axis rather than let
// matchFields re-test it exactly — a TCPUDP row must absorb a concrete TCP add.
return r.canonicalMatch().protoNeutralized().EqualBase(o.canonicalMatch().protoNeutralized(), false)
}
// Covers reports whether the receiver's coverage contains o's: the same match in
// every ordinary field, with the receiver's family, transport and direction each
// spanning o's. FamilyAny spans both IP families, TCPUDP spans TCP and UDP, and
// DirAny spans input and output; a concrete value spans only itself. ProtocolAny is
// not a multi-state value — it matches every IP protocol — so it covers only
// ProtocolAny.
//
// It is the exported form of the coverage relation the library reasons with. A caller
// holding a rule read back from GetRules uses it to tell whether that rule already
// contains one it is about to add, rather than re-deriving the per-axis rules. It is
// asymmetric: a TCPUDP rule covers its TCP half, never the reverse. Direction is
// always honored; a backend that has no output concept reports Capabilities().Output
// false and folds a DirAny rule to its input half on write.
func (r *Rule) Covers(o *Rule) bool {
return r.covers(o, true)
}
// 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, transport and
// direction each touch the row's. It is the overlap relation a removal walks the
// stored rows with, so a target removes every row it covers (rule 3) and also
// matches a row that covers more than the target, which the backend then deletes and
// re-adds minus the targeted cell (splitMergedRow, rule 4). A FamilyAny/TCPUDP/DirAny
// target matches every row on that axis, such a 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
}
if !coversProtocolRemoval(r.Proto, o.Proto) {
return false
}
// Protocol is gated above; neutralize it on the tcp/udp axis so a TCPUDP row
// matches a concrete-transport target (the caller then splits it) and a TCPUDP
// target matches each concrete row it covers.
return r.canonicalMatch().protoNeutralized().EqualBase(o.canonicalMatch().protoNeutralized(), 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
}
// 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}
}
// expandProtocols returns the concrete-transport rows a rule materializes into on
// write: itself for a single protocol, or a TCP row plus a UDP row for a TCPUDP
// rule. Backends whose native config has no both-transports form call it before
// marshalling so per-row emission never has to reason about TCPUDP. The returned
// rows are copies; the caller's rule is untouched. It is the protocol analog of
// expandDirections.
func expandProtocols(r *Rule) []*Rule {
if r.Proto != TCPUDP {
return []*Rule{r}
}
tcp, udp := *r, *r
tcp.Proto, udp.Proto = TCP, UDP
return []*Rule{&tcp, &udp}
}
// expandFamilies returns the concrete-family rows a rule materializes into: itself
// when it already targets one family, or an IPv4 row plus an IPv6 row when it targets
// both. It reads the implied family, so a rule pinned by an address or an ICMP
// protocol is never split. Backends fan families out in their own way (a save file
// per family, a family-less inet row, a dual-stack config list), so this exists for
// the coverage math in cells/CoveredBy rather than for any write path.
func expandFamilies(r *Rule) []*Rule {
if r.impliedFamily() != FamilyAny {
return []*Rule{r}
}
v4, v6 := *r, *r
v4.Family, v6.Family = IPv4, IPv6
return []*Rule{&v4, &v6}
}
// cells returns the concrete rules r covers: the cross product of its three merged
// axes, each expanded to the values it spans. A FamilyAny + TCPUDP + DirAny rule
// yields eight cells; a fully concrete rule yields itself. The direction expansion
// role-swaps the outbound half, so each cell is stated in its own natural frame —
// covers compares in the inbound frame, so that swap round-trips. On a backend with
// no output concept (outputHonored false) the direction axis does not distinguish
// two rules, so it is not expanded.
func (r *Rule) cells(outputHonored bool) []*Rule {
dirs := []*Rule{r}
if outputHonored {
dirs = expandDirections(r)
}
var out []*Rule
for _, d := range dirs {
for _, p := range expandProtocols(d) {
out = append(out, expandFamilies(p)...)
}
}
return out
}
// coveredBy is the coverage relation behind CoveredBy, with the direction axis
// gated on whether the backend distinguishes output rules at all.
func (r *Rule) coveredBy(rules []*Rule, outputHonored bool) bool {
for _, cell := range r.cells(outputHonored) {
covered := false
for _, have := range rules {
if have.covers(cell, outputHonored) {
covered = true
break
}
}
if !covered {
return false
}
}
return true
}
// CoveredBy reports whether every concrete rule the receiver spans is covered by at
// least one rule in rules. It is the set form of Covers, and its inverse: where
// a.Covers(b) asks whether one rule contains another, b.CoveredBy([]*Rule{a}) asks
// whether a set contains one.
//
// A rule that spans several axes is rarely stored as one object: GetRules reports the
// firewall's actual rows, so a rule the caller authored as FamilyAny may read back as
// an IPv4 row and an IPv6 row on a backend that cannot store one family-agnostic row.
// Such a rule is fully present in the set even though no single member Covers it, so
// coverage is decided cell by cell rather than rule by rule. A caller uses it to
// decide whether a rule is already installed before adding it.
//
// It expands the receiver across family, transport and direction and requires every
// resulting cell to be covered, so a rule spanning both transports is not reported
// present when only its TCP half is. The receiver is not modified.
func (r *Rule) CoveredBy(rules []*Rule) bool {
return r.coveredBy(rules, true)
}
// splitDualRowProtocol returns the rule a backend must re-add after deleting one
// transport of a genuine TCPUDP row — a single stored rule covering both TCP and
// UDP — to satisfy a concrete-protocol removal: the surviving opposite transport.
// It mirrors splitDualRow for the protocol axis. It returns nil when no split
// applies: the matched row is not a merged TCPUDP row, or the target names no
// single transport (so the whole row goes).
func splitDualRowProtocol(matched, target *Rule) *Rule {
if matched.Proto != TCPUDP {
return nil
}
opp := oppositeProtocol(target.Proto)
if opp == ProtocolAny {
return nil
}
s := *matched
s.Proto = opp
return &s
}
// splitMergedRow returns the rows a backend must re-add after deleting a single
// stored row that covered more than the caller targeted. A row may be merged on two
// axes at once — nftables' inet table holds a FamilyAny rule as one unpinned row,
// and a TCPUDP rule as one `meta l4proto { tcp, udp }` row — so removing one cell of
// that family×transport grid can leave a remainder that needs two rows to express.
// It composes the per-axis splits: the untargeted family keeps the row's full
// transport coverage, and the untargeted transport is then scoped to the family the
// target named, so the two rows never overlap. It returns nil when the target covers
// the whole row.
func splitMergedRow(matched, target *Rule) []*Rule {
var out []*Rule
if s := splitDualRow(matched, target); s != nil {
out = append(out, s)
}
if s := splitDualRowProtocol(matched, target); s != nil {
// A family split above already re-added the untargeted family across both
// transports, so this row must not repeat it: pin it to the targeted family.
if len(out) > 0 {
s.Family = target.impliedFamily()
}
out = append(out, s)
}
return out
}
// coversProtocol reports whether an existing rule's protocol have already covers a
// caller rule's protocol want (the asymmetric add/dedup form). TCPUDP spans TCP and
// UDP, so it covers either; every other protocol covers only itself. ProtocolAny is
// not a merged value — it matches every IP protocol — so it covers only ProtocolAny.
func coversProtocol(have, want Protocol) bool {
if have == want {
return true
}
return have == TCPUDP && (want == TCP || want == UDP)
}
// coversProtocolRemoval reports whether two rules touch a common transport (the
// symmetric remove/move form): a TCPUDP on either side spans TCP and UDP, so it
// touches either concrete transport and vice versa.
func coversProtocolRemoval(a, b Protocol) bool {
if a == b {
return true
}
if a == TCPUDP && (b == TCP || b == UDP) {
return true
}
if b == TCPUDP && (a == TCP || a == UDP) {
return true
}
return false
}
// protoNeutralized returns a copy of r with its protocol cleared to TCPUDP when it
// sits on the merged tcp/udp axis, so the field compare in EqualForDedup and
// EqualForRemoval does not re-test a protocol coversProtocol has already gated.
// Every other protocol is returned unchanged, keeping matchFields' exact protocol
// equality for rules that never merge.
func (r *Rule) protoNeutralized() *Rule {
if !onProtocolAxis(r.Proto) {
return r
}
c := *r
c.Proto = TCPUDP
return &c
}
// onProtocolAxis reports whether a protocol participates in the tcp/udp merge:
// the two concrete transports and their merged TCPUDP form.
func onProtocolAxis(p Protocol) bool {
return p == TCP || p == UDP || p == TCPUDP
}
// 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
}
// CheckExpandedProtocol reports a TCPUDP rule reaching a row-level marshaller.
// TCPUDP is a merged, logical protocol: a backend with no both-transports form fans
// it into a tcp row and a udp row with expandProtocols before marshalling, so a
// TCPUDP rule arriving here means that fan-out was skipped. Backends whose native
// syntax does carry both transports in one row (nftables' `meta l4proto { tcp, udp }`)
// do not call it.
func (r *Rule) CheckExpandedProtocol() error {
if r.Proto == TCPUDP {
return fmt.Errorf("the tcpudp protocol matches two transports and must be expanded to a tcp and a udp rule")
}
return nil
}
// logicalInsertIndex maps a 1-based logical position (a rule's Number, as GetRules
// reports it) to the 0-based physical index to insert before, given the physical
// index of each logical rule and the physical row count. A position past the last
// logical rule appends (returns physicalLen). It exists because a backend's physical
// list may hold rows GetRules does not report as their own rule — an unmodeled
// foreign line pf keeps as an opaque row, a route tuple ufw numbers but this backend
// cannot model, the LOG line iptables folds into the action line that follows it —
// so a logical position counted over the reported rules lands at the wrong physical
// row unless it is mapped through the anchors. When every physical row is its own
// logical rule this reduces to position-1, the plain physical index.
func logicalInsertIndex(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 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")
}
// TCPUDP is a multi-state, logical protocol with no NAT form: a translation is applied
// per transport, and no backend's NAT syntax carries both in one rule. The filter
// path fans TCPUDP out with expandProtocols; NAT has no such fan-out, so reject it
// here rather than let a backend emit a `tcpudp` protocol token. A caller wanting
// both transports translated adds a tcp rule and a udp rule.
if r.Proto == TCPUDP {
return fmt.Errorf("nat rules take a single transport; add a tcp rule and a udp rule: %w", ErrUnsupportedNAT)
}
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 {
return r.Covers(o)
}
// Covers reports whether the receiver's coverage contains o's, mirroring Rule.Covers
// for NAT rules. Family is the only axis a NAT rule spans — a translation applies per
// transport and NAT has no direction — so a FamilyAny rule covers either family and a
// concrete one covers only itself.
func (r *NATRule) Covers(o *NATRule) bool {
return coversFamily(r.impliedFamily(), o.impliedFamily()) && r.EqualBase(o)
}
// CoveredBy reports whether every concrete NAT rule the receiver spans is covered by
// at least one rule in rules, mirroring Rule.CoveredBy. A FamilyAny receiver requires
// both families to be covered, whether by one FamilyAny rule or by an IPv4 rule and
// an IPv6 rule.
func (r *NATRule) CoveredBy(rules []*NATRule) bool {
for _, cell := range r.cells() {
covered := false
for _, have := range rules {
if have.Covers(cell) {
covered = true
break
}
}
if !covered {
return false
}
}
return true
}
// cells returns the concrete-family NAT rules the receiver spans, the NAT analog of
// Rule.cells over the single axis a NAT rule spans.
func (r *NATRule) cells() []*NATRule {
if r.impliedFamily() != FamilyAny {
return []*NATRule{r}
}
v4, v6 := *r, *r
v4.Family, v6.Family = IPv4, IPv6
return []*NATRule{&v4, &v6}
}
// 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)
}
// 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 backend that can store a bidirectional rule as one
// object reads it back as DirAny, while one that cannot fans it into a concrete
// input row plus a role-swapped output row on write (expandDirections). 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
}
// The backend type strings Manager.Type reports, one per backend. They are declared
// here beside the interface rather than beside each implementation because every
// backend lives behind a build tag for its own platform: a caller — or a test — that
// branches on mgr.Type() must be able to name any backend on any platform, not only
// the ones that compile for the host.
const (
IPTablesType = "iptables"
NFTType = "nftables"
UFWType = "ufw"
FirewallDType = "firewalld"
CSFType = "csf"
APFType = "apf"
PFType = "pf"
WFType = "windows-firewall"
)
// 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 desired does not cover 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.
//
// The diff is a coverage relation, not rule-for-rule equality, because GetRules
// reports the firewall's actual rows and a backend stores a rule the way its model
// allows: iptables holds a FamilyAny rule as an IPv4 row plus an IPv6 row, pf holds
// a DirAny rule as an inbound row plus an outbound row, while nftables holds either
// as one row. An existing rule is kept when every concrete cell it spans is wanted
// (Rule.CoveredBy over desired), and a desired rule is added when some cell it spans
// is not yet present (its CoveredBy over existing). Comparing this way keeps Sync a
// no-op against its own output whichever representation the backend chose, where
// plain equality would remove-and-re-add every fanned-out rule on each run. A rule
// only partially covered by desired is removed whole and the wanted part re-added.
// The Comment, HasPrefix and Packets/Bytes fields never 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
// Remove any existing rule desired does not fully cover. 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. A rule whose cells are spread
// across several desired rules is still fully wanted and is kept.
kept := make([]*Rule, 0, len(existing))
for _, e := range existing {
if e.coveredBy(desired, outputHonored) {
kept = append(kept, e)
continue
}
if err := mgr.RemoveRule(ctx, zoneName, e); err != nil {
return added, removed, err
}
removed++
}
// Add any wanted rule that is not already present. A rule already in the
// firewall — whoever created it — counts as present, so Sync does not add a
// duplicate of a rule the surviving rows already cover. Queued additions count
// as present too: adding a covered duplicate would double-apply it on a
// RuleBatcher backend (which sees both as new) and over-count added on the rest.
var toAdd []*Rule
for _, d := range desired {
if d.coveredBy(kept, outputHonored) || d.coveredBy(toAdd, outputHonored) {
continue
}
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)
}