Skip to content

Rule Processor Developer Documentation

This document is the per-processor reference. For the surrounding orchestration (driver, configlets, script assembly, end-to-end flow), see Compilation Pipeline first.

The Python implementation follows the same architecture as the original C++ fwbuilder code. Legacy C++ source: src/libfwbuilder/src/fwcompiler/ (upstream reference).


Architecture Overview

The compilation pipeline is a chain of BasicRuleProcessor objects. Each processor pulls rules from its predecessor via get_next_rule(), transforms them, and pushes results into its own tmp_queue. Execution is pull-based: run_rule_processors() calls process_next() on the last processor, which recursively pulls from all predecessors.

Class hierarchy

Python uses the same class hierarchy as the C++ original:

BasicRuleProcessor          (_rule_processor.py)
├── PolicyRuleProcessor     (get_next() returns PolicyRule)
├── NATRuleProcessor        (get_next() returns NATRule)
└── RoutingRuleProcessor    (get_next() returns RoutingRule)

All concrete processors inherit from one of the typed subclasses and override process_next().

C++ historical context: C++ used DECLARE_POLICY_RULE_PROCESSOR macros to declare processors. In Python, processors are plain subclasses:

python class MyProcessor(PolicyRuleProcessor): def process_next(self) -> bool: rule = self.prev_processor.get_next_rule() ...

Core data structures

Member Python Purpose
tmp_queue collections.deque Output buffer. Processors push transformed rules here.
prev_processor BasicRuleProcessor Upstream processor (data source).
compiler Compiler Context pointer — gives access to fw, dbcopy, options, etc.
do_once bool Guard for slurp() — ensures it only pulls once.

Pull-based execution model

The pipeline runs by repeatedly calling process_next() on the last processor in the chain. Each processor calls prev_processor.get_next_rule() to pull one rule from upstream:

run_rule_processors():
    link each processor to its predecessor
    while (last_processor.process_next()) ;

get_next_rule():
    while (tmp_queue is empty AND process_next() returns true) ;
    if tmp_queue is empty: return None
    else: pop front of tmp_queue and return it

process_next():                    # each processor implements this
    rule = prev_processor.get_next_rule()   # pull one rule
    if rule is None: return False
    ... transform rule ...
    tmp_queue.append(rule)                  # push result(s)
    return True

This means a rule only flows through the chain when the final processor demands it — processors that drop a rule simply don't push to tmp_queue, and splitting processors push multiple rules for one input.

The slurp() method

Some processors need the entire rule set at once (e.g. DetectShadowing, PrintTotalNumberOfRules). They call slurp() instead of get_next_rule():

def slurp(self) -> bool:
    if not self.do_once:
        while (rule := self.prev_processor.get_next_rule()) is not None:
            self.tmp_queue.append(rule)
        self.do_once = True
        return len(self.tmp_queue) != 0
    return False  # subsequent calls return False immediately

After slurping, the processor can iterate tmp_queue freely. On the next call from get_next_rule(), the buffered rules drain out one at a time.

Processor categories

  • Source — injects rules into the pipeline (Begin)
  • Splitting — one input rule produces multiple output rules (e.g. expand groups, atomize, negation expansion)
  • Filtering — rules may be dropped (e.g. drop empty rule elements, drop wrong address family)
  • Transforming — rules are modified in place (e.g. set chain, set target)
  • Validation — rules are checked for errors (may abort compilation)
  • Pass-through — rule passes unchanged; side effects only (e.g. print progress, count rules)
  • Output — rules are converted to platform-specific text

Key rule properties (iptables)

Processors communicate through string properties stored on the Rule object:

Property Set by Read by Values
ipt_chain finalizeChain, decideOnChain*, setChain* PrintRule, countChainUsage, removeFW INPUT, OUTPUT, FORWARD, PREROUTING, POSTROUTING, user-defined chain name
ipt_target decideOnTarget PrintRule, countChainUsage ACCEPT, DROP, REJECT, RETURN, QUEUE, .CONTINUE, .CUSTOM, chain name
stored_action storeAction PrintRule Original action string before later processors modify it
originated_from_a_rule_with_tagging storeAction splitIfSrcAny, chain processors true if original rule had tagging
originated_from_a_rule_with_classification storeAction chain processors true if original rule had classification
originated_from_a_rule_with_routing storeAction chain processors true if original rule had routing
single_object_negation SingleSrc/Dst/SrvNegation PrintRule true — use ! prefix instead of chain-based negation
ipt_multiport prepareForMultiport PrintRule true — use -m multiport module
action_on_reject fillActionOnReject PrintRule tcp-reset, ICMP unreachable type, etc.

Base Processors (platform-independent)

These live in src/firewallfabrik/compiler/processors/ and are reusable by all compiler backends.

C++ reference: src/libfwbuilder/src/fwcompiler/

Compiler utilities

Begin (line 310 / 733) — Source

Injects rules from source_ruleset into the pipeline. On first call, iterates the source ruleset and for each rule:

  1. Skips disabled rules and dummy rules (with warning).
  2. Creates a copy in compiler->dbcopy and adds it to compiler->temp_ruleset.
  3. Pushes the copy to tmp_queue.
  4. Sets init = true and returns true.

On subsequent calls returns false immediately. All downstream processors work with these copies, not the originals.

printTotalNumberOfRules (line 323 / 764) — Pass-through

Calls slurp() to buffer all upstream rules. If verbose mode is on, prints " processing N rules". Returns true once (rules then drain from buffer), false if no rules.

createNewCompilerPass (line 337 / 780) — Pass-through

Takes a pass_name constructor parameter. Calls slurp(), prints the pass name, and returns true. Creates a logical boundary between compilation phases — all rules are buffered and then re-released.

simplePrintProgress (line 349 / 838) — Pass-through

Pulls one rule at a time. If the rule's label differs from the previous one, prints " rule LABEL" (when verbose). Pushes the rule unchanged.

singleRuleFilter (line 631 / 818) — Filter

Used in single-rule compilation mode (-xp). Pulls each rule and checks: - If not in single_rule_mode: pushes rule through unchanged. - If in single_rule_mode: only pushes rules whose ruleset name matches single_rule_ruleset_name AND whose position matches single_rule_position. Other rules are silently dropped.

Always returns true (even when dropping) to keep pulling upstream.

Debug (line 621 / 791) — Pass-through

Calls slurp() to buffer all upstream rules. When rule_debug_on is true, prints a separator line with the previous processor's name, then for each rule matching debug_rule, calls debugPrintRule(). Automatically inserted after every processor by Compiler::add() when -xp is active (except after simplePrintProgress).

dropRuleWithEmptyRE (line 562 / 1564) — Filter

Pulls one rule and checks for empty (non-"any") rule elements: - PolicyRule: checks Src, Dst. - NATRule: checks OSrc, ODst, OSrv, TSrc, TDst, TSrv. - RoutingRule: checks RDst, RGtw, RItf.

If any required element is empty (was non-empty before upstream processing removed objects), the rule is dropped with an optional warning. Otherwise pushes through.

This processor appears multiple times in the pipeline — after each stage that can remove objects from rule elements (group expansion, address family filtering, address expansion, etc.).

checkForObjectsWithErrors (line 594 / 1411) — Validation

Pulls one rule and iterates all rule elements. For each object that has a .rule_error attribute set to true, calls compiler->abort() with the object's .error_msg. This propagates errors from MultiAddress objects that failed DNS resolution or other preprocessor steps.

DropIPv4Rules / DropIPv6Rules (line 534 / 545) — Filter

Both inherit from DropRulesByAddressFamilyAndServiceType. For each rule element:

  1. Removes addresses of the unwanted family via DropAddressFamilyInRE().
  2. Removes services incompatible with the family via DropByServiceTypeInRE() (checks isV4Only() / isV6Only() on each service).
  3. If a rule element that was non-empty becomes empty, drops the entire rule with a warning.

DropIPv4Rules removes IPv4 (for IPv6-only compilation); DropIPv6Rules removes IPv6 (for IPv4-only compilation).

Generic rule element processors

These are parameterized — they take a slot name (e.g. 'src') and operate on that specific rule element. Named convenience subclasses instantiate them for specific elements (see Convenience subclasses).

C++ reference: Compiler.h / Compiler.cpp

splitIfRuleElementMatchesFW (line 363 / 861) — Split

Splits rules that contain the firewall object in a specified rule element. For each object in the element that matches the firewall (by ID, parent_cluster_id, or complexMatch()):

  1. Creates a new rule with only that matching object in the element.
  2. Pushes the new rule to tmp_queue.

After extracting all matches, removes them from the original rule's element and pushes the original too. This ensures the firewall gets its own rule for proper chain assignment (OUTPUT for firewall-sourced, INPUT for firewall-destined, FORWARD for others).

singleObjectNegation (line 376 / 985) — Transform

Optimizes negation when a rule element has getNeg() == true and exactly one object. Instead of the expensive chain-based negation expansion, sets the single_object_negation boolean attribute on the rule and clears the negation flag. This lets PrintRule emit a simple ! prefix.

For interface elements (Itf, ItfInb, ItfOutb): always applies.

For address elements: only applies when the single address has exactly one inet address AND doesn't complexMatch() the firewall (which would need splitting first).

fullInterfaceNegationInRE (line 389 / 1036) — Transform

Expands a negated interface element into the explicit set of all other interfaces. Given "not eth0, eth1":

  1. Gets all firewall interfaces.
  2. Filters out: unprotected interfaces, loopback, bridge ports (unless bridging_fw), cluster interfaces.
  3. Removes the negated interfaces from the remaining set.
  4. Replaces the rule element contents with the remaining interfaces.
  5. Clears the negation flag.

Result: !{eth0, eth1} becomes {eth2, eth3}.

replaceClusterInterfaceInItfRE (line 402 / 1102) — Transform

For each interface in the rule element that belongs to a failover cluster:

  1. Looks up the FailoverClusterGroup.
  2. Calls getInterfaceForMemberFirewall() to find the real member interface.
  3. Replaces the cluster interface reference with the real interface.
  4. Sorts the element by name for deterministic output.

Must run before ItfNegation (which needs real interfaces).

eliminateDuplicatesInRE (line 434 / 1148) — Transform

Removes duplicate objects within a rule element. Iterates through the element, keeping only the first occurrence of each object (compared by ID via the equalObj functor, or a custom comparator). If duplicates were removed, clears and rebuilds the element with unique objects only.

recursiveGroupsInRE (line 450 / 1204) — Validation

For each group object in the rule element, recursively checks all children for circular references (a group containing itself, directly or indirectly). Aborts compilation with an error if recursion is detected.

emptyGroupsInRE (line 470 / 1258) — Filter/Transform

Detects groups with zero non-group children (recursively counted via countChildren()). Behavior depends on the ignore_empty_groups firewall option:

  • If true: removes the empty groups and issues a warning. If the rule element becomes "any" after removal, drops the entire rule with a warning (a match-nothing element is meaningless).
  • If false: aborts compilation with an error listing the empty groups.

swapMultiAddressObjectsInRE (line 486 / 1340) — Transform

Replaces compile-time MultiAddress objects (where isRunTime() == true) with their MultiAddressRunTime equivalents. Generates a stable ID by appending "_runtime" to the original object's string ID. Looks up or creates the runtime object in dbcopy. This allows platform-specific handling of DNS names and other dynamic address types.

expandMultipleAddressesInRE (line 499 / 1401) — Transform

Replaces Host and Firewall objects in a rule element with their individual interface addresses. Calls compiler->_expand_addr() which:

  1. Recursively expands address objects via _expand_addr_recursive().
  2. Skips loopback interfaces (unless the rule is attached to loopback).
  3. Skips bridge ports.
  4. Expands failover cluster interfaces to corresponding member interfaces.
  5. Filters by current address family (IPv4 vs. IPv6).
  6. Sorts results by address value for deterministic output.

ReplaceFirewallObjectWithSelfInRE (line 858 / 915) — Transform

Replaces explicit firewall object references with a DNSName object named "self" (source name "self"). This is used by platforms that support runtime self-identification. Looks up or creates the runtime DNSName in dbcopy. Should run after splitIfSrc/DstMatchesFw to ensure the firewall is isolated in its own rule.

replaceFailoverInterfaceInRE (line 606 / 1440) — Transform

Replaces cluster failover interfaces with real member interfaces. Handles both interfaces where isFailoverInterface() == true and those with the cluster_interface option set. For each, looks up the FailoverClusterGroup and gets the corresponding member interface via getInterfaceForMemberFirewall().

PolicyCompiler processors

C++ reference: PolicyCompiler.h / PolicyCompiler.cpp

InterfacePolicyRules (line 152 / 358) — Split

Associates rules with interfaces. If the Itf element is "any", pushes the rule unchanged. Otherwise, for each object in Itf:

  • If the object is an ObjectGroup: iterates its members, creating one rule per interface (validates each is actually an Interface).
  • If the object is an individual Interface: creates one rule with only that interface.

Each output rule has exactly one interface in its Itf element.

ExpandGroups (line 160 / 414) — Transform

Recursively expands all group objects in Src, Dst, and Srv. Calls compiler->expandGroupsInRuleElement() for each, which:

  1. Recursively replaces group references with their member objects.
  2. Skips MultiAddressRunTime objects (already handled).
  3. Checks address family compatibility.
  4. Sorts results alphabetically by name.
  5. Validates each expanded object is appropriate for the element type.

expandGroupsInSrv (line 165 / 431) — Transform

Same as ExpandGroups but only for the Srv element.

expandGroupsInItf (line 170 / 440) — Transform

Same as ExpandGroups but only for the Itf element.

ExpandMultipleAddresses (line 277) — Transform

Expands Host and Firewall objects in both Src and Dst to their individual interface addresses. Calls compiler->_expand_addr() on each.

addressRanges (line 283 / 472) — Split

Expands AddressRange objects in Src and Dst to equivalent network objects:

  • IPv4 ranges: converted to a set of networks via convertAddressRange().
  • IPv6 ranges: kept as-is (iptables supports -m iprange for IPv6).

Creates Network objects for each converted address and registers them with group_registry if present.

checkForZeroAddr (line 407 / 673) — Validation

