Platform and OS Defaults (Single Source of Truth)¶
Problem¶
Firewall and host OS options are stored as a JSON dict in the SQLAlchemy options column. Because JSON is schema-free, there was no single authoritative place that defined which keys exist, what types they have, or what their default values are. Defaults were scattered across:
- Hardcoded Python dicts in GUI dialog files
- ORM model defaults
- Implicit assumptions in the compiler
This led to:
- Silent failures from typos -- a misspelled key (e.g.
log_perfixinstead oflog_prefix) would be stored without error but silently ignored by the compiler. - Inconsistent defaults -- the GUI, the compiler, and new-object creation could each assume a different default for the same option.
- No visible defaults in the GUI -- text fields showed no placeholder text indicating what the compiler would use if left empty.
- No tooltips -- users had to guess what each setting does.
Solution¶
All option definitions now live in YAML files co-located with the platform packages:
src/firewallfabrik/platforms/
iptables/defaults.yaml # 47 options
nftables/defaults.yaml # 49 options
linux/defaults.yaml # 33 host OS options
Each option entry contains:
| Field | Purpose |
|---|---|
type |
Data type: bool, str, int, enum, text, tristate |
default |
The canonical default value (used for seeding new objects and GUI population) |
supported |
Whether the compiler uses this option (true/false) |
widget |
Name of the Qt widget in the .ui file (or ~ for options without a widget) |
placeholder |
(str only) Placeholder text for the GUI when default is empty |
description |
Human-readable description, used as GUI tooltip |
values |
(enum only) List of allowed values |
inverted |
(bool only) Whether the checkbox has inverted semantics |
label |
(linux only) Associated QLabel widget name, for disabling |
nftables_supported |
(linux only) Whether the option is relevant for nftables — the editor greys the field out when it is false, so it has to be true for anything the generated nftables script uses |
Example from nftables/defaults.yaml:
log_prefix:
type: 'str'
default: 'RULE %N -- %A '
supported: true
widget: 'logprefix'
description: >-
Prefix string for log messages. Supported macros:
%N = rule number, %A = action, %I = interface name,
%C = chain name, %R = rule set name.
Loader API¶
The module firewallfabrik.platforms._defaults provides cached access to the YAML schemas:
| Function | Returns |
|---|---|
get_platform_defaults(platform) |
Full schema dict for a compiler platform (iptables or nftables) |
get_os_defaults(os_name) |
Full schema dict for a host OS (e.g. linux24) |
get_default_values(platform) |
{key: default} for supported options only -- used to seed new firewall objects |
get_os_default_values(os_name) |
{key: default} for supported OS options |
get_option_default(platform, os_name, key) |
Single option default, checking platform then OS |
get_known_keys(platform, os_name) |
Set of all valid option keys |
validate_options(platform, os_name, options) |
List of warnings for unknown keys in an options dict |
YAML files are loaded once via @functools.cache and importlib.resources.
How Defaults Flow Through the System¶
1. New Object Creation¶
When a new Firewall is created (new_device_dialog.py), get_default_values(platform) seeds the initial options dict with all supported defaults. This dict is stored as JSON in the database.
2. GUI Settings Dialogs¶
The settings dialogs (iptables_settings_dialog.py, nftables_settings_dialog.py, linux_settings_dialog.py) load the YAML schema at import time and use it for:
- Widget mapping -- which widget corresponds to which canonical option key
- Tooltips --
entry['description']is set viasetToolTip() - Placeholder text --
entry['placeholder']orentry['default']is shown as grey text inQLineEditfields - Unsupported marking -- widgets for
supported: falseoptions are disabled - Populate fallback -- if an option is missing from the stored JSON, the YAML default is used for populating the dialog
3. Compiler / ORM (get_option())¶
Host.get_option(key) resolves an option value using a two-tier lookup:
- Explicit value in
self.options[key](the JSON dict stored in the database). - YAML default from
platforms/<platform>/defaults.yamlorplatforms/<os>/defaults.yaml.
If the key is not found in either tier, get_option() raises a KeyError. This catches typos in compiler code (e.g. get_option('acept_established')) at the earliest possible moment -- the first test run will fail with a clear error message instead of silently returning None.
The method accepts no caller-supplied fallback. All defaults live in the YAML files. Compiler call sites simply call fw.get_option('some_key') without a second argument.
The second argument it does take is a platform, and only a driver passes it. get_option() resolves the schema through fw.platform, and a firewall imported from a .fwb file says iptables whatever it is compiled with, because Firewall Builder has no other Linux platform. CompilerDriver.firewall_option(fw, key) names the platform the driver compiles for, so the nftables driver reads the nftables schema. Read a firewall option in a driver through that method and nowhere else: fw.options.get(key, something) puts a second default beside the one in the YAML file, and the two drift.
A boolean is compared after every whitespace character is removed from it, the way FWObject::getBool does it (firewallfabrik.core._options.option_is_true). A data file may write the value on a line of its own, and '\n True \n' == 'true' is False while bool('\n False \n') is True - so without the removal the same file answers the same question both ways. The removal belongs to the boolean test alone: a log prefix ends in a space on purpose.
Note:
rule.get_option(key, default)onCompRuleobjects is a different method that still accepts a caller-supplied default, because rules have their own per-rule options dict and no YAML schema.
String values "True" / "False" (common in XML imports) are coerced to Python bools.
Zero Is Not Always a Value¶
Four host OS options are numbers whose default is -1, meaning "leave the
kernel setting alone": linux24_conntrack_max,
linux24_conntrack_hashsize, linux24_tcp_fin_timeout and
linux24_tcp_keepalive_interval. A stored 0 means the same thing, and
the OS configurator maps it to -1 before it decides whether to emit the
line at all. Firewall Builder does the same and says why above the two
conntrack ones (OSConfigurator_linux24.cpp), because every .fwb it
writes carries 0 for a field the administrator left alone.
The mapping is load-bearing, not cosmetic. nf_conntrack_max of 0 makes
ct_count > nf_conntrack_max true for every new connection
(net/netfilter/nf_conntrack_core.c), so the box logs "table full,
dropping packet" and stops passing traffic; nf_conntrack_hash_resize
answers 0 with -EINVAL; tcp_fin_timeout of 0 ends a connection before
it can close in order.
The spin boxes in linuxsettingsdialog_q.ui therefore start at -1 and
show "kernel default" there. A field whose default the editor cannot show
turns that default into whatever its minimum happens to be on the next
save, which is how the zero got into the data files in the first place.
The Release a Firewall Is Compiled For¶
The version field on the firewall object is not an option and lives
beside platform and host_OS in the object's data, not in options.
It says which release of the packet filter the generated script has to
work on, and both compilers gate parts of their output on it.
A release belongs to the platform the firewall names. Firewall
Builder says so by taking the platform as the argument of
getVersionsForPlatform (libgui/platforms.cpp:418), and both
get_iptables_version and get_nftables_version ask it before they read
the field: lt_1.2.6 is an iptables release and 0.9.3 an nftables one,
and each is below every gate of the other platform, so reading it there
would silently take away every version-gated match. Either compiler can
be handed either firewall - the CLI takes the platform from the command
it was called as, and the audit corpus compiles every firewall for both.
An empty value means the newest. Without a pinned release the target
is whatever the machine runs, which for every currently supported
distribution is iptables 1.8.x and nftables 1.x. Firewall Builder reads
the empty value as the oldest instead (version_compare("", ...) is
negative and its pipeline switches on it), which is why its reference
output writes an address range out as covering networks where fwf uses
-m iprange. That difference is deliberate and accounts for a large
part of the missing column in compare-reference.sh.
The list the editor offers is PLATFORM_VERSIONS in
gui/platform_settings.py: Firewall Builder's own list for iptables,
value for value, and for nftables the releases at which this compiler's
output changes - 0.9.1 for the flags dynamic set a per-source
connection limit counts in and for the name of a standard chain priority
(priority filter rather than priority 0, which is the first line of
every base chain), 0.9.2 for ip option <name> exists, 0.9.3 for
meta hour / meta day / meta time and 0.9.5 for snat prefix to /
dnat prefix to. Everything else the nftables compiler emits is 0.9.0
or older, and ct count, auto-merge and log flags are the three that
reach that floor. Add a row to that list
whenever a new construct needs a release newer than one a supported
distribution ships, and a matching constant in
platforms/nftables/_utils.py.
The placeholder Field¶
Some options have an empty-string default ('') but the GUI should show a meaningful hint. For these, the YAML entry includes a placeholder field:
linux24_path_iptables:
type: 'str'
default: ''
placeholder: '/sbin/iptables'
description: >-
Path to the iptables binary.
Leave empty to use the compiler default.
The dialog's _apply_placeholders() method checks placeholder first, then falls back to default. This lets the GUI show a meaningful hint even when the stored default is an empty string.
Important: Only use
placeholderfor options where an empty string genuinely means "use the compiler's built-in logic" (e.g. tool paths, where the compiler has its ownDEFAULT_TOOL_PATHSdict). For options where the default is a concrete value, setdefaultdirectly -- do not leavedefaultempty and hide the real value in a Pythonorfallback.
Adding a New Option¶
- Add the entry to the appropriate
defaults.yamlfile (alphabetical order). If both platforms have the option, give it the same default in both: the same option means the same thing on either, and a firewall switched from one to the other must not change what its script does.tests/test_option_defaults_are_the_only_defaults.pyasserts that, and that no driver reads such a key out of the raw options dict. - If it needs a GUI widget, add the widget to the
.uifile and set thewidgetfield. - The settings dialog will pick it up automatically via the YAML-driven widget maps.
- The compiler reads the value via
fw.get_option('key')-- the YAML default is returned automatically if the option is absent from the stored JSON. If you forget to add the YAML entry,get_option()raisesKeyErrorimmediately.
JSON Remains the Storage Format¶
The options column still stores a JSON dict in the SQLite database. JSON holds the user-set values. The YAML files define the schema and defaults. If a key is absent from JSON, get_option() returns the YAML default automatically. If the key is absent from both JSON and YAML, get_option() raises KeyError -- there is no silent fallback to None.