package firewall import ( "context" "errors" "fmt" "net" "sort" "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 // (unsupportedNAT, unsupportedOrdering, ...) 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) ) // unsupportedNAT is the error a backend returns from its NAT methods when its // model cannot express network address translation. backend names the backend. // //nolint:unused // only the wf backend needs it, and the authoritative `unused` run is GOOS=linux. func unsupportedNAT(backend string) error { return fmt.Errorf("%s does not support NAT in this model: %w", backend, ErrUnsupportedNAT) } // 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. // //nolint:unused // only the wf backend needs it, and the authoritative `unused` run is GOOS=linux. func unsupportedSet(backend string) error { return fmt.Errorf("%s does not support address sets in this model: %w", backend, ErrUnsupportedSet) } // 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) } // 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 { fam, _ := parseAddrFamily(strings.TrimPrefix(strings.TrimSpace(addr), "!")) return fam } // parseAddrFamily parses an address (IP or CIDR) and reports its family, or false // when the value is not a valid address. The boolean is what distinguishes it from // familyOfAddr, which folds an unset and an unparseable address into FamilyAny: // config-file parsers classify a line by whether it is an address at all. func parseAddrFamily(v string) (Family, bool) { cidrIP, _, err := net.ParseCIDR(v) ip := net.ParseIP(v) if err != nil && ip == nil { return FamilyAny, false } if (cidrIP != nil && cidrIP.To4() == nil) || (ip != nil && ip.To4() == nil) { return IPv6, true } return IPv4, true } // 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 } // setRefFamilyFrom resolves the single concrete family of the address set(s) a // rule references, using lookup to read a named set's family from wherever the // backend keeps its sets (the live kernel, its own config file, a D-Bus query). // A named set is family-typed, so this is what pins a family-agnostic // set-referencing rule to the one family it could ever match. An optional // leading "!" negation and "@" set marker are stripped before lookup. A set the // lookup cannot find, or a source/destination pair naming sets of different // families, cannot produce a loadable rule, so both are errors. With no set // reference among the arguments the result is IPv4; callers guard on isSetRef, // so that arm is only a safe default. func setRefFamilyFrom(lookup func(name string) (Family, bool, error), source, destination string) (Family, error) { fam := FamilyAny for _, ref := range []string{source, destination} { if !isSetRef(ref) { continue } _, bare := splitAddrNeg(strings.TrimSpace(ref)) name := strings.TrimPrefix(bare, "@") sf, found, err := lookup(name) if err != nil { return FamilyAny, err } if !found { return FamilyAny, fmt.Errorf("rule references unknown address set %q", name) } // A family-untyped set (hash:mac and friends) matches as IPv4. if sf != IPv6 { sf = IPv4 } if fam != FamilyAny && sf != fam { return FamilyAny, fmt.Errorf("rule references address sets of different families") } fam = sf } if fam == FamilyAny { fam = IPv4 } return fam, nil } // resolveSetRefRule returns r pinned to its referenced set's family when the // rule is family-agnostic and names a set — resolve supplies the family from the // backend's set store — and passes every other rule through unchanged. Callers // resolve before fanning out per family, so a set-referencing rule is never // written for a family its single-family set can never match. func resolveSetRefRule(r *Rule, resolve func(source, destination string) (Family, error)) (*Rule, error) { if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) { return r, nil } fam, err := resolve(r.Source, r.Destination) if err != nil { return nil, err } rc := *r rc.Family = fam return &rc, nil } // resolveSetRefNAT is resolveSetRefRule for NAT rules. func resolveSetRefNAT(r *NATRule, resolve func(source, destination string) (Family, error)) (*NATRule, error) { if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) { return r, nil } fam, err := resolve(r.Source, r.Destination) if err != nil { return nil, err } rc := *r rc.Family = fam return &rc, nil } // Rule is a firewall filter rule: a packet match and the action applied to // matching packets. Its identity is the match fields and action; the derived // fields (HasPrefix, Number, Packets, Bytes, Comment) describe how the backend // stores or reports it and do not affect equality. type Rule struct { // Direction is the traffic direction the rule applies to: DirInput, // DirOutput, DirForward or DirAny. Direction Direction // Priority orders this rule relative to others on backends that advertise // Capabilities().Priority. Priority int // Family is the IP family the rule targets. FamilyAny targets both and is // resolved from an address or ICMP protocol when left unset. Family Family // Source matches the packet's source address or CIDR. A leading "!" negates // the match, and a non-IP token names an address set where supported. Empty // matches any source. Source string // Destination matches the packet's destination address or CIDR, with the // same semantics as Source. Empty matches any destination. Destination string // Port is the single destination port to match; Ports takes precedence when // non-empty. Port uint16 // Ports is a list of destination port ranges to match. Ports []PortRange // SourcePort is the single source port to match; SourcePorts takes // precedence when non-empty. SourcePort uint16 // SourcePorts is a list of source port ranges to match. SourcePorts []PortRange // Proto is the network protocol the rule matches. ProtocolAny matches every // protocol. Proto Protocol // ICMPType, when set, restricts an ICMP/ICMPv6 rule to a single message // type. A nil pointer matches every type. It is only meaningful when Proto // is ICMP or ICMPv6. ICMPType *uint8 // State restricts the rule to the given connection-tracking states. The // zero value applies no state match. State ConnState // InInterface matches the inbound interface the packet arrived on. Empty // matches any interface. InInterface string // OutInterface matches the outbound interface the packet leaves on. Empty // matches any interface. OutInterface string // Action is the action applied to matching packets. Action Action // Log, when set, logs each matched packet before the Action is applied. Log bool // LogPrefix is an optional label attached to the log line when Log is set. LogPrefix string // RateLimit caps the packet rate the rule matches; nil applies no limit. RateLimit *RateLimit // ConnLimit caps the concurrent connections the rule matches; nil applies // no limit. ConnLimit *ConnLimit // Packets is the per-rule packet counter, populated by GetRules on backends // that advertise Capabilities().RuleCounters. Packets uint64 // Bytes is the per-rule byte counter, populated alongside Packets. Bytes uint64 // Comment is an optional human-readable label stored alongside the rule on // backends that advertise Capabilities().Comments. Comment string // HasPrefix reports whether the rule carries the library's configured // prefix. It is derived on read and purely informational — the library // itself never branches on it. HasPrefix bool // Number is the rule's 1-based position within its chain, populated by // GetRules on backends that advertise Capabilities().RuleOrdering. It // mirrors the position argument of InsertRule and MoveRule. Number int // table records the backend container a container backend read this rule // from; it backs HasPrefix for the container backends. table string // meterSet records the dynamic set a per-source connection-limit row counts // in, captured on read by the nftables backend so a removal can clear it. meterSet string } // IsInput reports whether the rule's direction is exactly input (inbound). It is // a strict, single-direction test: a DirAny rule is not an input rule. Backends // use it to route a rule to the input chain and to stamp read-back values, so it // must not fire for DirAny; a DirAny rule reaches a per-chain path only after // expandDirections has already split it into concrete rows. The both-directions // coverage a DirAny rule spans is decided by coversDirection, not here. func (r *Rule) IsInput() bool { return r.Direction == DirInput } // IsOutput reports whether the rule's direction is exactly output (outbound). It // is the strict output analog of IsInput; see that method for why a DirAny rule // must not report true here. 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 } // 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 } // natPortSpecsToRule is portSpecsToRule for NAT rules: it writes a parsed set // of match-port ranges onto the rule's Port/Ports fields. func natPortSpecsToRule(r *NATRule, specs []PortRange) { if len(specs) == 1 && specs[0].Start == specs[0].End { r.Port = specs[0].Start return } r.Ports = specs } // 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 } // perSourceLimited reports whether the rule carries a per-source connection // limit, the form whose count is keyed on the source address. func (r *Rule) perSourceLimited() bool { return r.ConnLimit != nil && r.ConnLimit.PerSource } // 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 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() } // 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. A ported `any` in a ufw tuple is not this // shape: it means tcp+udp and is modeled as TCPUDP. 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. outputSupported is false // on a backend with no output concept (Capabilities().Output), where the // input/output distinction is dropped so those two directions never tell two // rules apart. The coverage callers (covers, EqualForRemoval) pass false here // only after coversDirection has already decided direction — including keeping a // forward rule distinct — so the exact-direction gate is theirs to skip. func (r *Rule) matchFields(rule *Rule, outputSupported bool) bool { if r.Direction != rule.Direction && outputSupported { 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). outputSupported is false on a backend with no output concept // (Capabilities().Output), where direction does not distinguish two rules. func (r *Rule) Equal(rule *Rule, outputSupported bool) bool { if r.impliedFamily() != rule.impliedFamily() { return false } return r.matchFields(rule, outputSupported) } // EqualBase reports whether two rules are the same, ignoring family. // outputSupported has Equal's meaning. func (r *Rule) EqualBase(rule *Rule, outputSupported bool) bool { return r.matchFields(rule, outputSupported) } // coversDirection reports whether an existing rule's direction (have) already // covers a caller rule's 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 outputSupported is false the backend has no // output concept, so input and output never distinguish two rules — but a // forward rule is still its own chain and only covered by another forward rule. func coversDirection(have, want Direction, outputSupported bool) bool { if !outputSupported { return (have == DirForward) == (want == DirForward) } 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, outputSupported bool) bool { if !outputSupported { return (a == DirForward) == (b == DirForward) } 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, with the receiver's family, direction // and transport (a TCPUDP row covers its tcp/udp halves) all covering 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, outputSupported bool) bool { return r.covers(o, outputSupported) } // 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, direction and // port axes each span o's. outputSupported is false on a backend with no output // concept, where direction never distinguishes two rules. func (r *Rule) covers(o *Rule, outputSupported bool) bool { if !coversFamily(r.impliedFamily(), o.impliedFamily()) { return false } if !coversDirection(r.Direction, o.Direction, outputSupported) { return false } if !coversProtocol(r.Proto, o.Proto) { return false } // The port axes compare in the inbound frame, as matchFields does below: an // output rule's source and destination ports swap roles. rc, oc := r.canonicalMatch(), o.canonicalMatch() if !coversPorts(rc.PortSpecs(), oc.PortSpecs()) || !coversPorts(rc.SourcePortSpecs(), oc.SourcePortSpecs()) { return false } // Protocol and ports are gated above, so neutralize them on their axes rather // than let matchFields re-test them exactly — a TCPUDP row must absorb a // concrete TCP add, and a port-list row its single-port cells. return rc.protoNeutralized().portsNeutralized().EqualBase(oc.protoNeutralized().portsNeutralized(), false) } // Covers reports whether the receiver's coverage contains o's: the same match in // every ordinary field, with the receiver's family, transport, direction and port // axes each spanning o's. FamilyAny spans both IP families, TCPUDP spans TCP and // UDP, DirAny spans input and output, and a port list or range spans the ports it // contains; 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: a target removes every row it covers, and also matches a row // that covers more than the target, which the backend then deletes and re-adds // minus the targeted cell (splitMergedRow). 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. The // field compare runs in the inbound frame (canonicalMatch) with direction // excluded, since the axis gates above already decided it. func (r *Rule) EqualForRemoval(o *Rule, outputSupported bool) bool { ft, fr := o.impliedFamily(), r.impliedFamily() if ft != FamilyAny && fr != FamilyAny && ft != fr { return false } if !coversDirectionRemoval(r.Direction, o.Direction, outputSupported) { 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 backs the // coverage math in cells/CoveredBy, the filterFamiliesIPv6 narrowing, and the // per-family fan-outs that work on rows (nftables sets, the csf/apf hook's // per-family command lines). 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} } // filterFamiliesIPv6 returns the expandFamilies rows narrowed to the families the // backend enforces: with ipv6Enabled false, the IPv6 row of a family-agnostic rule // is dropped rather than written as a line the backend would never enforce. A row // already pinned to a concrete family keeps it, IPv6 included, so Restore can // reproduce a snapshot verbatim. func filterFamiliesIPv6(ipv6Enabled bool, r *Rule) []*Rule { rows := expandFamilies(r) if ipv6Enabled || len(rows) == 1 { return rows } kept := make([]*Rule, 0, 1) for _, row := range rows { if row.Family != IPv6 { kept = append(kept, row) } } return kept } // expandPorts returns the single-port-spec rows a port-list rule materializes // into on write: itself when each port axis carries at most one spec, otherwise // the cross product of one row per destination-port spec and source-port spec. // Backends whose rule form carries a single port element (a firewalld rich rule // or zone port, a pf row — pfctl expands a list on load) call it before // marshalling so per-row emission never has to reason about a list; the stored // rows cover the list rule through coversPorts. The returned rows are copies; // the caller's rule is untouched. It is the port analog of expandProtocols. func expandPorts(r *Rule) []*Rule { dest, src := r.PortSpecs(), r.SourcePortSpecs() if len(dest) <= 1 && len(src) <= 1 { return []*Rule{r} } // An unset axis stays unset in every row; a set axis contributes one row per // spec, written back through portSpecsToRule so a single discrete port takes // the Port field, matching how a backend spells a parsed row. var dests, srcs [][]PortRange for _, pr := range dest { dests = append(dests, []PortRange{pr}) } if len(dests) == 0 { dests = [][]PortRange{nil} } for _, pr := range src { srcs = append(srcs, []PortRange{pr}) } if len(srcs) == 0 { srcs = [][]PortRange{nil} } var out []*Rule for _, d := range dests { for _, s := range srcs { c := *r c.Port, c.Ports, c.SourcePort, c.SourcePorts = 0, nil, 0, nil portSpecsToRule(&c, d) sourcePortSpecsToRule(&c, s) out = append(out, &c) } } return out } // expandNATPorts is expandPorts for NAT rules: it fans a match-port list into // one rule per spec so a backend whose NAT form carries a single port (a // firewalld forward-port, a pf rdr row) stores each spec as its own row, every // row translating to the same target. func expandNATPorts(r *NATRule) []*NATRule { specs := r.PortSpecs() if len(specs) <= 1 { return []*NATRule{r} } out := make([]*NATRule, 0, len(specs)) for _, pr := range specs { c := *r c.Port, c.Ports = 0, nil natPortSpecsToRule(&c, []PortRange{pr}) out = append(out, &c) } return out } // cells returns the concrete rules r covers: the cross product of its 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 (outputSupported false) the direction axis does not distinguish // two rules, so it is not expanded. The port axis expands a list to one cell per // spec, keeping each range whole, so a stored set of single-port rows covers a // list rule on a backend that fans lists out (firewalld, pf). func (r *Rule) cells(outputSupported bool) []*Rule { dirs := []*Rule{r} if outputSupported { dirs = expandDirections(r) } var out []*Rule for _, d := range dirs { for _, p := range expandProtocols(d) { for _, fam := range expandFamilies(p) { out = append(out, expandPorts(fam)...) } } } 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, outputSupported bool) bool { for _, cell := range r.cells(outputSupported) { covered := false for _, have := range rules { if have.covers(cell, outputSupported) { 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, direction and ports and // requires every resulting cell to be covered, so a rule spanning both transports // is not reported present when only its TCP half is, and a port-list rule is not // reported present when only some of its ports are. The receiver is not modified. func (r *Rule) CoveredBy(rules []*Rule) bool { return r.coveredBy(rules, true) } // MatchesAny reports whether the receiver is the same underlying rule as any of // targets, honoring direction but ignoring the comment, which is not part of rule // identity. It is the set form of Equal, and unlike CoveredBy it demands identity // rather than coverage: a TCPUDP target does not match its TCP half. It backs // comment-agnostic removal, where a stored row is acted on when it means the same // rule as one the caller named, however that row was spelled or commented. func (r *Rule) MatchesAny(targets []*Rule) bool { for _, t := range targets { if r.Equal(t, true) { return true } } return false } // OrphanLogMatchesAny reports whether the receiver is a log-only row — a LOG rule // whose action partner is gone, carried as Log with no action — belonging to a // logged rule named by one of targets. A logged rule is stored as a LOG row plus // the action row under it; when the action row is edited away by hand the stray // LOG row no longer reads back as a rule, and this is how a removal still claims // it. It reports false for any receiver that carries an action, so a complete // rule is matched by MatchesAny alone. func (r *Rule) OrphanLogMatchesAny(targets []*Rule) bool { if r.Action != ActionInvalid || !r.Log { return false } for _, t := range targets { if !t.Log { continue } // Compare against the target stripped of its action, which is the shape the // surviving LOG row encodes. tl := *t tl.Action = ActionInvalid if r.Equal(&tl, true) { return true } } return false } // 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) } // coversPorts reports whether an existing rule's port specs (have) cover a caller // rule's (want) — the port-axis analog of coversProtocol: every port want spans // lies inside a range have carries, so a list covers its elements and a range its // interior, never the reverse. Both sides are compared in coalesced canonical // form, so contiguous have ranges merge over a want range while a discrete have // set covers only the ports it names, never the gaps between them. An empty want // is always covered; an empty have covers only an empty want. func coversPorts(have, want []PortRange) bool { hc := coalescePortRanges(have) for _, w := range coalescePortRanges(want) { covered := false for _, h := range hc { if h.Start <= w.Start && w.End <= h.End { covered = true break } } if !covered { return false } } return true } // 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 } // portsNeutralized returns a copy of r with both port axes cleared, so the field // compare in covers does not re-test an axis coversPorts has already gated — a // port-list row must absorb its single-port cells. It mirrors protoNeutralized // for the port axes. func (r *Rule) portsNeutralized() *Rule { c := *r c.Port, c.Ports, c.SourcePort, c.SourcePorts = 0, nil, 0, nil 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 } } // 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 } // validate reports whether the filter rule is well formed independent of any // backend, mirroring NATRule.validate. Every backend's entry points call it first // so a fundamentally malformed rule fails uniformly: an ICMP type set on a // non-ICMP protocol, a port match with no port-carrying transport, or an // interface bound to the side its direction cannot see. It holds only checks // every backend shares; a backend-specific limit lives in that backend's // validateRule. func (r *Rule) validate() error { if err := r.checkICMPType(); err != nil { return err } // A port match requires a concrete port-carrying transport (tcp/udp/sctp); the // allowed set narrows per backend, but no backend can match a port with none. if r.PortNeedsConcreteProtocol() { return fmt.Errorf("a port requires a concrete transport protocol") } // An interface match must sit on the side the rule's direction can observe: a // packet an input rule matches has not yet been routed to an outgoing // interface, and one an output rule matches never arrived on an incoming one. // The forward direction sees both, so it accepts either, and the check is // strict on IsInput/IsOutput so a DirAny rule — authored in the inbound frame // and swapped per direction by expandDirections — is judged on its concrete // halves. Backends that cannot express an interface at all reject any // interface match separately. if r.IsOutput() && r.InInterface != "" { return fmt.Errorf("an input interface cannot be matched on an output rule") } if r.IsInput() && r.OutInterface != "" { return fmt.Errorf("an output interface cannot be matched on an input rule") } return nil } // 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 is the zero value of NATKind, meaning no translation; it is // rejected when authoring a NAT rule. 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 only the // concrete kinds NATKind.String emits. The sentinel "invalid" (NATInvalid) is // rejected, mirroring ParseAction, so callers cannot author a NAT rule with no // real kind; backup decoding round-trips it separately in NATKind.UnmarshalJSON. 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 } 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 NAT methods. type NATRule struct { // Kind is the translation to perform. Kind NATKind // Family is the IP family the rule targets. FamilyAny is resolved from the // translation target or a matched address when left unset. Family Family // Proto is the network protocol the rule matches. Proto Protocol // Interface matches the inbound interface for DNAT/Redirect and the // outbound interface for SNAT/Masquerade. Empty means any interface. Interface string // Source matches the packet's source address or CIDR, with the same // semantics as Rule.Source. Empty matches any source. Source string // Destination matches the packet's destination address or CIDR, with the // same semantics as Rule.Destination. Empty matches any destination. Destination string // Port is the single destination port the rule matches; Ports takes // precedence when non-empty. Port uint16 // Ports is a list of destination port ranges the rule matches. 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 the rule carries the library's configured // prefix, mirroring Rule.HasPrefix. It is derived on read and purely // informational. HasPrefix bool // Number is the rule's 1-based position within its nat chain, populated by // GetNATRules on backends that advertise Capabilities().RuleOrdering. It // mirrors the position argument of InsertNATRule and MoveNATRule. Number int // table records the container a container backend read this NAT rule from; // it backs 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 } // expandNATFamilies returns the concrete-family rows a NAT rule materializes // into: itself when it already targets one family, or an IPv4 row plus an IPv6 // row when it targets both. It is the NAT analog of expandFamilies. func expandNATFamilies(r *NATRule) []*NATRule { if r.impliedFamily() != FamilyAny { return []*NATRule{r} } v4, v6 := *r, *r v4.Family, v6.Family = IPv4, IPv6 return []*NATRule{&v4, &v6} } // filterNATFamiliesIPv6 returns the expandNATFamilies rows narrowed to the // families the backend enforces, the NAT analog of filterFamiliesIPv6: with // ipv6Enabled false, the IPv6 row of a family-agnostic rule is dropped rather // than written as a line the backend would never enforce. A row already pinned // to a concrete family keeps it, IPv6 included, so Restore can reproduce a // snapshot verbatim. func filterNATFamiliesIPv6(ipv6Enabled bool, r *NATRule) []*NATRule { rows := expandNATFamilies(r) if ipv6Enabled || len(rows) == 1 { return rows } kept := make([]*NATRule, 0, 1) for _, row := range rows { if row.Family != IPv6 { kept = append(kept, row) } } return kept } // 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") } 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. A FamilyAny rule covers either family and a concrete one covers // only itself; a match-port list or range covers the ports it contains. NAT has no // direction axis and a translation applies per transport, so family and ports are // the only axes a NAT rule spans. func (r *NATRule) Covers(o *NATRule) bool { return coversFamily(r.impliedFamily(), o.impliedFamily()) && coversPorts(r.PortSpecs(), o.PortSpecs()) && r.portsNeutralized().EqualBase(o.portsNeutralized()) } // portsNeutralized returns a copy of r with its match ports cleared, so the field // compare in Covers does not re-test an axis coversPorts has already gated. It // mirrors Rule.portsNeutralized. func (r *NATRule) portsNeutralized() *NATRule { c := *r c.Port, c.Ports = 0, nil return &c } // 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; a match-port list requires every port to be covered, whether by one // list rule or by a row per port on a backend that fans lists out. 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 NAT rules the receiver spans: the cross product of // its family and match-port expansions, the NAT analog of Rule.cells. func (r *NATRule) cells() []*NATRule { fams := []*NATRule{r} if r.impliedFamily() == FamilyAny { v4, v6 := *r, *r v4.Family, v6.Family = IPv4, IPv6 fams = []*NATRule{&v4, &v6} } var out []*NATRule for _, fam := range fams { out = append(out, expandNATPorts(fam)...) } return out } // 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/MoveNATRule 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). // //nolint:unused // only the pf backend needs it, and the authoritative `unused` run is GOOS=linux. func numberNATSequential(rules []*NATRule) { for i, r := range rules { r.Number = i + 1 } } // splitNATDualRow is splitDualRow's NAT analog: the copy of a genuine // dual-family NAT row (a single stored translation with no family pin) that a // backend re-adds after deleting it for a concrete-family removal, pinned to // the family the caller did NOT target. It returns nil when no split applies. // Family is the only axis a NAT rule spans. func splitNATDualRow(matched, target *NATRule) *NATRule { tf := target.impliedFamily() if tf == FamilyAny || matched.impliedFamily() != FamilyAny { return nil } opp := *matched opp.Family = oppositeFamily(tf) return &opp } // 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 } // 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 // IPv6 is true when the backend manages IPv6 rules at all. It is host-resolved // on the backends whose own configuration can disable IPv6 (csf, apf) or whose // packaging can omit it (iptables); a false IPv6 means every IPv6 rule shape, // ICMPv6 included, is unmanageable, not merely rejected on one axis. (IPv4 is // managed by every backend, so it is not advertised as a capability.) IPv6 bool // PortPair is true when a source-port match can be combined with a // destination-port match in one rule. A false PortPair means such a rule is // rejected with ErrUnsupportedSourcePort (a firewalld rich rule carries a // single port element). PortPair 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 is true when per-rule packet logging is honored. Logging bool // RateLimit is true when per-rule rate limiting is honored. RateLimit bool // ConnLimit is true when per-rule connection limiting is honored. 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/MoveNATRule. 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 // Negation is true when a "!"-negated Source/Destination match is honored. // A false Negation means such a rule is rejected with ErrUnsupported (WFP // has no negated address condition). Negation bool // RejectAction is true when the Reject action — refuse with an error // response, as opposed to a silent Drop — is expressible. A false // RejectAction means a Reject rule is rejected with ErrUnsupported (WFP // only permits or blocks). RejectAction bool // FamilyWithoutAddress is true when a rule with a concrete Family but no // Source/Destination is expressible. A false value means family scoping // rides on addresses alone and such a rule is rejected with ErrUnsupported // (a WFP filter scopes family through its address conditions). FamilyWithoutAddress bool // DenyActionFromConfig is true when the backend's native deny store carries // no per-entry action — the firewall tool applies the deny action its own // config names (csf.conf DROP, conf.apf ALL_STOP). A deny added with the // config's action is stored natively and reads back with it; one with a // differing action is expressed elsewhere (the raw-iptables hook). Because // the native entry encodes no action, RemoveRule clears it whatever action // the removal target names. DenyActionFromConfig 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 // MoveNATRule moves an existing NAT rule to the given position within its nat // chain. position uses 1-based indexing; a non-positive position is treated as // 1, and a position larger than the chain's current rule count moves the rule // to the end. Backends that do not support ordered rules return an error; // backends without NAT support return the NAT sentinel. MoveNATRule(ctx context.Context, zoneName string, rule *NATRule, position int) 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. Backends // that cannot manage address sets return an unsupported error. 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 } // 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 } outputSupported := 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, outputSupported) { 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 over-count added. var toAdd []*Rule for _, d := range desired { if d.coveredBy(kept, outputSupported) || d.coveredBy(toAdd, outputSupported) { continue } toAdd = append(toAdd, d) } // Count additions as they land so the reported added stays accurate when a // later add errors after several have already been applied. for _, r := range toAdd { if err := mgr.AddRule(ctx, zoneName, r); err != nil { return added, removed, err } added++ } return added, removed, nil }