Detects likely configuration errors:

  1. findHostWithNoInterfaces() — finds Host objects with no Interface children (can't have an address).
  2. findZeroAddress() — finds Address/Network/Host with address 0.0.0.0. Skips the "any" object, dynamic interfaces, unnumbered interfaces, and bridge ports. Also catches the pattern A.B.C.D/0 where A.B.C.D ≠ 0.0.0.0 (likely a /32 vs. /0 typo).

Aborts compilation if any are found.

checkForUnnumbered (line 299 / 718) — Validation

Calls compiler->catchUnnumberedIfaceInRE() on Src and Dst. Aborts if any interface is unnumbered or a bridge port (these can't be used as addresses in rules).

ConvertToAtomicForAddresses (line 310 / 733) — Split

Creates the cartesian product of Src × Dst. For each (src_obj, dst_obj) pair, creates a new rule with exactly one object in Src and one in Dst. Srv is left unchanged (may still have multiple objects).

ConvertToAtomicForIntervals (line 316 / 762) — Split

Splits rules so each has exactly one Interval object. If the Interval element is "any" or missing, pushes the rule unchanged. Otherwise creates one rule per interval.

ConvertToAtomic (line 321 / 790) — Split

Full atomic conversion: creates the cartesian product of Src × Dst × Srv. Each output rule has exactly one object in each element. Used in the shadowing detection pass where exact comparison is needed.

MACFiltering (line 509 / 980) — Transform

Removes physAddress objects from Src and Dst (MAC filtering is unsupported on most platforms). Issues a warning if any were removed. Aborts if a rule element becomes empty after removal (means the rule only matched on MAC).

DetectShadowing (line 449 / 891) — Validation

Uses slurp() to load the entire ruleset. For each rule (skipping fallback and hidden rules):

  1. Calls find_more_general_rule() to check all previously seen rules.
  2. If a more general rule is found (and they have different absolute rule numbers and aren't identical), aborts with an error showing which rule shadows which.
  3. Adds the current rule to rules_seen_so_far.

Also has a variant DetectShadowingForNonTerminatingRules that detects when a non-terminating rule (Continue) shadows a terminating rule above it.

Generic service processors

These processors operate on the Srv (or OSrv for NAT) rule element.

C++ reference: Compiler.h / Compiler.cpp

groupServicesByProtocol (line 655) — Split

Inherits from groupServices, which splits rules with multiple services into groups based on a virtual groupingCode(). For groupServicesByProtocol, the grouping code is srv->getProtocolNumber(). Result: each output rule has services of the same protocol.

If the rule has only one service, it passes through unchanged.

separateTCPWithFlags — Split

Inherits from separateServiceObject. Separates TCP services that have TCP flags set into individual rules. Condition: TCPService::isA(srv) && has flags.

separatePortRanges — Split

Separates TCP/UDP services where source and destination port ranges are mismatched (can't be combined in a single -m multiport match).

separateSrcPort — Split

Separates TCP/UDP services that have source port specifications into individual rules (source ports need separate --sport matches).

separateUserServices — Split

Separates UserService objects (iptables --uid-owner match) into individual rules (only valid in OUTPUT chain).

verifyCustomServices — Validation

For each CustomService in the Srv element, checks that getCodeForPlatform(compiler->myPlatformName()) is non-empty. Throws FWException if a custom service has no code for the target platform.

CheckForTCPEstablished — Validation

Aborts if any TCPService in Srv has getEstablished() == true (the "established" flag is not supported by the iptables platform — stateful matching is done via conntrack instead).

Compiler-level convenience subclasses

Many generic processors are instantiated as named subclasses for specific rule elements. These are defined in the Compiler and PolicyCompiler headers:

Subclass Base processor Rule element Python
eliminateDuplicatesInSRC eliminateDuplicatesInRE Src compiler/processors/_generic.py
eliminateDuplicatesInDST eliminateDuplicatesInRE Dst compiler/processors/_generic.py
eliminateDuplicatesInSRV eliminateDuplicatesInRE Srv compiler/processors/_generic.py
recursiveGroupsInSrc recursiveGroupsInRE Src
recursiveGroupsInDst recursiveGroupsInRE Dst
recursiveGroupsInSrv recursiveGroupsInRE Srv
emptyGroupsInSrc emptyGroupsInRE Src compiler/processors/_generic.py:EmptyGroupsInRE('...', 'src')
emptyGroupsInDst emptyGroupsInRE Dst compiler/processors/_generic.py:EmptyGroupsInRE('...', 'dst')
emptyGroupsInSrv emptyGroupsInRE Srv compiler/processors/_generic.py:EmptyGroupsInRE('...', 'srv')
emptyGroupsInItf emptyGroupsInRE Itf compiler/processors/_generic.py:EmptyGroupsInRE('...', 'itf')
swapMultiAddressObjectsInSrc swapMultiAddressObjectsInRE Src
swapMultiAddressObjectsInDst swapMultiAddressObjectsInRE Dst
ExpandMultipleAddressesInSrc expandMultipleAddressesInRE Src ❌ (single ExpandMultipleAddresses does both)
ExpandMultipleAddressesInDst expandMultipleAddressesInRE Dst ❌ (single ExpandMultipleAddresses does both)
splitIfSrcMatchesFw splitIfRuleElementMatchesFW Src platforms/iptables/_policy_compiler.py
splitIfDstMatchesFw splitIfRuleElementMatchesFW Dst platforms/iptables/_policy_compiler.py
singleObjectNegationItf singleObjectNegation Itf ❌ (ItfNegation handles single-object inline)
ItfNegation fullInterfaceNegationInRE Itf ⚠️ platforms/iptables/_policy_compiler.py (see above)
replaceClusterInterfaceInItf replaceClusterInterfaceInItfRE Itf compiler/processors/_generic.py

Common implementation patterns

These patterns recur throughout the processor implementations:

Pattern 1: Safe iteration with deferred modification

# Collect objects to remove first, then modify (avoids mutation during iteration)
to_remove = [obj for obj in rule.src if condition(obj)]
for obj in to_remove:
    rule.src.remove(obj)

Pattern 2: Creating duplicate rules (splitting)

new_rule = rule.duplicate()
new_rule.src = [object]  # set single object
self.tmp_queue.append(new_rule)

Pattern 3: Accessing typed objects in rule elements

for obj in rule.src:
    if isinstance(obj, Address):
        # process address
        ...

C++ reference: The C++ equivalents use FWReference::getObject(), RuleElement::cast(), and Rule::duplicate() with explicit memory management.


iptables Processors

These live in src/firewallfabrik/platforms/iptables/ and are specific to the iptables/ip6tables backend.

C++ reference: src/iptlib/

The compiler object is PolicyCompiler_ipt which provides: - ipv6 flag — whether compiling for ip6tables - my_table — current table (filter or mangle) - minus_n_commands — tracks created chains (for deduplication) - chain_usage_counter — tracks per-chain rule counts

Table filtering

dropMangleTableRules (h:158 / cpp:781) — Filter

Filters rules based on which table is being compiled. When compiling for filter table, drops rules that need mangle (tagging, routing, or classification with action Continue). When compiling for mangle table, drops rules that don't need mangle. Also drops Branch rules whose target ruleset only needs the other table.

checkActionInMangleTable (h:164 / cpp:817) — Validation

Aborts if action == Reject in the mangle table. The REJECT target is only valid in the filter table in iptables.

checkForUnsupportedCombinationsInMangle (h:180 / cpp:841) — Validation

Aborts if a mangle table rule combines Route + (Tag or Classify) with a non-Continue action. This combination is problematic because the first target (e.g. MARK) jumps to a chain ending with ACCEPT, preventing the second target (e.g. CLASSIFY) from being reached.

Action and metadata storage

storeAction (h:211 / cpp:892) — Transform

Preserves original rule metadata before later processors modify it. Stores:

Stored property Source
stored_action rule->getActionAsString()
originated_from_a_rule_with_tagging rule->getTagging()
originated_from_a_rule_with_classification rule->getClassification()
originated_from_a_rule_with_routing rule->getRouting()

These are read later by chain selection and printing processors.

deprecateOptionRoute (h:187 / cpp:862) — Validation

Aborts if rule->getRouting() is true. The ROUTE target was removed from major Linux distributions and is no longer supported.

Logging

Logging1 (h:235 / cpp:880) — Transform

If the global firewall option log_all is true, sets rule->setLogging(true) on every rule. Simple global override.

Logging2 (h:241 / cpp:911) — Split

The complex logging processor. When logging is enabled on a rule:

Case 1: Action is Continue with no tagging/classification/routing. - Sets ipt_target to "LOG" and pushes the rule as-is (the rule itself becomes the log rule).

Case 2: All other logged rules. Creates up to 3 rules using an intermediate user-defined chain:

  1. Jump rule: matches Src/Dst/Srv/Itf, jumps to temp chain. Logging and limits are cleared (they apply in the chain, not here).
  2. LOG rule: in the temp chain, Src/Dst/Srv reset to "any" (already matched by the jump), target set to LOG with action Continue.
  3. Action rule: in the temp chain, Src/Dst reset to "any", carries the original action and target. Srv is preserved for --reject-with tcp-reset which needs the protocol.

clearLogInMangle (h:257 / cpp:570) — Transform

Sets logging = false for rules in the mangle table (unless the rule's ruleset is mangle-only). Prevents duplicate log entries when a rule generates output in both filter and mangle tables.

Interface and direction

InterfaceAndDirection (h:417 / cpp:1677) — Transform

Guarantees every rule has valid interface and direction values:

  • If direction is undefined, sets it to Both.
  • If interface is "any" and direction is Both, sets the .iface property to "nil" (no -i / -o option in output).
  • If interface is "any" and direction is Inbound or Outbound, adds a wildcard interface "*" (becomes -i + or -o + in output).

splitIfIfaceAndDirectionBoth (h:422 / cpp:1855) — Split

If a rule has a specific interface (not "any") and direction Both, splits it into two rules: - One with direction Inbound (will get -i iface). - One with direction Outbound (will get -o iface).

Rules with direction already set to Inbound or Outbound pass through.

checkInterfaceAgainstAddressFamily (h:967 / cpp:4198) — Filter

Drops rules whose interface has no addresses matching the current address family (IPv4 vs. IPv6). For example, an IPv4-only interface in an IPv6 compilation is dropped.

Exceptions (always passed through): - Dynamic interfaces (address determined at runtime). - Unnumbered interfaces. - Bridge port interfaces. - Failover interfaces — checks the corresponding member interface instead.

Tag, Classify, Route (mangle table)

splitIfTagClassifyOrRoute (h:246 / cpp:587) — Split

If a rule has more than one of {tagging, classification, routing} and the Src/Dst/Srv/Itf are not all "any", creates an intermediate chain:

  1. Jump rule: matches all conditions, jumps to the temp chain. Logging and limits are cleared.
  2. One rule per option: in the temp chain, each option gets its own rule with action Continue (so control falls through to the next).

This is necessary because each option maps to a different iptables target (MARK, CLASSIFY, ROUTE), and only one target can be used per rule.

clearTagClassifyInFilter (h:251 / cpp:534) — Transform

When compiling the filter table, clears classification, routing, and tagging flags. These options are only valid in the mangle table.

clearActionInTagClassifyIfMangle (h:264 / cpp:550) — Transform

When in the mangle table and the rule has tagging or classification, switches the action to Continue. This prevents the rule from terminating (ACCEPT/DROP) before the mark/classify target can take effect.

setChainPreroutingForTag (h:447 / cpp:1708) — Transform

If the rule has tagging (or originated_from_a_rule_with_tagging), no chain is set yet, direction is Both or Inbound, and interface is "any": sets ipt_chain to PREROUTING.

setChainPostroutingForTag (h:452 / cpp:1760) — Transform

Same conditions as above but for direction Both or Outbound: sets ipt_chain to POSTROUTING. Used when tagging rules also have routing.

setChainForMangle (h:457 / cpp:1793) — Transform

If in the mangle table and no chain is set: - Direction InboundPREROUTING - Direction OutboundPOSTROUTING - Direction Both → default FORWARD, then upgrade to PREROUTING based on action/direction heuristics.

splitIfTagAndConnmark (h:468 / cpp:1823) — Split

If the action is Tag and the CONNMARK option (ipt_mark_connections) is activated, splits into separate rules: one for MARK and one for CONNMARK (save/restore). These are different iptables targets that must be separate rules.

checkForRestoreMarkInOutput (h:462 / cpp:1777) — Transform

If a tagging rule uses CONNMARK and the chain is OUTPUT, sets the have_connmark_in_output flag on the compiler. This flag triggers generation of a CONNMARK restore-mark rule in the OUTPUT chain during the addPredefinedRules phase.

Negation

iptables has limited negation support — you can negate a single object with !, but negating a set requires chain-based expansion. The processors below handle both cases.

SingleSrcNegation / SingleDstNegation / SingleSrvNegation (h:306-320) — Transform

Handle the optimized case: when a rule element has negation and exactly one object. Sets the single_object_negation boolean attribute and clears the element's negation flag. PrintRule later emits a ! prefix.

Additional checks: - For AddressTable objects with ipset support: always applies (ipset supports ! --match-set). - For address objects: only applies when the address has exactly one inet address and doesn't complexMatch() the firewall. - For TagService / UserService: always applies.

SrcNegation (h:345 / cpp:1155) — Split

Expands multi-object negation in Src using an intermediate chain. Takes a shadowing_mode constructor parameter.

Creates 3 rules:

  1. Jump rule: (any, dst, srv, itf) → temp_chain. Logging and limits cleared (matching happens in original chain, action in temp chain).
  2. RETURN rule: (original_src, any, any, any) → RETURN in temp chain. Stateless, no limits. This rule matches traffic that should be excluded (the negated set) and returns to the calling chain.
  3. Action rule: moved to temp chain with original action. Src/Dst/Srv reset to "any" (already matched). Srv is preserved if action is Reject with TCP RST (needs protocol info).

In shadowing_mode, Dst/Srv/Interval are preserved in the jump rule instead of being reset (needed for accurate shadowing comparison).

DstNegation (h:362 / cpp:1285) — Split

Mirror of SrcNegation but for the Dst element.

SrvNegation (h:379 / cpp:1421) — Split

Same pattern for the Srv element.

TimeNegation (h:396 / cpp:1537) — Split

Same pattern for the time Interval element.

Splitting on Src/Dst = any

These processors handle the critical case where "any" may or may not include the firewall itself (controlled by the firewall_is_part_of_any_and_networks option).

splitIfSrcAny (h:485 / cpp:2170) — Split

If Src is "any" (or has single_object_negation set) and direction is not Inbound:

  1. Creates a copy with ipt_chain = OUTPUT, direction = Outbound. Dst/Srv/Interval are reset to "any" in the copy (these conditions are checked in the FORWARD rule).
  2. For mangle table with classification: creates an additional copy with ipt_chain = POSTROUTING.
  3. The original rule remains for the FORWARD chain.

Skips if: firewall_is_part_of_any_and_networks is false, has_output_chain flag is already set, chain is already assigned, or bridging firewall with bridge port interfaces (can't use --physdev-out in OUTPUT chain).

splitIfDstAny (h:490 / cpp:2255) — Split

Mirror of splitIfSrcAny for Dst:

  1. Creates a copy with ipt_chain = INPUT.
  2. For mangle with classification: additional PREROUTING copy.
  3. Original remains for FORWARD.

splitIfSrcAnyForShadowing / splitIfDstAnyForShadowing (h:544-550) — Split

Variants for the shadowing detection pass. Same logic but don't reset Dst/Srv/Interval in the split copies (preserves full match criteria for accurate shadowing comparison).

Splitting on firewall matches

splitIfSrcMatchesFw / splitIfDstMatchesFw — Split

Inherited from base splitIfRuleElementMatchesFW. Splits rules where the firewall object appears among other objects in Src or Dst. Each occurrence of the firewall gets its own rule for proper chain assignment.

splitIfSrcFWNetwork (h:569 / cpp:2528) — Split

Splits when Src is a network that the firewall is on. The firewall is a member of this network, so traffic could be both to/from the firewall and forwarded. Creates: - A FORWARD rule (network without the firewall). - An INPUT rule (firewall only).

splitIfDstFWNetwork (h:575 / cpp:2601) — Split

Mirror for Dst. Creates FORWARD + OUTPUT rules.

splitIfSrcNegAndFw (h:588 / cpp:2011) — Split

Handles the special case where Src has negation AND contains the firewall AND direction is not Inbound. The firewall must be split out before general negation expansion:

  1. Creates an OUTPUT rule with only the firewall objects in Src.
  2. Original rule keeps the non-firewall objects with negation flag preserved.

splitIfDstNegAndFw (h:593 / cpp:2089) — Split

Mirror for Dst with direction not Outbound.

splitIfSrcMatchingAddressRange / splitIfDstMatchingAddressRange (h:496-502) — Split

Splits when an AddressRange in Src/Dst includes the firewall's address. Checks if the range's start/end encompasses any firewall interface address. If so, splits into OUTPUT/INPUT + FORWARD rules similar to the network case.

Address range handling

specialCaseAddressRangeInSrc / specialCaseAddressRangeInDst (h:518-525) — Transform

If an AddressRange represents a single address (dimension == 1), replaces it with a simple IPv4 address object. This avoids the overhead of -m iprange for what's effectively a host match.

Chain selection

These processors progressively determine the ipt_chain property. They run in a specific order — earlier processors handle special cases, and finalizeChain provides the default.

decideOnChainIfSrcFW (h:734 / cpp:3081) — Transform

If Src matches the firewall (not an AddressRange): - Direction Outbound → chain = OUTPUT. - Direction Both → chain = OUTPUT, direction changed to Outbound.

For bridging firewalls: splits rules where the firewall is on a bridge port interface, putting the split copy in FORWARD.

decideOnChainIfDstFW (h:740 / cpp:3182) — Transform

If Dst matches the firewall or a cluster member: - Direction Inbound → chain = INPUT. - Direction Both → chain = INPUT, direction changed to Inbound.

decideOnChainIfLoopback (h:764 / cpp:3307) — Transform

For loopback interface with Src = "any" and Dst = "any" and no chain set: - Direction Inbound → chain = INPUT. - Direction Outbound → chain = OUTPUT.

decideOnChainForClassify (h:769 / cpp:3351) — Transform

If classification is enabled and no chain is set: - If also tagging: creates a separate rule for tagging without classification (action = Continue). - Sets chain to POSTROUTING (CLASSIFY target only works there).

finalizeChain (h:782 / cpp:3384) — Transform

The last-resort chain assignment. If no chain has been set:

  1. Defaults to FORWARD.
  2. Mangle table: sets PREROUTING (inbound) or POSTROUTING (outbound). Special handling for ACCEPT action based on direction.
  3. Filter table: checks if Src/Dst matches the firewall to upgrade to INPUT/OUTPUT.
  4. Drops FORWARD rules if ip_forward is disabled on the firewall (warns the user).

Target selection

decideOnTarget (h:787 / cpp:3506) — Transform

Maps the rule's action to an iptables target:

Rule action ipt_target
Accept ACCEPT
Deny DROP
Reject REJECT
Return RETURN
Pipe QUEUE
Continue .CONTINUE (pseudo-target — no -j in output)
Custom .CUSTOM
Branch target ruleset name

For tagging rules: target is set to MARK, CONNMARK, or CLASSIFY depending on the specific options. For routing: ROUTE.

Firewall object handling

removeFW (h:798 / cpp:3566) — Transform

Strips redundant firewall object references after chain assignment:

  • Chain = INPUT (or descendant): removes firewall from Dst (redundant — INPUT already implies destination is the firewall).
  • Chain = OUTPUT (or descendant): removes firewall from Src.

Skips if the rule has virtual NAT addresses or upstream negation (upstream_rule_neg flag).

specialCaseWithFW1 (h:636 / cpp:2720) — Split

Handles rules where the firewall appears in both Src and Dst. Splits into separate rules so each can be assigned to the correct chain (OUTPUT for src=fw, INPUT for dst=fw).

specialCaseWithFW2 (h:652 / cpp:2853) — Transform

After specialCaseWithFW1, expands Src and Dst to interface addresses including loopback. The standard _expand_addr skips loopback, but firewall-to-firewall traffic (e.g. a service listening on localhost) needs it.

specialCaseWithFWInDstAndOutbound (h:645 / cpp:2761) — Split

Splits if the firewall is in Dst with a specific interface and direction Outbound. This is an impossible combination (outbound to self?) — splits into an INPUT rule instead.

Multi-address and interface expansion

expandMultipleAddressesIfNotFWinSrc / ...Dst (h:709-710 / cpp:3013-3024) — Transform

Expands multi-address objects (Host, Firewall) in Src/Dst to their individual interface addresses — except if the object is the firewall itself. The firewall is kept intact so that removeFW can strip it later (expanding would lose the identity needed for chain-based removal).

expandLoopbackInterfaceAddress (h:718 / cpp:3060) — Transform

Replaces loopback interface object references with the actual loopback address. The standard _expand_addr skips loopback to avoid polluting normal rules, but by this point loopback-specific rules have been isolated and need the real address.

processMultiAddressObjectsInSrc / ...Dst (h:619-626) — Split

Splits rules containing MultiAddress objects. Each MultiAddress gets its own rule. This ensures runtime-resolved addresses (DNS names, address tables) are handled independently.

specialCaseWithUnnumberedInterface (h:672 / cpp:2931) — Transform/Filter

Handles unnumbered interfaces (interfaces with no IP address):

  • Direction Inbound: drops unnumbered/bridge interfaces from Src.
  • Direction Outbound in OUTPUT chain: drops from Dst.
  • Direction Outbound in other chains: drops from Src.

These interfaces can't be matched by address, so address-based rule elements referencing them are meaningless.

checkForDynamicInterfacesOfOtherObjects (h:681 / cpp:2998) — Validation

For each dynamic interface in Src/Dst, verifies it belongs to the firewall being compiled (or is a failover interface of the correct cluster). A dynamic interface of another object can never be resolved: the generated script asks the host it runs on, which knows only its own interfaces. The rule is reported and left out on both platforms and in both pipelines — the NAT counterpart is compiler/processors/_generic.py:NATCheckForDynamicInterfacesOfOtherObjects, shared by the two NAT compilers.

InterfacePolicyRulesWithOptimization (h:276 / cpp:702) — Split

One rule per interface named in the Itf element, and nothing else: the name says "with optimization" but the body writes a subrule_suffix of i1 onto each copy and no chain. That suffix only reaches getNewChainName, which Logging2 and accounting call earlier in the pass than this processor runs, so it names nothing — it is dead upstream as well. The chain that factors a rule body out is splitIfTagClassifyOrRoute, which is a different processor; earlier notes here conflated the two.

Reject handling

fillActionOnReject (h:804 / cpp:3668) — Transform

If the rule option action_on_reject is empty, copies the default from the global firewall option. This ensures every Reject rule has an explicit reject type (e.g. icmp-port-unreachable, tcp-reset).

splitRuleIfSrvAnyActionReject (h:824 / cpp:3683) — Split

If action is Reject with --reject-with tcp-reset and Srv is "any" (or includes both TCP and non-TCP services):

  1. Creates a rule for non-TCP services with action_on_reject cleared (uses default ICMP unreachable).
  2. Creates a rule for TCP services only, preserving the tcp-reset option.

This is necessary because tcp-reset only works with TCP protocol.

splitServicesIfRejectWithTCPReset (h:841 / cpp:3755) — Split

More granular version: separates TCP services from other services when reject-with-tcp-reset is active. Each protocol type gets its own rule with the appropriate reject method.

Service handling

groupServicesByProtocol — Split

(Defined in base Compiler.h.) Groups services by protocol number so each output rule has one protocol. This is required because iptables -p only accepts one protocol.

separateTCPWithFlags — Split

Splits TCP services with flags (SYN, ACK, FIN, etc.) into separate rules. TCP flags require the -m tcp --tcp-flags match which can only specify one flag combination per rule.

verifyCustomServices — Validation

Validates that CustomService objects have code for the iptables platform.

specialCasesWithCustomServices (h:867 / cpp:3920) — Transform

Handles known custom services that need special treatment (e.g. services that set specific match modules or protocols).

separatePortRanges — Split

Separates TCP/UDP services with port ranges that can't be combined in multiport (e.g. overlapping source/destination ranges).

separateUserServices — Split

Isolates UserService objects (iptables -m owner --uid-owner) into their own rules. Only valid in the OUTPUT chain.

separateSrcPort — Split

Splits services with source port specifications. Source and destination ports need separate match parameters (--sport vs --dport).

prepareForMultiport (h:921 / cpp:3837) — Split/Transform

Prepares services for the -m multiport module:

  • Single service: passes unchanged.
  • IP/ICMP/Custom services: each gets its own rule (can't use multiport).
  • ≤15 TCP/UDP services: sets the ipt_multiport flag for PrintRule.
  • >15 TCP/UDP services: splits into groups of 15 (multiport limit).

checkForStatefulICMP6Rules (h:855 / cpp:3717) — Validation

If a service is ICMPv6 and the rule is stateful (stateless == false), forces stateless = true and issues a warning. ICMPv6 should not be statefully tracked (it can break IPv6 neighbor discovery).

CheckForTCPEstablished — Validation

Aborts if a TCP service has the "established" flag set. iptables handles established connections via conntrack, not per-service flags.

Validation

checkMACinOUTPUTChain (h:811 / cpp:3613) — Validation

iptables cannot match on MAC source address in the OUTPUT chain (packets haven't been through the network stack yet). Warns and strips MAC addresses from OUTPUT rules.

checkUserServiceInWrongChains (h:817 / cpp:3645) — Validation

The -m owner --uid-owner match only works in the OUTPUT chain (matching the process that generated the packet). Warns if a UserService appears in INPUT or FORWARD.

SkipActionContinueWithNoLogging (h:974 / cpp:506) — Filter

Drops rules where target == .CONTINUE and there is no logging, tagging, or classification. Such rules produce no iptables output (no -j target and no -j LOG/-j MARK/-j CLASSIFY), so they are dead code.

Bridging

bridgingFw (h:429 / cpp:1959) — Transform

For bridging firewalls, ensures broadcast and multicast traffic goes to the FORWARD chain. Also handles --physdev module usage for bridge port interfaces.

convertAnyToNotFWForShadowing (h:284 / cpp:3973) — Transform

For the shadowing detection pass, when firewall_is_part_of_any_and_networks is true, converts "any" to "!fw" so shadowing analysis correctly accounts for the firewall being part of "any":

  1. Creates a RETURN rule matching the firewall.
  2. Modifies the original rule's Src/Dst to be !fw.

Optimization (PolicyCompiler_ipt_optimizer.cpp)

optimize1 (h:873 / optimizer:152) — Split

Reduces the number of rule element checks by creating sub-chains. Picks the element with the fewest objects (≤15) and splits on it:

  1. Jump rule: matches only the smallest element, all others set to "any", jumps to a temp chain. Stateful check and limits are disabled.
  2. Detail rule: in the temp chain, matches all conditions.

This is run 3 times in the pipeline for cascading optimization. Each pass can split on a different element.

Skips if: any element has ≤1 objects, or 3+ elements are "any" (not enough to optimize).

optimize2 (h:892 / optimizer:259) — Transform

If a rule is a "leaf" (in a user-defined chain, i.e. already filtered by a jump rule) and the action doesn't need protocol specificity (not Reject with TCP RST), sets Srv to "any". The protocol was already matched by the jump rule, so re-checking it is redundant.

optimize3 (h:898 / optimizer:290) — Filter

Removes duplicate rules. Converts each rule to its string representation via PrintRule, and drops rules that produce identical output. Uses a set<string> to track seen rules.

optimizeForMinusIOPlus (h:916 / optimizer:317) — Transform

Removes redundant interface matching: - Chain = INPUT and interface matches all (+): removes -i + (INPUT already implies inbound on all interfaces). - Chain = OUTPUT and interface matches all: removes -o +.

Accounting

accounting (h:959 / cpp:4111) — Transform

Processes rules with action = Accounting (NFACCT target):

  1. Gets accounting chain name from rule option (or generates one).
  2. If the accounting chain is the same as the rule's chain: sets target to RETURN.
  3. Otherwise: creates an intermediate accounting chain with a RETURN rule, and sets the rule's target to the chain name.

countChainUsage (h:980 / cpp:4173) — Transform

Counts how many rules reference each user-defined chain (via their ipt_target). Stores counts in compiler->chain_usage_counter. PrintRule later skips creation of chains with zero usage (dead chains from optimization or filtering).

Output generation

PrintRule (h:1068 / PrintRule.cpp:1553) — Output

The final processor. Generates iptables shell commands. For each rule:

  1. Checks chain_usage_counter — skips if the rule's chain has zero usage.
  2. Outputs rule label and comments via _printRuleLabel().
  3. Creates chains as needed via _createChain() (emits $IPTABLES -N chain).
  4. Delegates to OSConfigurator_linux24.print_run_time_wrappers() for the dynamic-interface shell wrapper (run_time_wrappers configlet): a for/test -n loop over the address list, or a getinterfaces loop for a wildcard interface. NATPrintRule does the same.
  5. Calls PolicyRuleToString() to assemble the actual command.

PolicyRuleToString() assembles the command in this order:

$IPTABLES -w -t <table> -A <chain>
    <direction_and_interface>     # -i/-o iface, or -m physdev
    <protocol>                    # -p tcp/udp/icmp/...
    <multiport_module>            # -m multiport (if ipt_multiport)
    <src_addr>                    # -s addr, -m iprange, -m set
    <src_service>                 # --sport port
    <dst_addr>                    # -d addr, -m iprange, -m set
    <dst_service>                 # --dport port, --icmp-type, etc.
    <state_match>                 # -m conntrack --ctstate NEW
    <time_interval>               # -m time --timestart/--timestop
    <modules>                     # -m limit, -m connlimit, -m hashlimit
    -j <target>                   # ACCEPT/DROP/REJECT/LOG/MARK/chain/etc.

Key helper methods:

Method Output
_printChain() Chain name (validated ≤30 chars)
_printDirectionAndInterface() -i/-o for regular interfaces, -m physdev --physdev-in/out for bridge ports
_printProtocol() -p tcp -m tcp, -p udp -m udp, -p icmp -m icmp, -p ipv6-icmp
_printSrcAddr() / _printDstAddr() -s/-d addr, or -m iprange --src/dst-range, or -m set --match-set
_printSrcService() / _printDstService() --sport/--dport ports, --sports/--dports for multiport, --icmp-type, --tcp-flags
_printTarget() -j TARGET with options: --reject-with, --set-mark, --set-class, LOG params
_printLogParameters() -j LOG --log-level --log-prefix or -j ULOG/NFLOG --nflog-group --nflog-prefix
_printTimeInterval() -m time --timestart HH:MM --timestop HH:MM --days Mon,Tue,..., or --datestart/--datestop for an interval that pins a calendar window (iptables 1.4.0 and up)
_printModules() -m limit --limit N/s, -m connlimit --connlimit-above N, -m hashlimit ...
_printActionOnReject() --reject-with tcp-reset, --reject-with icmp-port-unreachable, etc.
_printRuleLabel() Comment block: # Rule N (label)\n# description\necho "Rule N ..."\n
_createChain() $IPTABLES -N chainname (skipped if already created, tracked via minus_n_commands)
_printSingleObjectNegation() ! prefix for addresses/interfaces with single_object_negation

PrintRuleIptRst (h:1161 / PrintRuleIptRst.cpp:117) — Output

Variant that generates iptables-restore format instead of shell commands. Not ported: the choice is binary in fwf, so restore mode always takes the echo variant below. The C++ third case — a plain restore stream when no rule needs a shell variable — has no Python entry point.

PrintRuleIptRstEcho (h:1178 / PrintRuleIptRstEcho.cpp:79) — Output

Variant for iptables-restore with echo wrappers, and the only one fwf selects. Wraps every line in a shell echo so a rule can carry a variable — a run-time address table, a dynamic interface address — which a plain restore file cannot. The *filter / *nat and COMMIT markers come from the script_body_iptables_restore configlet, not from the printer.

iptables NAT Processors

These processors are specific to the iptables NAT compilation pipeline (NATCompiler_ipt). They handle interface negation, port translation, NONAT splitting, and address expansion for NAT rules.

SingleObjectNegationItfInb — Transform

Handles single-object negation for the inbound interface (ItfInb) element in NAT rules. If the element has negation and contains exactly one object, converts to inline ! negation by setting itf_inb_single_object_negation = True and clearing the negation flag.

C++: NATCompiler::singleObjectNegationItfInb

SingleObjectNegationItfOutb — Transform

Mirror of SingleObjectNegationItfInb for the outbound interface (ItfOutb). Sets itf_outb_single_object_negation = True when the element has negation and exactly one object.

C++: NATCompiler::singleObjectNegationItfOutb

PortTranslationRules — Transform

Copies ODst into TDst for port-only DNAT rules targeting the firewall. Triggers when nat_rule_type == DNAT, TSrc and TDst are both empty, TSrv is set, and ODst is the firewall. This allows SpecialCaseWithRedirect to detect and convert it to a Redirect rule downstream.

C++: NATCompiler_ipt::portTranslationRules

SpecialCaseWithRedirect — Transform

Converts DNAT rules to Redirect when TDst matches the firewall. After PortTranslationRules fills in TDst for port-only translations, this processor reclassifies the rule type to NATRuleType.Redirect, which changes the iptables target from DNAT --to-destination to REDIRECT --to-ports.

C++: NATCompiler_ipt::specialCaseWithRedirect

SplitNONATRule — Split

Splits NONAT rules into two: one for POSTROUTING and one for PREROUTING (or OUTPUT if OSrc is the firewall). NONAT rules need ACCEPT in both chains to prevent accidental translation by other rules. When OSrc is the firewall, the second copy goes to OUTPUT with OSrc cleared.

C++: NATCompiler_ipt::splitNONATRule

ReplaceFirewallObjectsODst — Transform

Replaces Firewall objects in ODst with the firewall's non-loopback Interface objects. Skips Masq and Redirect rule types. This prepares the rule for ExpandMultipleAddresses which expands interfaces to their addresses.

C++: NATCompiler_ipt::ReplaceFirewallObjectsODst

ReplaceFirewallObjectsTSrc — Transform

Replaces Firewall objects in TSrc with the interface facing ODst. For SNAT rules where TSrc is the firewall itself, finds the interface whose network contains the ODst address and uses that interface's address for the SNAT source. Falls back to all eligible (non-loopback, non-unnumbered, non-bridge-port) interfaces when ODst is "any" or no matching interface is found. When odst_single_object_negation is set, skips the direct match and uses the fallback (excluding the ODst-facing interface). Also excludes the OSrc-facing interface from the fallback set.

C++: NATCompiler_ipt::ReplaceFirewallObjectsTSrc

SingleObjectNegationOSrc — Transform

Handles single-object negation for OSrc in NAT rules. If OSrc has negation and contains exactly one address object that doesn't complexMatch() the firewall, converts to inline ! negation by setting osrc_single_object_negation = True and clearing the negation flag.

C++: NATCompiler::singleObjectNegationOSrc

SingleObjectNegationODst — Transform

Mirror of SingleObjectNegationOSrc for ODst. Sets odst_single_object_negation = True when the element has negation and exactly one address object that doesn't complexMatch() the firewall.

C++: NATCompiler::singleObjectNegationODst

SplitIfOSrcAny — Split

For DNAT rules where OSrc is "any" (or has single_object_negation) and the inbound interface is "any", creates a copy with OSrc set to the firewall object. This is part of the local_nat support — when local_nat and firewall_is_part_of_any_and_networks are both enabled, it ensures locally-originated DNAT traffic gets its own rule. Skips rules added for negation handling.

C++: NATCompiler_ipt::splitIfOSrcAny

SplitIfOSrcMatchesFw — Split

Splits rules where OSrc contains the firewall among other objects. Extracts firewall-matching objects into separate rules via complexMatch(). The original rule keeps the non-firewall objects.

C++: NATCompiler_ipt::splitIfOSrcMatchesFw

LocalNATRule — Transform

For DNAT/DNetnat/Redirect rules where OSrc matches the firewall, sets the chain to OUTPUT. If OSrc is the firewall object itself, clears OSrc to "any" (the OUTPUT chain already implies the firewall is the source).

C++: NATCompiler_ipt::localNATRule

ExpandMultipleAddresses (NAT) — Transform

Expands Host/Firewall/Interface objects in NAT element lists (OSrc, ODst, TSrc, TDst) to their Address objects. Expansion scope depends on rule type: NONAT/Return expand OSrc+ODst; SNAT/SDNAT/DNAT expand all four; Redirect expands OSrc+ODst+TSrc. Sorts results by address for deterministic output. Skips loopback interfaces when expanding from Host/Firewall. Lives in compiler/processors/_generic.py and is wired into both NAT pipelines.

C++: NATCompiler::ExpandMultipleAddresses

ClassifyNATRule (enhanced) — Transform

Enhanced version of the base ClassifyNATRule that handles TSrv port translation logic. In addition to classifying by TSrc/TDst presence, it checks whether TSrv translates source ports only, destination ports only, or both (comparing against OSrv to detect no-op translations where ports match). This affects SDNAT detection: TSrc + dst port translation or TDst + src port translation both classify as SDNAT.

C++: NATCompiler::classifyNATRule


Full pipeline order

C++ reference pipeline (PolicyCompiler_ipt::compile())

This is the full C++ pipeline order from PolicyCompiler_ipt.cpp:4291. The Python pipeline implements a subset of these processors — see Implementation status for the current status.

Shadowing detection pass (optional)

Begin → addRuleFilter → printTotalNumberOfRules → ItfNegation →
InterfacePolicyRules → convertAnyToNotFWForShadowing →
recursiveGroupsInSrc → recursiveGroupsInDst → recursiveGroupsInSrv →
ExpandGroups → dropRuleWithEmptyRE →
eliminateDuplicatesInSRC → eliminateDuplicatesInDST → eliminateDuplicatesInSRV →
swapMultiAddressObjectsInSrc → swapMultiAddressObjectsInDst →
ExpandMultipleAddressesInSrc → ExpandMultipleAddressesInDst → dropRuleWithEmptyRE →
ConvertToAtomic → SkipActionContinueWithNoLogging → checkForObjectsWithErrors →
DetectShadowing → simplePrintProgress

This pass converts every rule to fully atomic form (one object per Src/Dst/Srv) so that DetectShadowing can do exact superset comparisons. It uses ConvertToAtomic (the full cartesian product) and convertAnyToNotFWForShadowing to handle the "any includes firewall" case.

Main compilation pass

Begin → addPredefinedRules → addRuleFilter → printTotalNumberOfRules →
singleRuleFilter → deprecateOptionRoute → checkForUnsupportedCombinationsInMangle →
clearTagClassifyInFilter → clearLogInMangle → clearActionInTagClassifyIfMangle →
storeAction → Logging1 →
emptyGroupsInItf → expandGroupsInItf → replaceClusterInterfaceInItf →
singleObjectNegationItf → ItfNegation →
decideOnChainForClassify → InterfaceAndDirection → splitIfIfaceAndDirectionBoth →
recursiveGroupsInSrc → recursiveGroupsInDst → recursiveGroupsInSrv →
emptyGroupsInSrc → emptyGroupsInDst → emptyGroupsInSrv →
SingleSrvNegation → splitRuleIfSrvAnyActionReject → SrvNegation → expandGroupsInSrv →
CheckForTCPEstablished → fillActionOnReject → splitServicesIfRejectWithTCPReset →
fillActionOnReject → splitServicesIfRejectWithTCPReset →
SingleSrcNegation → SingleDstNegation →
splitIfSrcNegAndFw → splitIfDstNegAndFw →
SrcNegation → DstNegation → TimeNegation →
Logging2 → splitIfTagClassifyOrRoute → splitIfTagAndConnmark → Route →
ExpandGroups → dropRuleWithEmptyRE →
eliminateDuplicatesInSRC → eliminateDuplicatesInDST → eliminateDuplicatesInSRV →
swapMultiAddressObjectsInSrc → swapMultiAddressObjectsInDst →
accounting → splitIfSrcAny →
[checkActionInMangleTable if mangle] →
setChainForMangle → setChainPreroutingForTag → splitIfDstAny → setChainPostroutingForTag →
processMultiAddressObjectsInSrc → processMultiAddressObjectsInDst →
[addressRanges OR specialCaseAddressRange* + splitIfMatchingAddressRange*] →
dropRuleWithEmptyRE →
splitIfSrcMatchesFw → splitIfDstMatchesFw →
specialCaseWithFW1 → decideOnChainIfDstFW → splitIfSrcFWNetwork →
decideOnChainIfSrcFW → splitIfDstFWNetwork → specialCaseWithFW2 →
expandMultipleAddressesIfNotFWinSrc → expandMultipleAddressesIfNotFWinDst →
expandLoopbackInterfaceAddress → dropRuleWithEmptyRE →
InterfacePolicyRulesWithOptimization → checkInterfaceAgainstAddressFamily →
decideOnChainIfLoopback → finalizeChain →
specialCaseWithFWInDstAndOutbound → decideOnTarget →
checkForRestoreMarkInOutput → removeFW →
ExpandMultipleAddresses → dropRuleWithEmptyRE →
[DropIPv4Rules OR DropIPv6Rules] →
checkForUnnumbered → checkForDynamicInterfacesOfOtherObjects →
[bridgingFw if bridging] → specialCaseWithUnnumberedInterface →
optimize1 → optimize1 → optimize1 →
groupServicesByProtocol → separateTCPWithFlags → verifyCustomServices →
specialCasesWithCustomServices → separatePortRanges → separateUserServices →
separateSrcPort → checkForStatefulICMP6Rules →
optimize2 → prepareForMultiport →
ConvertToAtomicForAddresses → checkForZeroAddr → checkMACinOUTPUTChain →
checkUserServiceInWrongChains → ConvertToAtomicForIntervals →
optimize3 → optimizeForMinusIOPlus →
checkForObjectsWithErrors → countChainUsage →
PrintRule → simplePrintProgress

Pipeline phases (logical grouping)

The main compilation pass can be understood as these logical phases:

  1. InitializationBegin through Logging1. Injects rules, adds predefined rules, stores metadata, applies global logging override.

  2. Interface normalizationemptyGroupsInItf through ItfNegation. Expands interface groups, replaces cluster interfaces, handles interface negation.

  3. Direction splittingdecideOnChainForClassify through splitIfIfaceAndDirectionBoth. Sets up direction, splits "Both" rules.

  4. Group validationrecursiveGroupsIn* through emptyGroupsIn*. Checks for recursive and empty groups.

  5. Negation processingSingleSrvNegation through TimeNegation. Optimizes single-object negation, splits multi-object negation into chains, handles reject/TCP-reset interactions with negation.

  6. Logging and taggingLogging2 through Route. Creates LOG rules, splits tag/classify/route combinations.

  7. Group expansionExpandGroups through swapMultiAddressObjects*. Expands remaining groups, deduplicates, swaps MultiAddress objects.

  8. Any/firewall splittingaccounting through setChainPostroutingForTag. Handles "any" includes firewall, creates INPUT/OUTPUT/FORWARD splits.

  9. Address expansionprocessMultiAddressObjects* through expandLoopbackInterfaceAddress. Expands address ranges, multi-address objects, handles firewall-network overlaps.

  10. Chain and target assignmentInterfacePolicyRulesWithOptimization through removeFW. Assigns chains, targets, removes redundant firewall references.

  11. Address family filteringExpandMultipleAddresses through specialCaseWithUnnumberedInterface. Final address expansion, drops wrong address family, handles unnumbered/dynamic interfaces.

  12. Optimizationoptimize1 (×3) through optimizeForMinusIOPlus. Sub-chain optimization, multiport preparation, duplicate removal.

  13. Service normalizationgroupServicesByProtocol through checkForStatefulICMP6Rules. One protocol per rule, separate port ranges, validate services.

  14. Final atomizationConvertToAtomicForAddresses through ConvertToAtomicForIntervals. One address and one interval per rule.

  15. OutputcountChainUsage through simplePrintProgress. Count chain usage, generate iptables commands, print progress.

Minimal pipeline (for tracing/development)

This reduced set of 15 processors produces correct output for simple rules:

Begin → addRuleFilter → storeAction → InterfaceAndDirection →
ExpandGroups → finalizeChain → decideOnTarget → removeFW →
ExpandMultipleAddresses → groupServicesByProtocol → prepareForMultiport →
ConvertToAtomicForAddresses → countChainUsage → PrintRule → simplePrintProgress

iptables NAT pipeline order

The NAT compilation pipeline (NATCompiler_ipt.compile()) processes NAT rules through some 90 processor instances. Read it as the shape of the pass, not as a ledger: verify the wiring against compile() itself, which is where this listing is derived from.

Begin → PrintTotalNumberOfRules →
ExpandGroupsInItfInb → ReplaceClusterInterfaceInItfRE(itf_inb) →
SingleObjectNegationItfInb → ItfInbNegation →
ExpandGroupsInItfOutb → ReplaceClusterInterfaceInItfRE(itf_outb) →
SingleObjectNegationItfOutb → ItfOutbNegation →
ResolveMultiAddress → RecursiveGroupsInRE(osrc/odst/osrv/tsrc/tdst/tsrv) →
EmptyGroupsInRE(osrc/odst/osrv/tsrc/tdst/tsrv) →
ExpandGroups → DropRuleWithEmptyRE → [DropIPv4Rules OR DropIPv6Rules] →
EliminateDuplicatesInOSRC → EliminateDuplicatesInODST → EliminateDuplicatesInOSRV →
NATProcessMultiAddressObjectsInRE(osrc/odst/tsrc/tdst) →
DoOSrvNegation → ConvertToAtomicForOSrv →
ClassifyNATRule → SplitSDNATRule → ClassifyNATRule(reclassify) →
ConvertLoadBalancingRules → VerifyRules →
SingleObjectNegationOSrc → SingleObjectNegationODst →
DoOSrcNegation → DoODstNegation → SplitOnODst →
PortTranslationRules → SpecialCaseWithRedirect →
[SplitIfOSrcAny → SplitIfOSrcMatchesFw (if local_nat)] →
SplitNONATRule → SplitNATBranchRule → LocalNATRule → DecideOnChain → DecideOnTarget →
SplitODstForSNAT → ReplaceFirewallObjectsODst → ReplaceFirewallObjectsTSrc →
SplitOnDynamicInterfaceInODst → SplitOnDynamicInterfaceInTSrc →
ExpandMultipleAddressesInNAT → DropRuleWithEmptyRE →
[DropIPv4Rules OR DropIPv6Rules] → DropRuleWithEmptyRE →
NATSpecialCaseWithUnnumberedInterface →
NATCheckForDynamicInterfacesOfOtherObjects →
VerifyRuleWithMAC → CheckUserServiceInWrongChains →
NATExpandAddressRanges → SplitMultiSrcAndDst →
GroupServicesByProtocol → SeparateTCPWithFlags → VerifyCustomServices →
Verify{PortRanges,IcmpTypes,IpProtocols,AddressRanges,Addresses,MacAddresses,ScriptLiterals} →
VerifyRules2 → SeparatePortRanges → SeparateSrcPort → SeparateSrcAndDstPort →
PrepareForMultiport → SplitMultipleICMP → ConvertToAtomicForAddresses →
AddVirtualAddress → AssignInterface → VerifyRules3 →
DynamicInterfaceInODst → DynamicInterfaceInTSrc → AlwaysUseMasquerade →
ConvertToAtomicForItfInb → ConvertToAtomicForItfOutb →
CheckForObjectsWithErrors → CountChainUsage →
NATPrintRule → SimplePrintProgress

Routing pipeline order

platforms/linux/_routing_compiler.py, shared by both platforms — the routing compiler runs once per firewall, not once per address family. Ported from RoutingCompiler_ipt::compile(); the order is "check what the rule names, expand it, then check what the expansion left".

Begin → PrintTotalNumberOfRules → SingleRuleFilter (auto) →
RecursiveGroupsInRE(rdst) → EmptyGroupsInRE(rdst) →
EmptyRGtwAndRItf → SingleAddressInRGtw → RItfChildOfFw →
VerifyAddressRanges → VerifyRouteMetrics →
ExpandGroups → ExpandMultipleAddressesInRouting → DropRuleWithEmptyRE →
ValidateRoutingDestination → ReachableGateway →
GatewayOnRoutingInterface → ExpandAddressRangesInRDst →
EliminateDuplicatesInRDst → FindDefaultRoute → CompetingRoutingRules →
ConvertToAtomicForRDst → ClassifyRoutingRules →
EliminateDuplicateRoutingRules → NoteIPv6Routes → RoutingPrintRule

Two things about that pass are worth knowing before touching it. Every check in it compares rule elements by object id, the way fwbuilder does - except EliminateDuplicateRoutingRules, which compares what the command says: the destination and the gateway by address and the interface by name, the way RoutingRuleToString renders them. That is the whole point of the processor, because two objects holding one address and two interface objects of one name are one route on the wire. A cluster member has exactly that pair, since the copy of a cluster interface shares its name with the member's own. And RItfChildOfFw lets an interface of a cluster stand for the firewall's own only when the firewall is one of that cluster's members, asked from the cluster's own membership so it holds when the member is compiled on its own.

RoutingPrintRule writes the routing_functions configlet before the first route command and RoutingCompilerLinux.epilog() closes the block with restore_script_output, the way RoutingCompiler_ipt::PrintRule::processNext and its epilog() do. The configlet saves the routing table the box has, defines route_command_error, and takes the terminal out of the way so a route that changes the route to the administrator does not leave the session hanging. Every command is followed by || route_command_error "<label>", or by a warning when the rule carries the no_fail option the routing options dialog writes as "non-critical rule". FindDefaultRoute decides the configlet's proto_filter: with a default route of its own to install the script may delete the one that is there, without one it has to keep it. None of this is emitted in single-rule compile mode, where there are no shell functions to call.

The block is written per address family, and that is where this pass differs from the C++ one. ip route means the IPv4 table and nothing else, and Firewall Builder never had to say so because it compiles no IPv6 route at all. Here three questions have an answer per table:

  • NoteIPv6Routes says whether the script installs an IPv6 route, and only then does the block save, clear and restore that table beside the IPv4 one. Without it the route survived into the next activation and ip -6 route add answered "File exists", which stops the script. It sits behind the last processor that can still drop a rule and slurps, so it sees the rules the print rule will get and no others - a family the script installs no route in must not be touched.
  • FindDefaultRoute answers proto_filter and proto_filter6 separately, or a script installing an IPv6 default route deletes the IPv4 default route the box came up with and nothing puts it back.
  • _route_command_key carries the family, because route_address writes both 0.0.0.0/0 and ::/0 as default - which is what ip route add wants there - and two default routes out of one device are otherwise one key and the second is dropped as a duplicate.

Both readers of the routing table use ip -o route show, which puts a route with several next hops on one line. Read line by line, default and its indented nexthop ... lines are three entries, and the two the loops build out of the tail are no routes at all: the rollback lost an equal-cost default route entirely and the delete loop answered each fragment with an iproute2 usage dump.

Not ported from the C++ pass, and what it costs:

C++ processor Consequence
createSortedDstIdsLabel Ported as the _destination_key helper the two rules below share, not as a processor
checkForObjectsWithErrors An object a rule names that failed to load is reported by the policy and NAT passes but not by this one
DropIPv6RulesWithWarning Deliberate: fwf compiles an IPv6 route as $IP -6 route add, which fwbuilder cannot. Everything the paragraph above says about the block being written per address family follows from that

Implementation status

Every processor documented above is ported and behaves like fwbuilder unless it appears in the lists below. "Not ported" means there is no Python equivalent; "partial" means a Python equivalent exists but has a known behavioral gap. nftables-specific gaps are listed separately under nftables feature status.

Not ported

  • createNewCompilerPass — pass-through bookkeeping, not needed
  • ReplaceFirewallObjectWithSelfInRE"self" DNSName substitution
  • replaceFailoverInterfaceInRE — the pf variant of the cluster interface substitution; replaceClusterInterfaceInItfRE is what the two Linux pipelines use and it is ported
  • addressRanges (policy range-to-networks split) — fwf handles AddressRange natively via SpecialCaseAddressRangeIn{Src,Dst} / SplitIf{Src,Dst}MatchingAddressRange
  • SkipActionContinueWithNoLogging — filter for Continue-with-no-logging rules
  • separate shadowing pass: SplitIfSrcAnyForShadowing / SplitIfDstAnyForShadowing exist but are intentionally left unwired, and the DetectShadowingForNonTerminatingRules variant is absent (inline DetectShadowing is used instead)

Partial

  • singleObjectNegation — the AddressTable/ipset branch aside, the check is the C++ one: single_negation_qualifies asks the object count_inet_addresses(True) == 1, which every model class answers for itself, and complex_match against the firewall. Only the shadowing_mode variant of the three chain-building negations is missing (below).
  • SrcNegation / DstNegation / SrvNegation — missing the shadowing_mode variant; the separate shadowing pass works on copies instead
  • TimeNegation — nftables inverts one meta hour, meta time or meta day with != and needs no chain for it. Two of them are a disjunction, which one nftables rule cannot hold, so an interval naming both a window of the day and a set of weekdays — and a rule naming several intervals, whose negation is an "and" the atomizing split downstream would turn back into an "or" — is expanded into the same jump / return / action chain the iptables processor builds for every negated interval. The nftables compiler has no other temporary chain: PolicyCompiler_nft.get_new_tmp_chain_name names it, declares it in chain_rules and records it in temp_chains, which is what lets PrintRule_nft._print_verdict write the jump.
  • Calendar window of an Interval — the first and last date are compiled as --datestart / --datestop on iptables 1.4.0 and up and as meta time on nftables, and the daily window is then dropped, the way fwbuilder does it. An older iptables has no date options in its time match, so the rule is compiled without them and says so; a date outside the 1970-2038 range both tools can express is reported and the rule is left out.
  • InterfacePolicyRules — does not expand a group in the interface rule element. The main pass runs ExpandGroupsInItf first, so only the shadowing pass, which does not, can meet one.
  • "Assume firewall is part of any and networks" is normalised once, in the prolog, the way PolicyCompiler_ipt::prolog does it: a .fwb carries the value as '', '0', '1', 'False', 'True' or 'true', because the option was a checkbox in Firewall Builder 3.0 and a tri-state afterwards. Empty and the cleared old checkbox mean "use the firewall's setting", the ticked one means on, and anything else is read as a number - which is why 'true' is off. Every processor afterwards asks assumes_fw_is_part_of_any and nothing else.
  • expandMultipleAddressesInRE — an interface named in a rule element is replaced by every address it carries, its sub-interfaces included, the way Compiler::_expand_addr_recursive does it. Three kinds stay in the element as objects, because they carry no address the compiler could write down and because the checks that report them read the object: a dynamic interface, whose addresses the generated script looks up while it runs, an unnumbered one, and a bridge port.
  • An Attached Networks object stands for the subnets of every address its parent interface carries and is worked out again on each compile, so a rule naming it follows a change of address without being edited. Firewall Builder marks it run time when that interface is not a regular one - dynamic, unnumbered or a bridge port - and then writes $i_<iface>_network into the rule, a shell variable nothing in its generated script sets. There is nothing to write down at compile time for such an interface, so fwf reports the object and leaves the rule out.
  • Begin — does not skip a rule that references a deleted object. Firewall Builder leaves a "dummy" reference behind and warns; fwf has no deleted objects of its own and no .fwb of the corpus carries one, so such a rule is compiled as a rule about 255.255.255.255.

Reporting

A message about a rule is recorded once per compiler, not once per copy of the rule. This holds for a warning as much as for an error: Compiler overrides warning() only to word the message the way the Firewall Builder output does, and an override that forgets the record or the muted() check turns every internal copy of a rule into another line of the report. One rule as the editor shows it reaches the print rule as several - the service split gives an ICMP and a TCP half a rule each, the negation expansion builds three, the chain decisions split on top of that - and repeating one sentence per copy buries the rest of the report. The scope is deliberately the compiler and not the driver: the iptables filter and mangle passes are separate compilers and report separately, because they compile different rules. add_rule_filter() is what makes that true, and every pipeline reading the firewall's rules has to start with it - run_shadowing_pass() included, the way PolicyCompiler_ipt::compile calls addRuleFilter() in both passes. A pass that skips it reports the same finding once per table and reasons about rules its own table never installs. The inline comments next to the rules are not de-duplicated, so every emitted copy still carries its own reason.

A print-rule method that reports something it cannot express answers None, and its caller leaves the rule out. An empty string is a valid answer of its own in several of them - .CONTINUE carries no target, a rule that marks nothing carries no mangle statement - which is why the failure branches cannot use it. This holds for _print_target, _print_limit, _print_connlimit and _print_hashlimit on iptables and for _print_verdict, _print_mangle_statement, _print_limit and _print_hashlimit on nftables.

A rule element is rendered out of several objects, and the object that cannot be rendered is what says why: _print_addr names it and its reason, and the element around it adds a sentence of its own only when no object gave one - which is the case of an object that carries no address at all. Both nftables print rules pass a reasons list down for that. Two errors about one rule, the second of them the vaguer, is how a report stops being read.

Intentional deviations

  • DecideOnChainIfLoopback — for direction Both, splits into INPUT+OUTPUT rules; C++ sets only one chain. This is deliberate, not a gap.
  • Multi-address handling uses ResolveMultiAddress (compile-time) + ProcessMultiAddressObjectsInRE (runtime) instead of the C++ swapMultiAddressObjectsInRE.
  • VerifyPortRanges (compiler/processors/_service.py) and VerifyAddressRanges (compiler/processors/_generic.py) have no C++ counterpart. Firewall Builder corrects a range whose end is below its start in its editor (TCPServiceDialog::applyChanges, its bug #1695481, and AddressRangeDialog::applyChanges), so its compiler never asks; a data file written by an older release, by another tool or by hand carries whatever it carries, and both packet filters refuse such a range - iptables the command, nftables the whole ruleset. The same processors bound a port number at 65535, which is what xtables_parse_port does.
  • The other Verify* processors have no C++ counterpart for the same reason: the editor bounds the field and the compiler then trusts it, so a data file written elsewhere carries whatever it carries. VerifyIcmpTypes and VerifyIpProtocols (compiler/processors/_service.py) bound the ICMP type and code and the IP protocol number at 255, VerifyTimeIntervals (compiler/processors/_generic.py) the hour and the weekday of an Interval, VerifyMacAddresses the spelling of a MAC address and VerifyRouteMetrics (platforms/linux/_routing_compiler.py) the metric of a route. All of them are wired into every pipeline that can carry the value; a check added late has to be checked against every other reader of the same field, because a Verify processor only guards what runs after it.
  • SpecialCasesWithCustomServices asks whether a Custom Service does its own connection-state matching case-insensitively, where the C++ uses a case-sensitive find("ESTABLISHED"). Both tools read the state names with strncasecmp (netfilter extensions/libxt_conntrack.c and libxt_state.c) and nftables knows the lowercase spelling and no other, so an administrator may write either - and the two platforms have to give one and the same object the same answer. The predicate is custom_service_matches_state in platforms/linux/_netfilter.py.
  • A Branch rule whose target chain can reach the chain the rule is in is reported and left out, where Firewall Builder emits the jump and warns. The kernel walks every jump reachable from a base chain and answers -EMLINK at NFT_JUMP_STACK_SIZE levels (nft_chain_validate, netfilter net/netfilter/nf_tables_api.c), which both tools report as "Too many links" - and neither nft --check nor the shell sees it, because the ruleset has to reach the kernel first. nftables loads atomically, so the whole ruleset is refused and the firewall keeps the rules it had; iptables refuses the jump from every built-in chain and installs the rest, so the branch is silently absent from a script that activates cleanly. Only the jump that closes the cycle is left out - CompilerDriver.find_branch_loop_edges walks the branch graph from the top rule sets and names the back edges - so the rest of the branch tree stays reachable. This is the one place where missing in compare-reference.sh is one higher than Firewall Builder on purpose.
  • A rule set that is not the firewall's own top one names its chains after itself even when it is called "Policy". getNewChainName asks whether the name is "Policy" and writes RULE_<n> when it is, which is right for the firewall's own policy and wrong for a rule set of another firewall that a Branch rule points at - almost every firewall object owns one of that name, and findImportedRuleSets compiles it into this script beside our own. Both then build RULE_<n>, so the two rule sets share a chain and whichever rule is appended first decides for both; with logging on, the chains form a cycle the kernel refuses (-EMLINK, "Too many links"). Compiler.rule_set_key says the same thing for the hashed temporary chain names of the negation and SDNAT expansions on both platforms. This is the second place where missing in compare-reference.sh is higher than Firewall Builder on purpose (8 lines, firewall33-1 and firewall81).
  • VerifyScriptLiterals (compiler/processors/_generic.py) asks a question none of those do: three objects cannot be resolved by the compiler, so their names travel into the generated shell script and are read there - the data file of a run-time address table, the name a run-time DNS name resolves and the name of a dynamic interface. A bare word is shell syntax and a double-quoted one still expands $, a backtick and a backslash, so each of them is checked against a positive alphabet. The same processor answers the C++ processMultiAddressObjectsInRE abort for a file name below %DATADIR% on a firewall that names no data directory.

Compiling a cluster

A cluster is not a machine: it is what its members have in common. Both drivers therefore compile a member and are told which cluster it belongs to, which is what CompilerDriver::compile does, and the CLI expands a cluster named on the command line into one run per member. Before anything reads the member's rules, CompilerDriver.populate_cluster_elements gives it what it inherits (CompilerDriver::populateClusterElements):

  • the state sync group, whose interface names the link conntrackd replicates over. It is remembered on the firewall as state_sync_interface and state_sync_group_id - compile-run values that are in no defaults.yaml and are read off the options dict rather than through get_option.
  • a copy of every cluster interface that has a failover group for this member, carrying the address the cluster shares. The copy is what makes that address count as the firewall's, so a rule naming it goes to INPUT and OUTPUT and a translation to it resolves to that one address. It shares its name with the member's own interface, which on Linux it has to - the failover protocol runs on the member's NIC and the rule says -i <that name> - so Interface.cluster_interface marks it and the unique index on (device, interface name) makes an exception for it.
  • the cluster's rule sets, merged by name: the member's own wins when it has rules and is said out loud, an empty one of the same name is replaced (mergeRuleSets, fwbuilder ticket #372). All three kinds move across - policy, NAT and routing, the way populateClusterElements calls the merge once per type. The routing one is the easiest to forget and the most expensive to lose: no oracle can see it, because compare-reference.sh counts $IPTABLES lines and a route installs none, and a member compiled without it activates the new packet filter with no route at all.

Everything written there lives in CompilerDriver.compile_session, which is rolled back when the compile ends. The C++ mutates its object database because fwb_ipt throws it away afterwards; the GUI here compiles in the same process as the editor.

platforms/linux/_automatic_rules.py then builds the rules a member needs to see the other members - the failover protocol per interface, the state sync link - and both policy compilers put them in front of the top rule set with negative positions. Not ported from AutomaticRules_ipt: the vrrpd/heartbeat configuration generation, which fwbuilder does not do for Linux either.

Five more places ask about the cluster next to the firewall, each ported from its C++ site: AssignInterface, ReplaceFirewallObjectsODst, ReplaceFirewallObjectsTSrc, specialCaseWithRedirect and addVirtualAddress in the NAT pipelines - on a member only the copies of the cluster interfaces count (fwbuilder ticket #1185) and the interface facing the destination is looked for on the cluster, which is why the two are compared by name; checkForDynamicInterfacesOfOtherObjects exempts a dynamic interface of the cluster; checkInterfaceAgainstAddressFamily falls back to the member's own interface (ticket #1172); and decideOnChainIfDstFW puts a rule addressed to a cluster this firewall belongs to in the input chain, asked from the cluster's own membership so it holds when the member is compiled on its own. Every one of them is skipped where the cluster object is itself what is being compiled - fwf allows that and Firewall Builder does not.

The generated script must not configure the address a failover group shares: keepalived, heartbeat and corosync put it on and take it off themselves, so the copy configures nothing and the member's own interface of that name lists it as "ignore" (interfaceProperties::manageIpAddresses, and manage_addresses is false for every Linux protocol in the resource file).

The values those rules are built from come from the group's options, and the editor writes them: gui/cluster_protocol_dialogs.py behind the "Edit Parameters" button of the cluster group panel, one dialog per protocol the way Firewall Builder has one (#84). Two of them read differently here. The port spin boxes start at 1, because a rule permitting port 0 permits a port nothing speaks, and AutomaticRules._group_port reports a stored value that is no port rather than writing --dport 0 the way atoi in the C++ does. And a group address is read for its family, so an IPv6 sync address produces an IPv6 object and reaches the IPv6 pass; the C++ checks the text against both families and then builds an IPv4 out of it either way, and the rules disappear.

A cluster group is created under the object it belongs to - a failover group under the cluster interface, a state sync group under the cluster (#78) - and carries a protocol from the start, VRRP or conntrack, because a group with no protocol names none and there are no rules to write for it. A Cluster is edited with ClusterDialog, the firewall panel without the iptables release: Firewall Builder writes only platform and host_OS onto a cluster and each member compiles for the release it names itself.

What counts as "the firewall"

Compiler.complex_match answers that for every processor that decides a chain, and it is a port of ObjectMatcher, not a comparison of addresses. Three parts of it are easy to lose:

  • Both of its flags default to on, the way Compiler::complexMatch declares them (Compiler.h:955), so a broadcast, a multicast and the "old broadcast" 0.0.0.0 count as the firewall. Such a packet is delivered locally, can be sent by the firewall itself and is never routed, so a rule naming one belongs in INPUT and OUTPUT and never in FORWARD.
  • The address is compared against every address of the firewall's interfaces, so a standalone IPv4 or IPv6 object holding one of them is the firewall. With broadcasts recognised, the network address and the broadcast address of each interface's subnet answer too (fwbuilder bug #1040773).
  • A Network object stops at a netmask that is not a host mask. Whether a network the firewall merely has an address on counts is the "assume firewall is part of any and networks" question, which the callers ask for themselves.

Only FinalizeChain and SplitIf{Src,Dst}MatchingAddressRange ask with the flags off, and only on a bridging firewall, which forwards such a frame instead of terminating it (b=m= !bridging_fw). The same line is present but commented out in decideOnChainIf{Src,Dst}FW, where bridgingFw adds the forward copy afterwards instead.

Processor naming map

C++ rule processor to FirewallFabrik class, in pipeline order. Classes under compiler/processors/ are shared by both platforms; platforms/iptables/ and platforms/nftables/ are platform-specific.

fwbuilder (C++) FirewallFabrik (Python)
Begin compiler/processors/_generic.py:Begin
printTotalNumberOfRules compiler/processors/_generic.py:PrintTotalNumberOfRules
createNewCompilerPass — (not ported)
simplePrintProgress compiler/processors/_generic.py:SimplePrintProgress
singleRuleFilter compiler/processors/_generic.py:SingleRuleFilter
Debug compiler/_rule_processor.py:Debug
dropRuleWithEmptyRE compiler/processors/_generic.py:DropRuleWithEmptyRE
checkForObjectsWithErrors platforms/iptables/_policy_compiler.py:CheckForObjectsWithErrors
DropIPv4Rules compiler/processors/_generic.py:DropIPv4Rules / DropIPv6Rules
splitIfRuleElementMatchesFW platforms/iptables/_policy_compiler.py:SplitIfSrcMatchesFw / SplitIfDstMatchesFw
singleObjectNegation platforms/iptables/_policy_compiler.py:SingleSrcNegation / SingleDstNegation
fullInterfaceNegationInRE platforms/iptables/_policy_compiler.py:ItfNegation + compiler/processors/_policy.py:ItfNegation
replaceClusterInterfaceInItfRE compiler/processors/_generic.py:ReplaceClusterInterfaceInItfRE
eliminateDuplicatesInRE compiler/processors/_generic.py:EliminateDuplicatesInSRC/DST/SRV
recursiveGroupsInRE compiler/processors/_generic.py:RecursiveGroupsInRE
emptyGroupsInRE compiler/processors/_generic.py:EmptyGroupsInRE
AttachedNetworks::loadFromSource + Preprocessor_ipt::convertObject compiler/_compiler.py:Compiler._resolve_attached_networks, reached through ResolveMultiAddress
swapMultiAddressObjectsInRE No literal swap-to-runtime processor, but MultiAddress handling is implemented and wired: compiler/processors/_generic.py:ResolveMultiAddress resolves compile-time MultiAddress (wired in both pipelines) and platforms/iptables/_policy_compiler.py:ProcessMultiAddressObjectsInRE handles runtime MultiAddressRunTime objects (wired)
expandMultipleAddressesInRE compiler/_compiler.py:Compiler.expand_addr method
ReplaceFirewallObjectWithSelfInRE — (not ported)
replaceFailoverInterfaceInRE — (not ported)
InterfacePolicyRules compiler/processors/_policy.py:InterfacePolicyRules
ExpandGroups compiler/processors/_generic.py:ExpandGroups
expandGroupsInSrv platforms/iptables/_policy_compiler.py:ExpandGroupsInSrv
expandGroupsInItf platforms/iptables/_policy_compiler.py:ExpandGroupsInItf
ExpandMultipleAddresses compiler/processors/_policy.py + platforms/iptables/_policy_compiler.py:ExpandMultipleAddresses
addressRanges — (not ported)
checkForZeroAddr platforms/iptables/_policy_compiler.py:CheckForZeroAddr
checkForUnnumbered platforms/iptables/_policy_compiler.py:CheckForUnnumbered
ConvertToAtomicForAddresses compiler/processors/_generic.py:ConvertToAtomicForAddresses
ConvertToAtomicForIntervals platforms/iptables/_policy_compiler.py:ConvertToAtomicForIntervals
ConvertToAtomic compiler/processors/_generic.py:ConvertToAtomic
MACFiltering compiler/processors/_policy.py:MACFiltering
DetectShadowing compiler/processors/_generic.py:DetectShadowing
groupServicesByProtocol platforms/iptables/_policy_compiler.py:GroupServicesByProtocol
separateTCPWithFlags compiler/processors/_service.py:SeparateTCPWithFlags
separatePortRanges platforms/iptables/_policy_compiler.py:SeparatePortRanges
separateSrcPort compiler/processors/_service.py:SeparateSrcPort
separateUserServices compiler/processors/_service.py:SeparateUserServices
verifyCustomServices compiler/processors/_service.py:VerifyCustomServices
— (FirewallFabrik only) compiler/processors/_service.py:VerifyPortRanges
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyAddressRanges
— (FirewallFabrik only) compiler/processors/_service.py:VerifyIcmpTypes
— (FirewallFabrik only) compiler/processors/_service.py:VerifyIpProtocols
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyMacAddresses
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyScriptLiterals
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyTimeIntervals
CheckForTCPEstablished compiler/processors/_generic.py:CheckForTCPEstablished
dropMangleTableRules platforms/iptables/_policy_compiler.py:DropMangleTableRules
checkActionInMangleTable platforms/iptables/_policy_compiler.py:CheckActionInMangleTable
checkForUnsupportedCombinationsInMangle platforms/iptables/_policy_compiler.py:CheckForUnsupportedCombinationsInMangle
storeAction platforms/iptables/_policy_compiler.py:StoreAction
deprecateOptionRoute platforms/iptables/_policy_compiler.py:DeprecateOptionRoute + platforms/nftables/_policy_compiler.py:DeprecateOptionRoute
Logging1 platforms/iptables/_policy_compiler.py:Logging1
Logging2 platforms/iptables/_policy_compiler.py:Logging2
clearLogInMangle platforms/iptables/_policy_compiler.py:ClearLogInMangle
InterfaceAndDirection platforms/iptables/_policy_compiler.py:InterfaceAndDirection
splitIfIfaceAndDirectionBoth platforms/iptables/_policy_compiler.py:SplitIfIfaceAndDirectionBoth
checkInterfaceAgainstAddressFamily platforms/iptables/_policy_compiler.py:CheckInterfaceAgainstAddressFamily
splitIfTagClassifyOrRoute platforms/iptables/_policy_compiler.py:SplitIfTagClassifyOrRoute
clearTagClassifyInFilter platforms/iptables/_policy_compiler.py:ClearTagClassifyInFilter
clearActionInTagClassifyIfMangle platforms/iptables/_policy_compiler.py:ClearActionInTagClassifyIfMangle
setChainPreroutingForTag platforms/iptables/_policy_compiler.py:SetChainPreroutingForTag
setChainPostroutingForTag platforms/iptables/_policy_compiler.py:SetChainPostroutingForTag
setChainForMangle platforms/iptables/_policy_compiler.py:SetChainForMangle
splitIfTagAndConnmark platforms/iptables/_policy_compiler.py:SplitIfTagAndConnmark
checkForRestoreMarkInOutput platforms/iptables/_policy_compiler.py:CheckForRestoreMarkInOutput
SingleSrcNegation platforms/iptables/_policy_compiler.py:SingleSrcNegation / SingleDstNegation / SingleSrvNegation
SrcNegation platforms/iptables/_policy_compiler.py:SrcNegation
DstNegation platforms/iptables/_policy_compiler.py:DstNegation
SrvNegation platforms/iptables/_policy_compiler.py:SrvNegation
TimeNegation platforms/iptables/_policy_compiler.py:TimeNegation + platforms/nftables/_policy_compiler.py:TimeNegation
splitIfSrcAny platforms/iptables/_policy_compiler.py:SplitIfSrcAny
splitIfDstAny platforms/iptables/_policy_compiler.py:SplitIfDstAny
splitIfSrcAnyForShadowing platforms/iptables/_policy_compiler.py:SplitIfSrcAnyForShadowing / SplitIfDstAnyForShadowing
splitIfSrcMatchesFw platforms/iptables/_policy_compiler.py:SplitIfSrcMatchesFw / SplitIfDstMatchesFw
splitIfSrcFWNetwork platforms/iptables/_policy_compiler.py:SplitIfSrcFWNetwork
splitIfDstFWNetwork platforms/iptables/_policy_compiler.py:SplitIfDstFWNetwork
splitIfSrcNegAndFw platforms/iptables/_policy_compiler.py:SplitIfSrcNegAndFw
splitIfDstNegAndFw platforms/iptables/_policy_compiler.py:SplitIfDstNegAndFw
splitIfSrcMatchingAddressRange platforms/iptables/_policy_compiler.py:SplitIfSrcMatchingAddressRange / SplitIfDstMatchingAddressRange
specialCaseAddressRangeInSrc compiler/processors/_policy.py:SpecialCaseAddressRangeInSrc / SpecialCaseAddressRangeInDst (shared by both policy pipelines)
decideOnChainIfSrcFW platforms/iptables/_policy_compiler.py:DecideOnChainIfSrcFW
decideOnChainIfDstFW platforms/iptables/_policy_compiler.py:DecideOnChainIfDstFW
decideOnChainIfLoopback platforms/iptables/_policy_compiler.py:DecideOnChainIfLoopback
decideOnChainForClassify platforms/iptables/_policy_compiler.py:DecideOnChainForClassify
finalizeChain platforms/iptables/_policy_compiler.py:FinalizeChain
decideOnTarget platforms/iptables/_policy_compiler.py:DecideOnTarget
removeFW platforms/iptables/_policy_compiler.py:RemoveFW
specialCaseWithFW1 platforms/iptables/_policy_compiler.py:SpecialCaseWithFW1
specialCaseWithFW2 platforms/iptables/_policy_compiler.py:SpecialCaseWithFW2
specialCaseWithFWInDstAndOutbound platforms/iptables/_policy_compiler.py:SpecialCaseWithFWInDstAndOutbound
expandMultipleAddressesIfNotFWinSrc compiler/processors/_policy.py:ExpandMultipleAddressesIfNotFWInSrc / ExpandMultipleAddressesIfNotFWInDst
expandLoopbackInterfaceAddress platforms/iptables/_policy_compiler.py:ExpandLoopbackInterfaceAddress
processMultiAddressObjectsInSrc platforms/iptables/_policy_compiler.py:ProcessMultiAddressObjectsInSrc / ProcessMultiAddressObjectsInDst
specialCaseWithUnnumberedInterface platforms/iptables/_policy_compiler.py:SpecialCaseWithUnnumberedInterface
checkForDynamicInterfacesOfOtherObjects platforms/iptables/_policy_compiler.py:CheckForDynamicInterfacesOfOtherObjects
InterfacePolicyRulesWithOptimization platforms/iptables/_policy_compiler.py:InterfacePolicyRulesWithOptimization
fillActionOnReject platforms/iptables/_policy_compiler.py:FillActionOnReject
splitRuleIfSrvAnyActionReject platforms/iptables/_policy_compiler.py:SplitRuleIfSrvAnyActionReject
splitServicesIfRejectWithTCPReset platforms/iptables/_policy_compiler.py:SplitServicesIfRejectWithTCPReset
groupServicesByProtocol platforms/iptables/_policy_compiler.py:GroupServicesByProtocol
separateTCPWithFlags compiler/processors/_service.py:SeparateTCPWithFlags
verifyCustomServices compiler/processors/_service.py:VerifyCustomServices
— (FirewallFabrik only) compiler/processors/_service.py:VerifyPortRanges
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyAddressRanges
— (FirewallFabrik only) compiler/processors/_service.py:VerifyIcmpTypes
— (FirewallFabrik only) compiler/processors/_service.py:VerifyIpProtocols
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyMacAddresses
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyScriptLiterals
— (FirewallFabrik only) compiler/processors/_generic.py:VerifyTimeIntervals
specialCasesWithCustomServices platforms/iptables/_policy_compiler.py:SpecialCasesWithCustomServices
separatePortRanges platforms/iptables/_policy_compiler.py:SeparatePortRanges
separateUserServices compiler/processors/_service.py:SeparateUserServices
separateSrcPort compiler/processors/_service.py:SeparateSrcPort
prepareForMultiport platforms/iptables/_policy_compiler.py:PrepareForMultiport
checkForStatefulICMP6Rules platforms/iptables/_policy_compiler.py:CheckForStatefulICMP6Rules
CheckForTCPEstablished compiler/processors/_generic.py:CheckForTCPEstablished
checkMACinOUTPUTChain platforms/iptables/_policy_compiler.py:CheckMACInOUTPUTChain
checkUserServiceInWrongChains platforms/iptables/_policy_compiler.py:CheckUserServiceInWrongChains
SkipActionContinueWithNoLogging — (not ported)
bridgingFw platforms/iptables/_policy_compiler.py:BridgingFw
convertAnyToNotFWForShadowing platforms/iptables/_policy_compiler.py:ConvertAnyToNotFWForShadowing
optimize1 platforms/iptables/_policy_compiler.py:Optimize1
optimize2 platforms/iptables/_policy_compiler.py:Optimize2
optimize3 platforms/iptables/_policy_compiler.py:Optimize3
optimizeForMinusIOPlus platforms/iptables/_policy_compiler.py:OptimizeForMinusIOPlus
accounting platforms/iptables/_policy_compiler.py:Accounting
countChainUsage platforms/iptables/_policy_compiler.py:CountChainUsage
PrintRule platforms/iptables/_print_rule.py:PrintRule
PrintRuleIptRst not ported (restore mode always takes the echo variant)
PrintRuleIptRstEcho platforms/iptables/_print_rule.py:PrintRuleIptRstEcho
SingleObjectNegationItfInb platforms/iptables/_nat_compiler.py:SingleObjectNegationItfInb
SingleObjectNegationItfOutb platforms/iptables/_nat_compiler.py:SingleObjectNegationItfOutb
PortTranslationRules platforms/iptables/_nat_compiler.py:PortTranslationRules
SpecialCaseWithRedirect platforms/iptables/_nat_compiler.py:SpecialCaseWithRedirect
SplitNONATRule platforms/iptables/_nat_compiler.py:SplitNONATRule
ReplaceFirewallObjectsODst platforms/iptables/_nat_compiler.py:ReplaceFirewallObjectsODst
ReplaceFirewallObjectsTSrc platforms/iptables/_nat_compiler.py:ReplaceFirewallObjectsTSrc
SingleObjectNegationOSrc platforms/iptables/_nat_compiler.py:SingleObjectNegationOSrc
SingleObjectNegationODst platforms/iptables/_nat_compiler.py:SingleObjectNegationODst
SplitIfOSrcAny platforms/iptables/_nat_compiler.py:SplitIfOSrcAny
SplitIfOSrcMatchesFw platforms/iptables/_nat_compiler.py:SplitIfOSrcMatchesFw
LocalNATRule platforms/iptables/_nat_compiler.py:LocalNATRule
ExpandMultipleAddresses compiler/processors/_generic.py:ExpandMultipleAddressesInNAT (shared by both NAT pipelines)
NATCompiler_ipt::checkForDynamicInterfacesOfOtherObjects compiler/processors/_generic.py:NATCheckForDynamicInterfacesOfOtherObjects (shared by both NAT pipelines)
ClassifyNATRule platforms/iptables/_nat_compiler.py:ClassifyNATRule

nftables Processors

These live in src/firewallfabrik/platforms/nftables/ and are specific to the nftables backend. The nftables compiler is significantly simpler than iptables because nftables has native support for sets (no multiport hack), negation (no temp chains), inline logging (no LOG chain splitting), and user-defined tables/chains.

Key source files: - platforms/nftables/_policy_compiler.pyPolicyCompiler_nft and all policy rule processors - platforms/nftables/_nat_compiler.pyNATCompiler_nft and all NAT rule processors - platforms/nftables/_print_rule.pyPrintRule_nft final output generation (filter rules) - platforms/nftables/_nat_print_rule.pyNATPrintRule_nft final output generation (NAT rules) - platforms/nftables/_compiler_driver.pyCompilerDriver_nft orchestrator

Architecture differences from iptables

Concept iptables nftables
Chain assignment -A INPUT part of each command Rules written into chain blocks; per-chain chain_rules dict
Multiport -m multiport --dports 22,80,443 (max 15) Native sets: tcp dport { 22, 80, 443 } (unlimited)
Negation Temp chains for multi-object ! Native != operator
Logging Separate -j LOG rule + temp chain Inline log prefix "..." accept
Mangle table Separate -t mangle compilation pass Separate pass too: a <name>_mangle table whose chains hook in at priority mangle
Address family Separate iptables/ip6tables binaries inet family for dual-stack
Reject types --reject-with icmp-port-unreachable reject with icmp port-unreachable
Incoming interface in postrouting Refused by the tool (option_test_and_reject in xshared.c), except as -m physdev --physdev-in for a bridge port iifname, which loads and matches

The last row is a kernel fact with a tool-specific answer, so DropRuleWithImpossibleInterface and nat_interface_problem ask the compiler through can_match_inbound_in_postrouting() instead of deciding for themselves. Since kernel commit 28f8bfd1ac94 ("netfilter: Support iif matches in POSTROUTING", first in v5.5) the hook is entered with the device a routed packet came in on. Everything else in that check is unchanged: a packet has no outgoing device before the routing decision and a locally generated one no incoming device at all, so -o/oifname stays impossible in prerouting and input, and -i/iifname in output.

Error reporting

All processors have access to self.compiler.error(rule, msg) and self.compiler.warning(rule, msg). Errors appear as inline # comments in the generated script, set the compiler status to FWCOMPILER_ERROR, and cause the CLI to exit with code 1.

Convention: Messages say "not supported in nftables" when the feature genuinely doesn't exist in nftables (e.g. Scrub, Skip actions), and "not yet supported by nftables compiler" when nftables could do it but our compiler doesn't implement it yet (e.g. dynamic interfaces, Branch).

Policy processors (platforms/nftables/_policy_compiler.py)

StoreAction — Transform

Stores the original action string in rule._extra['stored_action'] before later processors modify it. Used by PrintRule_nft._get_log_prefix() for the %A macro.

InterfaceAndDirection — Transform

Sets undefined direction to Both. If interface is "any" and direction is Both, sets .iface to nil (no iifname/oifname in output). Otherwise records the interface name.

SplitIfIfaceAndDirectionBoth — Split

Splits rules with a specific interface and direction Both into two rules: one Inbound, one Outbound.

FillActionOnReject — Transform

Copies the default action_on_reject from the global firewall option if the rule's own option is empty.

Logging_nft — Transform

Simpler than iptables Logging2 because nftables supports inline logging.

  • Continue + log (no tagging/classification/routing): sets ipt_target = 'LOG'.
  • Continue + log + tagging/classification/routing: emits errors for unsupported features, then sets ipt_target = 'LOG'.
  • Other action + log: sets rule._extra['nft_log'] = True so PrintRule_nft emits log prefix "..." accept in a single rule.

Errors reported: - Policy routing not yet supported by nftables compiler — nftables has fib+marks, but not implemented.

SplitIfSrcNegAndFw — Split

Splits rules where Src is negated and contains firewall-like objects. Creates an OUTPUT chain rule for the FW objects (keeping negation) and passes through non-FW objects with a no_output_chain option. Skips rules that already have a chain assigned or have Inbound direction.

SplitIfDstNegAndFw — Split

Mirror of SplitIfSrcNegAndFw for Dst. Creates an INPUT chain rule for FW objects (keeping negation) and passes through non-FW objects with a no_input_chain option. Skips rules that already have a chain assigned or have Outbound direction.

NftNegation — Transform

Converts element negation flags to single_object_negation flags for nftables' native != operator. Unlike iptables (which needs temp chains for multi-object negation), nftables supports != for both single and multi-object sets, so this processor simply converts all src/dst/srv negation flags directly — no chain splitting needed.

SplitIfSrcAny / SplitIfDstAny — Split

If Src/Dst is "any" (or has single_object_negation with a non-firewall object), creates an additional rule for the OUTPUT/INPUT chain. The original remains for FORWARD. First checks the firewall_is_part_of_any_and_networks option (per-rule then global) — if not set, passes the rule through unchanged.

SplitIfSrcMatchesFw / SplitIfDstMatchesFw — Split

Splits rules where the firewall object appears among other objects in Src/Dst. Each firewall occurrence gets its own rule.

DecideOnChainIfDstFW / DecideOnChainIfSrcFW — Transform

Sets chain to input/output if Dst/Src matches the firewall.

SplitIfSrcFWNetwork / SplitIfDstFWNetwork — Split

Splits when Src/Dst contains a network the firewall has an interface on. Creates an additional OUTPUT/INPUT rule.

SpecialCaseWithFW2 — Transform

When Src == Dst == firewall, replaces both with the firewall's interface addresses (including loopback).

DecideOnChainIfLoopback — Transform/Split

Assigns input/output chain for any-any rules on loopback interface. For direction Both, splits into two rules.

FinalizeChain — Transform

Last-resort chain assignment. Defaults to forward, then upgrades to input/output based on direction and firewall match. In the mangle table the direction decides instead, and an accepting rule goes to prerouting whatever its direction says. A rule that ends up in forward on a firewall whose packet forwarding is off is reported and left out — both platforms do this, the option is read per address family.

DecideOnTarget — Transform

Maps rule action to iptables-style target string (used internally; PrintRule_nft maps to nftables verdicts):

Action Target Notes
Accept ACCEPT
Deny DROP
Reject REJECT
Return RETURN
Continue .CONTINUE Pseudo-target — no verdict in output
Custom .CUSTOM The rule's own text, written out verbatim; reported and left out when the firewall names another platform
Accounting .CONTINUE Counts into a named counter, no verdict
Branch target rule set name A jump into the chain the branch rule set compiles into; a target no chain carries is reported and the rule left out
Modify Error: not yet supported by compiler
Pipe QUEUE Rendered as the queue verdict
Scrub Error: not supported in nftables
Skip Error: not supported in nftables

RemoveFW — Transform

Removes the firewall object from Src (if OUTPUT chain) or Dst (if INPUT chain) after chain assignment.

ExpandMultipleAddresses — Transform

Expands Host/Firewall objects in Src/Dst to their interface addresses via compiler.expand_addr().

GroupServicesByProtocol — Split

Splits rules with services of different protocols. Special case: if only TCP+UDP with identical port sets, merges into meta l4proto { tcp, udp } th dport ... by setting rule._extra['merged_tcp_udp'] = True.

Within one protocol the rule is split further, because a single nft rule carries one destination port set: TCP/UDP services that agree on their source port stay together, every other service type (ICMP, IP, custom, tag, user) gets its own rule. This is the counterpart of the iptables PrepareForMultiport.

Optimize3 — Filter

Removes duplicate rules that produce identical nftables commands. Includes the chain name in the dedup key (unlike iptables where the chain is part of the command string).

NAT processors (platforms/nftables/_nat_compiler.py)

DropRuleWithEmptyRE — Filter

Drops rules where _has_empty_re is set (a required rule element became empty after upstream processing).

EliminateDuplicatesInOSRC / EliminateDuplicatesInODST / EliminateDuplicatesInOSRV — Transform

Removes duplicate objects within OSrc/ODst/OSrv by Python object identity.

ClassifyNATRule — Transform

Classifies the NAT rule type based on TSrc/TDst/TSrv contents:

TSrc TDst Type
any any NONAT
Network any SNetnat
other any SNAT
any Network DNetnat
any firewall Redirect
any other DNAT
set set SDNAT
Branch action NATBranch

A rule may translate nothing but the port, with TSrc and TDst both "any". The translated side then follows from TSrv: a source port makes it an SNAT, a destination port a DNAT, and a port on the side opposite an address translation makes it an SDNAT. Same logic as the iptables ClassifyNATRule.

SpecialCaseWithRedirect — Transform

Reclassifies a DNAT rule as Redirect when TDst is the firewall. PortTranslationRules fills TDst in for a port-only translation that targets the firewall, which is a redirect to a local port rather than a DNAT.

VerifyRules — Validation

Aborts if negation is used in TSrc, TDst, or TSrv (these are not supported in translated elements).

DecideOnChain — Transform

Assigns NAT rules to chains:

Rule type Chain
SNAT, SNetnat, Masq postrouting
DNAT, DNetnat, Redirect prerouting
NONAT, Return, SDNAT no assignment needed
Other Error: no chain assignment

GroupServicesByProtocol — Split

Splits NAT rules with mixed-protocol services.

ConvertToAtomicForAddresses — Split

Creates the cartesian product of OSrc × ODst × TSrc × TDst. Each output rule has at most one object per element. A negated OSrc / ODst element is kept whole and rendered as != { a, b }, because "not one of these" only holds when none of them matches.

AssignInterface — Transform

Assigns outbound interface for SNAT/Masquerade rules. If TSrc is an interface on the firewall, uses that. Otherwise, creates one rule per non-loopback firewall interface.

PrintRule_nft (platforms/nftables/_print_rule.py) — Output

Final processor for policy rules. Generates nft rule statements and writes them to the per-chain compiler.chain_rules dict.

Rule format: [iifname/oifname] [ip saddr] [ip daddr] [proto match] [ct state new] [log ...] [verdict]

Key methods and their error reporting:

Method Errors reported
_print_addr(obj, rule) Dynamic interface not yet supported by compiler; Interface/Host has no addresses
_print_addr_basic(obj, rule) Cannot resolve address for object type
_print_src_addr() / _print_dst_addr() Could not resolve any source/destination addresses
_print_service() Service type not yet supported by compiler
_print_verdict() Rule branches to a rule set nftables cannot jump to; rule with a Custom action and no statement; an action with no verdict
_print_reject() Unknown reject type, falling back to generic reject (warning)

Supports: - Interface matching: iifname/oifname (wildcard), iif/oif (loopback — index-based) - Address matching: CIDR notation, address ranges (start-end), sets ({ addr1, addr2 }) - Service matching: TCP/UDP ports (single, range, multiport sets), ICMP type/code, IP protocol number - IP header matching: fragments (ip frag-off & 0x1fff != 0, frag more-fragments 1), DSCP, IPv4 options (ip option lsrr exists, ip hdrlength > 5 for "any option") - Merged TCP+UDP: meta l4proto { tcp, udp } th dport ... - Negation: != on addresses, ports, ICMP type, protocol; the ICMP type/code pair as a concatenation (icmp type . icmp code != { X . Y }) - Connection tracking: ct state new - Inline logging: log prefix "..." level ... combined with verdict - Log prefix macros: %N (position), %A (action), %I (interface), %C (chain), %R (ruleset)

Reports an error instead of silently dropping a condition it cannot express: a ToS-byte match, the IPv4 timestamp option, a negated custom service, and a negated element whose match consists of several conditions (nftables cannot express the disjunction one rule would need).

NATPrintRule_nft (platforms/nftables/_nat_print_rule.py) — Output

Final processor for NAT rules. Generates nft NAT rule statements.

Key methods and their error reporting:

Method Errors reported
_print_addr(obj, rule) Interface/Host has no addresses; Cannot resolve address for object type
_print_service() Service type not yet supported by compiler
_print_nat_action() DNAT has no translated destination

NAT action output:

Rule type Output
NONAT accept
Masq masquerade
SNAT/SNetnat snat to addr[:port], snat to :port for a port-only translation
DNAT/DNetnat dnat to addr[:port], dnat to :port for a port-only translation
Redirect redirect [to :port]
Return return

SDNAT never reaches the print rule: SplitSDNATRule splits it into a DNAT and an SNAT rule beforehand.

Compiler driver

CompilerDriver_nft (platforms/nftables/_compiler_driver.py) orchestrates the full nftables compilation. The overall driver flow and the generated script structure (a /bin/sh script around the ruleset, table inet filter { … }, table ip nat { … }) are documented in Compilation Pipeline. Both iptables and nftables drivers call _warn_unsupported_options() (base CompilerDriver) to emit warnings for recognised but unimplemented firewall options (ULOG/NFLOG, TCP/IP log options, numeric log levels, log_all, kernel timezone, bridge interfaces). The nftables driver reuses the iptables routing compiler.

Policy pipeline order

Begin → SingleRuleFilter → DeprecateOptionRoute →
[DropMangleTableRules (filter run) OR KeepMangleTableRules (mangle run)] →
ClearTagClassifyInFilter → ClearActionInTagClassifyIfMangle → ClearLogInMangle →
StoreAction → Logging1 →
EmptyGroupsInRE(itf) → ExpandGroupsInItf → ReplaceClusterInterfaceInItfRE(itf) →
SingleObjectNegationItf → ItfNegation → DecideOnChainForClassify → InterfaceAndDirection → SplitIfIfaceAndDirectionBoth →
ResolveMultiAddress →
RecursiveGroupsInRE(src/dst/srv) → EmptyGroupsInRE(src/dst/srv/itf) →
ExpandGroups → DropRuleWithEmptyRE →
EliminateDuplicatesInSRC/DST/SRV →
CheckForTCPEstablished →
SplitRuleIfSrvAnyActionReject → FillActionOnReject → SplitServicesIfRejectWithTCPReset →
FillActionOnReject(2) → SplitServicesIfRejectWithTCPReset(2) →
SrvNegation → AddOtherProtocolsForNegatedService →
Logging_nft → SplitIfTagAndConnmark → Accounting →
SplitIfSrcNegAndFw → SplitIfDstNegAndFw → NftNegation → TimeNegation →
SplitLogWithStatefulLimit →
[DetectShadowing (if check_shading and not single-rule mode)] →
[CheckActionInMangleTable (mangle run)] →
SplitIfSrcAny → SetChainForMangle → SetChainPreroutingForTag → SplitIfDstAny → SetChainPostroutingForTag →
ProcessMultiAddressObjectsInRE(src/dst) →
SpecialCaseAddressRangeInSrc → SpecialCaseAddressRangeInDst →
SplitIfSrcMatchingAddressRange → SplitIfDstMatchingAddressRange →
SplitIfSrcMatchesFw → SplitIfDstMatchesFw → SpecialCaseWithFW1 →
DecideOnChainIfDstFW → SplitIfSrcFWNetwork → DecideOnChainIfSrcFW → SplitIfDstFWNetwork →
SpecialCaseWithFW2 → ExpandMultipleAddressesIfNotFWInSrc → ExpandMultipleAddressesIfNotFWInDst → DropRuleWithEmptyRE →
ConvertToAtomicForInterfaces → CheckInterfaceAgainstAddressFamily →
DecideOnChainIfLoopback → FinalizeChain → SpecialCaseWithFWInDstAndOutbound → DecideOnTarget → CheckForRestoreMarkInOutput →
RemoveFW → ExpandMultipleAddresses → ExpandLoopbackInterfaceAddress → DropRuleWithEmptyRE →
[DropIPv4Rules OR DropIPv6Rules] → DropRuleWithEmptyRE →
CheckForUnnumbered → CheckForDynamicInterfacesOfOtherObjects →
[BridgingFw (if bridging firewall)] →
ConvertToAtomicForIntervals → GroupServicesByProtocol →
VerifyCustomServices →
Verify{PortRanges,IcmpTypes,IpProtocols,AddressRanges,Addresses,MacAddresses,ScriptLiterals,TimeIntervals} →
SpecialCasesWithCustomServices →
CheckForStatefulICMP6Rules →
Optimize3 → CheckMACInOUTPUTChain → CheckUserServiceInWrongChains →
CheckForZeroAddr → CheckForObjectsWithErrors →
PrintRule_nft → SimplePrintProgress

~70 processors vs. ~110 in iptables. The pipeline shares many base processors with iptables (Begin, ExpandGroups, DropRuleWithEmptyRE, EliminateDuplicatesIn*, DropIPv4/6Rules, ConvertToAtomicForInterfaces, SimplePrintProgress, EmptyGroupsInRE, DetectShadowing) but omits the temp-chain and multiport processors (nftables has native != negation and sets).

The same pipeline runs twice per rule set, once per table. MangleCompiler_nft (platforms/nftables/_mangle_compiler.py) is PolicyCompiler_nft with my_table = 'mangle'; it swaps the rule filter and reaches the chain names preroutingpostrouting. DetectShadowing runs in the filter pass only — the mangle pass sees a subset of the same rules and would just repeat every warning; in the filter pass it runs right after TimeNegation, deliberately before the split-any processors. Negation is handled natively via !=: SplitIfSrcNegAndFw, SplitIfDstNegAndFw, NftNegation, plus TimeNegation, which still builds a temporary chain and only for the negated interval that says two things at once. SrvNegation builds the same chain for the two service shapes no != can say - a service inspecting TCP flags and naming a port, and a match carrying no protocol beside one that does - and SplitLogWithStatefulLimit is the third user of that chain. Everything else about a negated service element is one rule per protocol group, written by print_negated_services. SplitIfSrcAny/SplitIfDstAny check the firewall_is_part_of_any_and_networks option with the same improved negation logic as iptables.

NAT pipeline order

Begin →
ExpandGroupsInItfInb → ReplaceClusterInterfaceInItfRE(itf_inb) →
SingleObjectNegationItfInb → ItfInbNegation →
ExpandGroupsInItfOutb → ReplaceClusterInterfaceInItfRE(itf_outb) →
SingleObjectNegationItfOutb → ItfOutbNegation →
ResolveMultiAddress →
RecursiveGroupsInRE(osrc/odst/osrv/tsrc/tdst/tsrv) →
EmptyGroupsInRE(osrc/odst/osrv/tsrc/tdst/tsrv) →
ExpandGroups → DropRuleWithEmptyRE →
[DropIPv4Rules OR DropIPv6Rules] →
EliminateDuplicatesInOSRC/ODST/OSRV →
ClassifyNATRule → SplitSDNATRule → ClassifyNATRule(reclassify) → ConvertLoadBalancingRules → VerifyRules →
SingleObjectNegationOSrc → SingleObjectNegationODst →
NftNegationOSrc → NftNegationODst → AddOtherProtocolsForNegatedServiceInNAT → NftNegationOSrv →
SplitOnODst → PortTranslationRules → SpecialCaseWithRedirect →
[if local_nat: [if fw_part_of_any: SplitIfOSrcAny] → SplitIfOSrcMatchesFw] →
SplitNONATRule → SplitNATBranchRule → LocalNATRule → DecideOnChain → DecideOnTarget →
SplitODstForSNAT → ReplaceFirewallObjectsODst → ReplaceFirewallObjectsTSrc →
ExpandMultipleAddresses → DropRuleWithEmptyRE →
[DropIPv4Rules OR DropIPv6Rules] → DropRuleWithEmptyRE →
NATCheckForDynamicInterfacesOfOtherObjects →
VerifyRuleWithMAC → CheckUserServiceInWrongChains →
GroupServicesByProtocol → SeparateTCPWithFlags → VerifyCustomServices →
Verify{PortRanges,IcmpTypes,IpProtocols,AddressRanges,Addresses,MacAddresses,ScriptLiterals} →
VerifyRules2 → SeparatePortRanges →
SeparateSrcPort → SplitMultipleServices → ConvertToAtomicForAddresses → AssignInterface →
ConvertToAtomicForItfInb → ConvertToAtomicForItfOutb →
CheckForObjectsWithErrors → NATPrintRule_nft → SimplePrintProgress

nftables feature status

Feature coverage of the nftables compiler for the fwbuilder rule model. The tables split what the compiler already does from what it does not yet do; the "nftables backend" column notes whether nftables (the technology) can express the feature at all, independent of our implementation.

Implemented

Feature Notes
IPv6 dual-stack Compiled as separate ip / ip6 passes (no unified inet table)
SDNAT (simultaneous SNAT+DNAT) SplitSDNATRule splits into DNAT+SNAT, handing the translated service to the half it belongs to, then ClassifyNATRule reclassifies
Port-only NAT A rule that translates only the port: Redirect, dnat to :port, snat to :port
Shadowing detection DetectShadowing, conditional on the check_shading option
Empty group validation EmptyGroupsInRE in both policy (SRC/DST/SRV/ITF) and NAT (OSRC/ODST/OSRV/TSRC/TDST/TSRV)
firewall_is_part_of_any_and_networks Checked by SplitIfSrcAny / SplitIfDstAny
Packet marking (tagging) meta mark set in the mangle table, via MangleCompiler_nft
Classification meta priority set in the mangle table's postrouting chain
Connection marking ct mark set mark per rule, meta mark set ct mark prepended to the prerouting and output chains
Pipe queue, queue number 0, the same queue the iptables QUEUE target uses
Accounting counter name "..." plus a counter object declared in the table
Negation expansion (policy) Native != via NftNegation + SplitIfSrcNegAndFw / SplitIfDstNegAndFw; a negated time that names both a window of the day and a set of weekdays, and a negated service element no single rule can exclude, become a jump / return / action chain, the way iptables says every negated element
NAT interface negation SingleObjectNegationItfInb / SingleObjectNegationItfOutb + != output
NAT OSrc/ODst negation SingleObjectNegationOSrc / SingleObjectNegationODst inline ! flags
NAT local_nat SplitIfOSrcAny + SplitIfOSrcMatchesFw + LocalNATRule
NAT ReplaceFirewallObjectsTSrc Replaces firewall in TSrc with the interface facing ODst
Address table (run-time) A named set per address family, declared with flags interval and auto-merge in the table its rules are in and filled from the file by load_address_tables after the ruleset is loaded. The script also offers the four commands the iptables script has offered since Firewall Builder wrote them - reload_address_table, add_to_address_table, remove_from_address_table and test_address_table, with the same arguments - so a block list can be kept up to date without recompiling the firewall. They read address_table_sets, the index of which set stands for which table, because a table used by both the filter and the NAT rules, or by both address families, has a set for each
Custom service whose code nftables has a spelling for A Custom Service carries one code per platform and no data file written before fwf has an nftables one, so the iptables code is read as a sequence of matches and translated when every one of them translates: -m state --state … / -m conntrack --ctstate … become ct state …, --tcp-flags and --syn become tcp flags … / …, -m owner --uid-owner / --gid-owner become meta skuid / meta skgid - the match a User Service object already compiles to - a bare -p becomes meta l4proto where nothing else pins the protocol, and -m rt becomes rt type / rt seg-left / rt hdrlength - but only on a service that declares itself IPv6-only, because rt in an ip table is "cannot use exthdr with ip" and costs the whole ruleset rather than the one rule. Each mapping is netfilter's own (state_xlate_print and _conntrack3_mt_xlate in extensions/libxt_conntrack.c, tcp_xlate in libxt_tcp.c, owner_xlate in libxt_owner.c, rt_xlate in libip6t_rt.c), SNAT, DNAT and the three RT0 options excepted; a code holding anything else - -m recent, -m string, -m psd, a connection-tracking helper - is left alone and reported, because guessing at platform text is how a rule matches something nobody wrote

Partial or not yet implemented

The nftables backend supports all of these; the fwf compiler does not fully implement them yet. Rules using a "not yet" feature abort with an error; the "partial" cases work with a warning or in the common case only.

Feature nftables backend fwf status Notes
Inline logging with verdict Yes One rule carries the log and the verdict (log prefix "…" accept), and the mark or the traffic class beside them where iptables needs a temporary chain for the second target. A log with a rate limit of its own becomes two rules, the way iptables' temporary chain does, so the limit gates the logging and not the traffic — and where the rule also holds a connection limit or a rate limit kept per key, SplitLogWithStatefulLimit builds the jump / log / action chain Logging2 builds, because two lines would consume that limit twice
Custom action any statement The rule's text is appended to the rule the way the iptables printer appends its custom target. It carries no platform of its own, so the firewall's platform says what it was written in: a firewall naming another one is reported, because nftables refuses the whole ruleset over a statement it cannot parse
Branch (sub-policy) jump / goto ⚠️ Partial Both a policy and a NAT branch rule set get a regular chain and are reached by a jump. A NAT branch gets one chain per direction, because prerouting and postrouting are separate hooks. A rule set belonging to another firewall or cluster object is compiled into this script as well, the way CompilerDriver::findImportedRuleSets does it. Two cases stay reported: a branch into the firewall's own top rule set, whose chains are hooked and cannot be jumped to - Firewall Builder emits the same empty chain there - and the jump that closes a cycle, which the kernel refuses (nft_chain_validate answers -EMLINK, "Too many links"); see the note under Intentional deviations
Dynamic interface addresses Sets / maps A named set per interface and family, filled by load_interface_address from the running interface after the ruleset loads; a wildcard name collects every interface it matches
Policy routing fib + marks ❌ Not yet DeprecateOptionRoute reports the rule and leaves it out, the way the iptables pipeline refuses it (#125)