Design Decisions¶
PySide6 vs. PyQt6¶
There is no strong technical reason to prefer PySide6 over PyQt6 for this project -- both frameworks are perfectly suitable. We chose PySide6 primarily because FirewallFabrik is being ported from the original Firewall Builder C++/Qt codebase. PySide6's API closely mirrors C++ Qt, which makes the porting process more straightforward and keeps the Python code visually and structurally similar to the original implementation.
Additionally, PySide6 is licensed under the LGPL, which is more permissive and leaves the door open for potential proprietary extensions in the future.
Removal of the "Deleted Objects" Feature from Firewall Builder¶
In Firewall Builder, deleting an object does not remove it permanently. Instead, the object is moved to a special system library called "Deleted Objects" (sysid99 / FWObjectDatabase::DELETED_OBJECTS_ID). This is implemented in ObjectManipulator::deleteObject() (ObjectManipulator_ops.cpp) and FWCmdMoveObject (FWCmdMoveObject.cpp). The idea is that users can later browse this library and restore objects.
However, the feature provides only very limited value in practice. When an object is moved to "Deleted Objects", all references to it are removed immediately: every rule element, group membership, or other relationship pointing to the object is cleaned up (and rule elements left empty are filled with dummy placeholders like dummySource or dummyDestination). The deleted references are stored in a reference_holders map inside the FWCmdMoveObject undo command, so pressing Ctrl+Z (undo) within the same session correctly restores both the object and all its references.
The problem is that the reference_holders map only lives in the in-memory undo stack. It is not persisted to the .fwb file. Once the user saves, closes, and reopens the file, the undo history is gone. At that point, the only way to restore an object from "Deleted Objects" is the right-click "Move to ..." or "Undelete" menu action -- and both undeleteLibrary() (ObjectManipulator_slots.cpp:71) and moveObject() (ObjectManipulator_ops.cpp:243) create a new FWCmdMoveObject with an empty reference_holders map, meaning references are not restored. A restored firewall object, for example, comes back without any of its former rule references.
For this reason, we decided not to implement "Deleted Objects" in FirewallFabrik. Deleting an object is permanent (see object_tree_ops.py:delete_object()). When importing .fwb files, the "Deleted Objects" library is explicitly excluded (_database.py:_load_xml()).
Instead, we recommend using Git as a version control system. With the switch from an XML-based backend (Firewall Builder) to YAML (FirewallFabrik), Git makes it easy to restore deleted objects -- including all their references -- by reverting or inspecting previous revisions of the data files.
Changed Default for "IPv4 Packet Forwarding"¶
The Linux host settings dialog (linuxsettingsdialog_q.ui) includes an "IPv4 Packet forwarding" combo box (linux24_ip_forward) with three options:
| UI Label | Stored Value | Effect in Compiled Script |
|---|---|---|
| No change | '' (empty) |
No echo statement is generated; the kernel setting is left as-is. FORWARD chain rules are still included (assumes forwarding is already enabled on the target). |
| On | '1' |
Generates echo 1 > /proc/sys/net/ipv4/ip_forward. FORWARD chain rules are included. |
| Off | '0' |
Generates echo 0 > /proc/sys/net/ipv4/ip_forward. FORWARD chain rules are excluded from the compiled output, since forwarding is explicitly disabled. |
The same three-state logic applies to IPv6 forwarding (linux24_ipv6_forward). The mapping between UI labels and stored values is defined in linux_settings_dialog.py (_COMBO_TEXT_TO_VALUE), and the script generation happens in _os_configurator.py using the ip_forwarding configlet.
In Firewall Builder, the default for new firewall objects was "On", which caused the compiled script to always write 1 to /proc/sys/net/ipv4/ip_forward -- even for hosts that do not forward traffic. We changed the default to "No change" so that the compiled script does not touch the kernel setting unless the user explicitly opts in.
The "linux24" Identifier¶
The identifier linux24 appears throughout the codebase -- in option names (linux24_ip_forward, linux24_path_iptables, ...), class names (OSConfigurator_linux24), configlet directories (resources/configlets/linux24/), and the GUI mapping in platform_settings.py (HOST_OS = {'linux24': 'Linux'}). Despite the name, it has nothing to do with a specific Linux kernel version.
The name originates from the early 2000s when fwbuilder was developed and Linux 2.4 was the current kernel introducing iptables support. As Linux evolved through 2.6, 3.x, 5.x, and 6.x, the iptables interface and kernel parameter paths (/proc/sys/net/ipv4/...) remained the same, so fwbuilder never renamed the identifier. It is defined in fwbuilder's res/os/linux24.xml as:
<Target name="linux24">
<description>Linux 2.4/2.6</description>
In FirewallFabrik, linux24 is a host OS identifier (not a platform). It represents "a Linux system managed via standard kernel parameters and iptables/ip6tables tools". The platform identifier is separate -- iptables or nftables -- and determines which compiler generates the output script. The relationship is: a firewall object has both a platform (what compiler to use) and a host OS (what kernel parameters and tool paths to configure), and linux24 is the only host OS we support.
Renaming it would break backward compatibility with existing .fwb files, configlet templates, and the compiler infrastructure, with no functional benefit. The UI displays it simply as "Linux" (see platform_settings.py).
Versioning: GUI vs. Compilers¶
The project maintains three independent version numbers:
| Component | Location | Example |
|---|---|---|
| GUI / Package | firewallfabrik/__init__.py → __version__ |
1.4.0rc1 |
| iptables compiler | platforms/iptables/__init__.py → __compiler_version__ |
1.4.0 |
| nftables compiler | platforms/nftables/__init__.py → __compiler_version__ |
1.4.0 |
The package version (__version__) is the version users see in fwf --version, the About dialog, and pip show firewallfabrik. It changes with every release — GUI features, bug fixes, documentation, anything.
The compiler versions (__compiler_version__) are embedded in the generated firewall scripts (e.g. # FirewallFabrik fwf-ipt v1.4.0). They change only when the compiler output changes — new configlets, rule generation fixes, template changes, etc. A purely cosmetic GUI change does not bump the compiler version.
This separation exists for determinism: if a user recompiles the same policy with a new GUI release that did not touch the compiler, the generated script must be byte-identical. Without separate versions, every GUI release would change the version header in every generated script, which breaks idempotency checks and produces unnecessary diffs.
When to bump which version:
- GUI-only change (dialog fix, tree behavior, etc.) → bump
__version__only. - Compiler change (configlet, template, rule processor, etc.) → bump the affected
__compiler_version__and__version__. - Both → bump all three.
GUI Architecture¶
.ui Files and the Custom UI Loader¶
PySide6's QUiLoader always creates a new top-level widget -- it cannot populate an existing one the way C++ Ui::setupUi(this) does. FWFUiLoader in gui/ui_loader.py works around this by overriding createWidget(): when the loader creates the top-level widget (parent is None), it returns the existing instance instead of allocating a new one. This lets .ui files fill an already-constructed QMainWindow or QWidget subclass with all its child widgets, menus, toolbars, and dock widgets.
The .ui files reference roughly 35 custom C++ widget class names (e.g. FirewallDialog, InterfaceDialog). CUSTOM_WIDGET_MAP in the same module maps each name to either a plain Qt base class (as a placeholder) or the real Python implementation once it has been ported. The loader consults this map whenever it encounters an unknown class name.
Tooltips in .ui Files¶
Tooltip strings in .ui files must wrap after 80 characters. Qt renders \n inside <string> elements as line breaks in the tooltip popup, so hard wraps in the XML source translate directly to the displayed text. This keeps the .ui source readable and prevents overly wide tooltip popups at runtime.
Main Window Layout¶
FWBMainWindow_q.ui defines the overall structure:
- Central widget -- a
QMdiArea(m_space) that hosts rule set views. - Dock widget (bottom) -- a
QTabWidgetwith an Editor tab and a Compiler Output tab. The Editor tab contains aQStackedWidget(objectEditorStack) with one page per object editor (Firewall, Host, IPv4, TCP Service, etc.). Clicking an object in the tree switches the stack to the matching editor page. - Menu bar, toolbar, and status bar -- all defined in the same .ui file.
An ObjectTree (left-hand side) is added programmatically and placed into a QSplitter alongside m_space.
File Naming Conventions¶
*_dialog.py for both popup dialogs and embedded editor panels. In fwbuilder, the C++ classes for editor panels are named *Dialog (e.g. FirewallDialog, TCPServiceDialog). The Python files mirror these names so that the fwbuilder source can be cross-referenced easily during porting. This means that files like device_dialogs.py (embedded editor panels in objectEditorStack) and preferences_dialog.py (an actual popup dialog) both use the _dialog suffix, even though they serve different purposes. This is intentional.
Singular vs. plural. Files containing multiple related editor classes use the plural form (device_dialogs.py for FirewallDialog, HostDialog, InterfaceDialog; service_dialogs.py for TCP/UDP/ICMP/IP). Files containing a single class use the singular form (time_dialog.py, library_dialog.py).
object_tree_*.py split. The object tree implementation is split across five files to keep each one focused:
| File | Responsibility |
|---|---|
object_tree.py |
Widget class, signal/slot wiring, event dispatch |
object_tree_actions.py |
Context-menu and keyboard-shortcut action handlers |
object_tree_data.py |
Constants: icon maps, type lists, folder-to-type mappings |
object_tree_menu.py |
Builds context menus and returns handler mappings |
object_tree_ops.py |
Database operations (create, delete, duplicate, move) |
policy_model.py / policy_view.py for all rule set types. Despite the name, these files handle Policy, NAT, and Routing rule sets. PolicyTreeModel accepts a rule_set_type parameter ('Policy', 'NAT', or 'Routing') and adapts its columns and behavior accordingly. The "policy" prefix was kept because Policy is the primary and most common rule set type.
Signals and Slots¶
Connections between signals and slots are established in two ways:
- In the .ui file -- the
<connections>section wires menu/toolbar actions to slot names (e.g.fileOpenAction.triggered()→fileOpen()). These are connected automatically when the .ui file is loaded. - In Python -- explicit
.connect()calls for signals not covered by the .ui file (e.g. custom tree signals, dynamically created widgets).
A slot must not be connected both ways. If a connection already exists in the .ui file, adding a Python .connect() call for the same signal/slot pair will cause the slot to fire twice per signal emission.
Firewall Builder Features Left Out Because YAML Replaced XML¶
Firewall Builder stores its data as XML, which no ordinary tool can diff or merge sensibly. It therefore had to bring its own. FirewallFabrik stores YAML, which every diff and merge tool already handles, so three of those features have no reason to exist here.
Rule set diff (RuleSetDiffDialog). Firewall Builder shows two rule sets
side by side and marks what differs. On a YAML file git diff, diff
--unified or any graphical diff viewer answers the same question, with
history and blame on top.
Find conflicting objects in two files (ObjConflictResolutionDialog).
Firewall Builder asks, object by object, which version wins when two data
files are merged. Merging XML by hand is impractical, so the dialog was the
only way. A YAML merge conflict is a normal three-way merge that git merge,
git mergetool and every editor resolve.
Template firewalls. The new-firewall wizard in Firewall Builder can start
from a template file so the new object arrives with a working policy instead
of an empty one. Copying a .fwf file and editing it does the same thing, and
keeping a template in version control is more useful than a second mechanism
inside the application. new_host_dialog.py therefore offers manual interface
configuration only.
The same reasoning does not extend to fwf-edit, the counterpart of
fwbedit: editing YAML by hand is possible, but a data file still has
cross-references and a schema that a text editor does not check. See
#146.
No Master Member in a Cluster¶
Firewall Builder lets one member of a cluster be the master: the New Cluster wizard has a "Master" column, the members dialog of a cluster group a "Master" checkbox, and the group stores the chosen interface as master_iface. Only three of its configurators read that value. The PIX compiler installs on the primary only and configures failover from it, the pf configurator gives the master the lower CARP advskew (OSConfigurator_bsd::interfaceConfigLineCARPInternal), and the secuwall configurator writes it into its VRRP settings. The iptables compiler copies it into interface options (failover_master, state_sync_master) that nothing reads afterwards.
FirewallFabrik compiles for iptables and nftables only, where the master has no effect on the generated script: which member is master is decided by the failover daemon and its own configuration (keepalived, heartbeat, corosync), which FirewallFabrik does not write. The value could not be kept either, because it is an object id, and object ids are regenerated on every load. So there is no master anywhere in FirewallFabrik. The readers drop master_iface, as well as the master_fw_id and member_fw_ids keys earlier versions wrote onto a cluster, and a file loses them on its next save.
A port of the pf compiler would have to bring the master back, stored as a reference to the member interface the way group members are.
A Failed iptables Activation Puts the Previous Ruleset Back¶
The start branch of the iptables script empties the ruleset before it installs the new one: reset_all sets the built-in policies to DROP in the shell form, and iptables-restore replaces each table in the restore form. In Firewall Builder a rule iptables refuses therefore leaves the machine with DROP policies and part of the new rules, on a remote machine usually without the rule that lets the administrator back in. Its installer once had a rollback, which rebooted the firewall, and dropped it in 4.2.0 as "too heavy-handed" (FirewallInstaller.cpp).
The generated script therefore saves the running ruleset with iptables-save and ip6tables-save right before it changes anything (fwf_save_ruleset in the script_skeleton configlet) and pipes it back into iptables-restore when the activation fails (fwf_rollback_and_exit), for both address families together, because an IPv6 failure comes after the IPv4 rules are already in place. The idea is Shorewall's, which restores its saved state the same way. A table the saved ruleset does not name did not exist before and is restored empty, with ACCEPT policies in filter.
What is not rolled back: kernel variables, interface addresses, ipsets, and nftables tables of other tools that nft flush ruleset removed. The nftables script does not need any of this, because nft -f loads a ruleset atomically and the script runs nft --check first.
tests/test_ipt_activation_rollback.py checks both forms in a network namespace, with the failure injected into the IPv6 part.
One Run of the Script at a Time¶
Both generated scripts take a lock with flock before a command that changes the firewall (fwf_lock, in the script_skeleton configlet and the nftables template), and wait up to a minute for it; status, test_interfaces and test_address_table only look and do not take it. Without it, two runs at the same time - an installation and a timer, or reload_address_table from cron during an activation - interleave their commands, and the second works on the first one's half-finished ruleset. The idea is Shorewall's mutex_on, but with flock instead of its lockfile and PID check, which Shorewall itself calls race-prone.
The lock file is /run/fwf.lock: /run belongs to root, so the file cannot be a symlink someone planted, and it is a tmpfs, so a lock never outlives a reboot. FWF_LOCK_FILE overrides it, which is what tests/test_script_lock.py uses in a network namespace. reload runs $0 start and exports FWF_LOCKED, so the child does not wait for the lock its parent holds. Without flock or without a writable lock file the script runs unlocked rather than not at all; Firewall Builder has no lock.
Connection Tracking Helpers Are Assigned by the Rule¶
Since Linux 4.7 the kernel attaches no connection tracking helper to a connection on its own (netfilter commit 3bb398d925ec), and since 6.0 there is no switch left to turn that back on (b118509076b3). Firewall Builder predates that and has no way to assign one, so FTP through a default-drop firewall stopped working when the kernels changed underneath it.
A TCP or UDP service names the helper (data['conntrack_helper'], the Helper field of the service editor); the catalog of helpers, their protocols, families and NAT modules is platforms/linux/_conntrack_helpers.py. A rule that accepts the service assigns the helper, the way firewalld attaches it to its services and Shorewall to a rule. SplitHelperAssignment makes a copy of every such rule early in both policy pipelines - before negation, logging and the optimisations move a rule into temporary chains that no longer carry its addresses or services - and the print rules write the copy as the assignment instead of as a rule: -j CT --helper in the raw table on iptables, ct helper set in front of the accept on nftables.
The RELATED connections a helper expects are accepted only between the rule's own ends, in either direction, in a helper chain per built-in chain, and every other RELATED connection of the helpers in use is dropped; RELATED connections without a helper, such as ICMP errors, pass as before. That is the recommendation of the netfilter developers in "Secure use of iptables and connection tracking helpers" (Leblond, Neira Ayuso, McHardy, Engelhardt), which firewalld does not follow. -m helper and ct helper both ask the helper of the master connection, which is what makes the split possible on either platform (net/netfilter/xt_helper.c, nft_ct.c).
Measured with vsftpd and in.tftpd behind a routing firewall in network namespaces on Rocky Linux 8 and 10 and Debian 12 and 13: passive and active FTP and TFTP over IPv4 and IPv6 on both platforms, the iptables script in both forms, and behind masquerading, where the NAT modules the script loads are what makes active FTP and TFTP work. Without the assignment, and without the NAT module, the same transfers fail.
A Rollback Timer Around the Activation¶
A policy that blocks the management connection locks the administrator out of a remote firewall. Firewall Builder's installer once guarded against that by scheduling a reboot before the activation (shutdown -r +{{$rbtimeout}}) and dropped it in 4.2.0 as "too heavy-handed" (fwbuilder commit 7bf0f8a4). The placeholder {{$rbtimeout}} stayed in the installer configlets without a value. FirewallFabrik fills it again, and puts back the saved ruleset instead of rebooting.
The generated script has three commands for it, in the rollback_timer configlet both packet filters share: try <seconds> saves the running ruleset, starts a timer and activates the policy, confirm stops the timer, and rollback puts the saved ruleset back at once, which is also what the timer runs. What is saved and how it goes back is per packet filter (fwf_timer_save and fwf_timer_restore): iptables-save per address family through the same fwf_restore_family the failed-activation rollback uses, and nft list ruleset, loaded again in one transaction behind a flush ruleset.
The ideas are Shorewall's try and safe-reload and ferm's --interactive. Reading their source showed three ways they still lock the administrator out, and each is handled here:
- The confirmation must come over a new connection. ferm reads
yesfrom the terminal of the session that activated the rules, Shorewallsafe-reloaddoes the same withread -t. Connection tracking lets that established session through under a policy that refuses every new one, so the answer proves nothing. The installer therefore confirms with a secondssh, withControlMaster=noandControlPath=noneso that a multiplexed channel of the first cannot stand in for it, and with a connect timeout of half the rollback timeout. - The timer must not belong to the session. Both tools wait in the foreground, and a session the new rules close - a REJECT with a TCP reset ends it at once - takes the process with it on SIGHUP, so nothing is put back. The timer here is a transient systemd unit (
systemd-run --on-active), outside the login session and immune to logind'sKillUserProcesses=yes, withsetsidas the fallback where systemd-run is missing or refused (an unprivileged namespace, a container).tryitself ignores SIGHUP, so an activation that cuts its own session still finishes. - A reboot must not bring the unconfirmed policy back. ferm edits its configuration in place, and a firewall that reboots after the rollback loads the rules again. The installer copies the script to
<script>.new, activates that, and moves it onto the path the firewall boots from only in the confirming command. Shorewall'strytakes a separate configuration directory for the same reason, and Firewall Builder's test install copied to a separate directory as well.
Four details keep the timer honest. The saved ruleset is loaded with iptables-restore --test or nft --check before anything is changed, so a try whose timer could not put it back is refused. A listing with rules of iptables-nft, for example, cannot be loaded by nft. A second try before the first is confirmed keeps the ruleset from before the first one. The state directory /run/fwf-rollback holds a copy of the script that saved the ruleset, and the timer runs that copy, so the saved files and the program that reads them stay together whatever is installed meanwhile. And each timer carries a token that the state directory names: a timer that was replaced but could not be stopped finds another token and does nothing.
Every command takes the lock of One Run of the Script at a Time, the timer included. The setsid timer is started with lock descriptor 9 closed and FWF_LOCKED emptied, otherwise it would hold the lock until it runs out, or skip it. A plain start or stop replaces an activation that waits for confirmation and stops its timer. Otherwise a deployment by other means, such as LFOps restarting the boot unit, would be undone when the timer ran out.
What is not put back is what the failed-activation rollback does not put back either: kernel variables, interface addresses, routes and ipsets. Because the whole ruleset goes back, a change another tool made to it while the timer ran is lost as well.
tests/test_rollback_timer.py checks both scripts in a network namespace, on the setsid path. The systemd path, the installer and a policy that drops or resets the SSH connection were run on Rocky Linux 8.3 (iptables 1.8.4, nftables 0.9.3, systemd 239) and Debian 11 (iptables 1.8.7, nftables 0.9.8, systemd 247): the new connection failed, the ruleset from before came back after the timeout, and the boot script was unchanged.
Importing a Running Ruleset¶
File > Import Firewall reads iptables-save, ip6tables-save and nft -j list ruleset (firewallfabrik/importer/). Two parsers write one neutral model, and one builder turns it into objects in the shape of the .fwf file, which the YAML reader merges into the open data file the way File > Import Library does. iptables and nftables are therefore mapped by the same code. The mapping follows Firewall Builder's importer (IPTImporter.cpp); what follows is where it had to go further, each found by comparing packet verdicts, not by reading output.
- A rule that is not carried over exactly fails closed. One that accepts or translates is imported disabled. One that drops or rejects is imported active without the part that could not be carried over: with fewer conditions it matches more and blocks more. Disabling it, as Firewall Builder does, lets through what the original stopped - the round trip over the corpus showed it in eight firewalls. Both are colored and their comment holds the original.
- The local side of the input and output chains is the firewall. A destination in the input chain, or a source in the output chain, is an address of this machine; read from a file nothing else says so, and the rule compiled into the forward chain. Such addresses go on an interface "imported" marked unprotected; a network, range, set or negation on that side goes into a rule set behind the firewall.
- A forward rule with an address range around one of the firewall's addresses goes into a rule set behind it as well. In the top rule set the compiler would add a copy to the output or input chain, as Firewall Builder's
splitIfSrcMatchesFwdoes, ahead of the rules imported there in their own right; in the corpus that copy accepted what the output chain dropped. A network does not do this, because the importer turns offfirewall_is_part_of_any_and_networks. - A FirewallFabrik rule has one interface and one service element. A second interface, a connection state next to a service, or a user next to a service goes into a rule set the rule branches into. The user stays in the rule itself, because the compiler accepts a user match only in the output chain.
gotois a branch followed by a return of the same match, which leaves the calling chain exactly where a goto would; firewalld reaches its zones that way.meta nfprotoin an inet table puts the action into a rule set compiled for that family only. An "all IPv4" network would say the same, but the compiler reads 0.0.0.0/0 as a network the firewall is on and moves a forward rule into the output chain.- One nft table can hold filter and NAT chains (firewalld since 1.0), and is read as two; a filter chain at the priority of mangle or below is not the filter.
- A jump into an empty NAT chain is left out, and so is the chain. firewalld jumps into a
_pre,_log,_deny,_allowand_postchain of every zone, most of them empty. Such a jump comes straight back, and the compilers refuse to branch into a NAT rule set without rules. A goto into an empty chain is a return, and a chain that only goes to empty ones is empty itself (firewalld before 1.0 reaches its zones that way). Policy chains stay, since an empty policy rule set compiles. - A masquerade rule stays a masquerade (
ipt_use_masq), rather than SNAT to the address the interface had when the script was compiled. With a negated outgoing interface, as in firewalld'soifname != "lo" masquerade, the translated source is the firewall, not the interface the rule excludes. queueis the Pipe action, which both compilers write as plainqueue/-j QUEUE, queue 0. A queue number other than 0,bypassorfanoutis a condition the rule cannot hold.- Routes are those of the main table an administrator configured,
proto bootorproto static. The generated script deletes every other route of the main table but the kernel's when it installs routes (therouting_functionsconfiglet), so a route of DHCP, router advertisements or a routing daemon would be pinned down by an imported copy, and is reported instead; the wizard leaves the routes unchecked while the table holds one. iproute2 printstype,protocol,tableandscopeonly where they are not the default (print_routeinip/iproute.c), andtable allwithout-4or-6lists both families, so the family is read off each route. - A blackhole, unreachable or prohibit route becomes policy rules, because the script deletes it with the other non-kernel routes and that would let through what it stopped. Two rules at the top of the Policy - the route decision comes before the filter - one for forwarded packets and one with the firewall as source, branch into a rule set that first returns what a more specific route of the main table (the kernel's included) or the firewall's own address takes, then drops or rejects with the code the kernel uses (
ip_error,ip6_pkt_discard,ip6_pkt_prohibit); IPv6 "no route" only exists as theicmp6-no-routetoken, whichreject_type_tokenpasses through. Verified by packet counters in a network namespace on both platforms. - Existing objects are used where they mean exactly the same, read the way the compiler reads them (
icmp_type_and_code). The Standard library's "Any" and "Dummy" are placeholders and never used; a service that assigns a connection tracking helper is never used for a plain one.
tests/test_importer.py covers each of these. tools/compiler-audit/roundtrip-import.py imports every compiled ruleset of the corpus back and compare-verdicts.py sends packets through the original and the result; compare-verdicts.py --pair compares a saved original (.nft or iptables-save .ipt) with what the generated script installed.