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 "/" (e.g. "10/minute"). func (rl RateLimit) String() string { return fmt.Sprintf("%d/%s", rl.Rate, rl.Unit) } // parseRateToken parses a "/" 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) }