OPNsense / pfSense config.xml Data Structure Research¶
Research conducted Feb 2025 by analyzing upstream OPNsense and pfSense source code, config.xml samples, and the Go schema package. This document informs parsing accuracy and identifies gaps in our data model.
Sources¶
| Source | URL / Path | Purpose |
|---|---|---|
| OPNsense config.xml.sample | src/etc/config.xml.sample (GitHub) |
Default configuration template |
| OPNsense FilterRule.php | src/opnsense/mvc/app/models/OPNsense/Firewall/FilterRule.php |
Rule processing logic |
| OPNsense Rule.php | src/opnsense/mvc/app/models/OPNsense/Firewall/Rule.php |
MVC model definitions |
| pfSense filter.inc | src/etc/inc/filter.inc |
pf rule generation from config.xml |
| pfSense firewall_rules_edit.php | src/usr/local/www/firewall_rules_edit.php |
Web UI address handling |
| pfSense Bug #6893 | Redmine issue tracker | Self-closing tag inconsistency fix |
| Go schema package | pkg/schema/opnsense/*.go |
Our current data model |
1. XML Boolean Patterns¶
OPNsense/pfSense uses two distinct patterns for boolean-like values. Understanding these is critical for correct Go type selection.
1a. Presence-Based Booleans (Self-Closing Tags)¶
Element existing = true, absent = false. Content is irrelevant.
Upstream PHP pattern: isset($rule['log']), which counts an empty element, or !empty($rule['interfacenot']), which does not. Check which one the vendor uses: pfSense tests <disabled> on a filter rule with isset() and OPNsense with !empty(), so the same element reads differently on the two.
Go type: BoolFlag (custom type in pkg/schema/opnsense/common.go), or *string where the body must be ignored (<ipv6allow>, <any>, <floating> on an OPNsense legacy rule, and on a pfSense filter rule <floating>, <quick> and <disabled>)
| Element | Parent Context | Upstream Evidence |
|---|---|---|
<any/> |
<source>, <destination> |
isset($this->rule['source']['any']) in FilterRule.php |
<not/> |
<source>, <destination> |
isset($adr['not']) in filter.inc |
<disabled/> |
<rule> (pfSense filter) |
isset($rule['disabled']) in filter.inc |
<floating/> |
<rule> (pfSense filter) |
isset($rule['floating']) in filter.inc |
<floating/> |
<rule> (OPNsense legacy filter) |
isset($rule['floating']) in filter.lib.inc; <quick> beside it is !empty() in FilterRule.php (#976) |
<quick/> |
<rule> (pfSense filter) |
isset($rule['quick']) in filter.inc |
<disabled/> |
<rule> (NAT) |
isset($rule['disabled']) in filter.inc |
<log/> |
<rule> (filter) |
isset($rule['log']) sets $filterent['log'] = true |
<quick/> |
<rule> (OPNsense filter, legacy and model) |
parseBool,quick (!empty): content matters, <quick/> is not quick. Both FirewallFilterRule.Quick and the legacy Rule.Quick are *string (#976) |
<disableconsolemenu/> |
<system> |
config.xml.sample: self-closing |
<enable/> |
<rrd> |
config.xml.sample: self-closing |
<tcpflags_any/> |
<rule> (filter) |
isset($rule['tcpflags_any']) |
<nopfsync/> |
<rule> (filter) |
isset($rule['nopfsync']) |
<allowopts/> |
<rule> (filter) |
$filterent['allowopts'] = true |
<disablereplyto/> |
<rule> (filter) |
$filterent['disablereplyto'] = true |
<nosync/> |
<rule> (filter, NAT) |
$natent['nosync'] = true |
<nordr/> |
<rule> (NAT inbound) |
isset($natent['nordr']) |
<staticnatport/> |
<rule> (NAT outbound) |
Presence-checked in OPNsense |
<nonat/> |
<rule> (NAT outbound) |
Presence-checked |
<interfacenot/> |
<rule> (filter) |
!empty($rule['interfacenot']) |
<nottagged/> |
<rule> (filter) |
Match packets NOT tagged |
<trigger_initial_wizard/> |
<opnsense> (root) |
First-boot wizard trigger |
<ipv6allow/> |
<system> |
isset($config['system']['ipv6allow']), before 26.1 |
<enable/> |
<interfaces><wan> |
Interface enabled (pfSense uses BoolFlag) |
<enable/> |
<dhcpd><lan> |
DHCP scope enabled (pfSense uses BoolFlag) |
pfSense Bug #6893 note: Prior to pfSense 2.3.3, some code produced <tag/> while other code produced <tag></tag>. Both forms are valid XML and our *string / BoolFlag types handle both correctly via Go's encoding/xml.
pfSense filter rule note: Floating, Quick and Disabled on FilterRule are *string, so an element counts whatever it holds, including <disabled>0</disabled> and <quick>0</quick>. The one exception is the direction check at filter.inc L3376, which also requires <floating> to hold yes; a rule that fails it compiles as in whatever <direction> holds.
OPNsense legacy <disabled> note: On a rule under <filter> this element is not presence-based. From 17.7 the firewall comments a rule out on !empty() (parseIsComment in Rule.php), so <disabled/> and <disabled>0</disabled> are live rules and <disabled>no</disabled> is not. Through 17.1 filter.inc tested isset(). See section 3a.
OPNsense 26.1 ipv6allow note: 26.1 replaced <system><ipv6allow> with the value-based <OPNsense><Interfaces><settings><disableipv6>, which is inverted (see GOTCHAS §3.7). The legacy element is typed *string, not BoolFlag: isset() allows IPv6 for any body, including 0, which BoolFlag reads as false.
pfSense presence-based enable note: pfSense correctly parses Interface and DhcpdInterface <enable/> elements as presence-based using BoolFlag types. The public API converts to string "1" for backward compatibility.
1b. Value-Based Booleans¶
Element contains 1, yes, or a specific value. Absent or empty = false.
Upstream PHP pattern: the stored value is tested, so an empty element reads as false
Go type: string with value check, or BoolFlag (which now delegates non-empty body content to shared.IsValueTrue)
| Element | Parent | Values | Type | Notes |
|---|---|---|---|---|
<enable> |
<interfaces><wan> |
1 |
varies | pfSense: presence-based (see §1a). OPNsense: value-based. pfSense parser uses BoolFlag fork |
<enable> |
<dhcpd><lan> |
1 |
varies | pfSense: presence-based (see §1a). OPNsense: value-based. pfSense parser uses BoolFlag fork |
<blockpriv> |
<interfaces><wan> |
1 |
string |
Block private networks |
<blockbogons> |
<interfaces><wan> |
1 |
string |
Block bogon networks |
<dnsallowoverride> |
<system> |
1 |
BoolFlag |
Allow DNS override (migrated from int) |
<usevirtualterminal> |
<system> |
1 |
BoolFlag |
Virtual terminal (migrated from int) |
<pf_share_forward> |
<system> |
1 |
BoolFlag |
Shared forwarding (migrated from int) |
<lb_use_sticky> |
<system> |
1 |
BoolFlag |
Sticky load balancing (migrated from int) |
<disablenatreflection> |
<system> |
yes |
string |
NAT reflection disabled |
<enable> |
various OPNsense modules | 1 |
string |
Service/feature enabled |
1c. Design Rationale¶
The distinction is not arbitrary:
- Presence-based = flags typically absent (disabled, negation, special modes)
- Value-based = feature toggles typically enabled (with explicit
1)
Note: As of PR #577, BoolFlag now delegates non-empty element bodies to shared.IsValueTrue, so int or string toggle fields can migrate to BoolFlag when the semantics allow. This addresses issue #558 where OPNsense 26.1 emits on into fields previously typed as int, causing parse failures. The canonical truthy/falsy parsers are implemented in pkg/schema/shared/ (see §8b).
BoolFlag is NOT a drop-in for every value-based boolean. BoolFlag.MarshalXML emits a self-closing element for true and emits nothing at all for false. That means a field whose on-wire representation is always <tag>0</tag> or <tag>1</tag> (and must remain so on round-trip) cannot migrate to BoolFlag — the false case would disappear from the output. Criteria for safe migration:
- Absence of the element must be semantically equivalent to
falsein the target device's behavior (OPNsense/pfSense treat most toggles this way — absent = disabled). - No round-trip consumer must depend on the literal
<tag>0</tag>form when the underlying value is false.
If either criterion fails, prefer shared.FlexBool (always emits <tag>0</tag> or <tag>1</tag>) or keep the field as string and call shared.IsValueTrue at the converter.
2. Source/Destination Structure¶
The <source> and <destination> elements are the most complex sub-structures in firewall rules. Understanding their design is critical.
2a. XML Structure Examples¶
<!-- Match any address -->
<source><any/></source>
<!-- Match interface subnet -->
<source><network>lan</network></source>
<!-- Match specific IP/CIDR -->
<source><address>192.168.1.0/24</address></source>
<!-- Match alias -->
<source><address>MyAlias</address></source>
<!-- Negated match -->
<source><not/><network>lan</network></source>
<!-- With port (TCP/UDP only) -->
<destination><network>wan</network><port>443</port></destination>
<!-- Port range -->
<destination><any/><port>8000-9000</port></destination>
2b. Mutual Exclusivity¶
<any>, <network>, and <address> are mutually exclusive. Resolution priority (from OPNsense legacyMoveAddressFields):
<network>(highest priority)<address><any>/ implicit any (when none present)
2c. Valid <network> Values¶
- Interface names:
lan,wan,opt1,opt2, ... - Interface IP:
lanip,wanip,opt1ip - Special:
(self)(all local IPs) - Interface group names
- VIP names
2d. Port Range Delimiters¶
- In config.xml: hyphen (
80-443) - In pf rules: colon (
80:443) - OPNsense/pfSense code handles the conversion
3. Filter Rule Fields Reference¶
3a. Currently Modeled (in Rule struct)¶
| Field | XML Element | Go Type | Status | Phase |
|---|---|---|---|---|
| Type | <type> |
string |
Correct | Initial |
| Descr | <descr> |
string |
Correct | Initial |
| Interface | <interface> |
InterfaceList |
Correct | Initial |
| IPProtocol | <ipprotocol> |
string |
Correct | Initial |
| Protocol | <protocol> |
string |
Correct | Initial |
| Source | <source> |
Source |
Complete | Phase 1 |
| Destination | <destination> |
Destination |
Complete | Phase 1 |
| Target | <target> |
string |
Correct | Initial |
| SourcePort | <sourceport> |
string |
Correct | Initial |
| Disabled | <disabled> |
*string |
Converted | Phase 2 |
| Quick | <quick> |
*string |
Converted | #976 |
| Log | <log> |
BoolFlag |
Added | Phase 2 |
| Floating | <floating> |
*string |
Converted | #976 |
| Gateway | <gateway> |
string |
Added | Phase 2 |
| Direction | <direction> |
string |
Added | Phase 2 |
| Tracker | <tracker> |
string |
Added | Phase 2 |
| StateType | <statetype> |
string |
Added | Phase 2 |
| Updated | <updated> |
*Updated |
Correct | Initial |
| Created | <created> |
*Created |
Correct | Initial |
| UUID | uuid attr |
string |
Correct | Initial |
Disabled keeps the element's text because the firewall tests it with !empty(), not for presence (section 1a). convertLegacyFirewallRule applies that test with modelFlagSet. This table is the OPNsense Rule. On the pfSense FilterRule, Floating, Quick and Disabled are *string and tested with isset().
3b. Rate-Limiting and Advanced Fields (Added in Phase 3)¶
| Field | XML Element | Go Type | Phase |
|---|---|---|---|
| MaxSrcNodes | <max-src-nodes> |
string |
Phase 3 |
| MaxSrcConn | <max-src-conn> |
string |
Phase 3 |
| MaxSrcConnRate | <max-src-conn-rate> |
string |
Phase 3 |
| MaxSrcConnRates | <max-src-conn-rates> |
string |
Phase 3 |
| TCPFlags1 | <tcpflags1> |
string |
Phase 3 |
| TCPFlags2 | <tcpflags2> |
string |
Phase 3 |
| TCPFlagsAny | <tcpflags_any> |
BoolFlag |
Phase 3 |
| ICMPType | <icmptype> |
string |
Phase 3 |
| ICMP6Type | <icmp6-type> |
string |
Phase 3 |
| StateTimeout | <statetimeout> |
string |
Phase 3 |
| AllowOpts | <allowopts> |
BoolFlag |
Phase 3 |
| DisableReplyTo | <disablereplyto> |
BoolFlag |
Phase 3 |
| NoPfSync | <nopfsync> |
BoolFlag |
Phase 3 |
| NoSync | <nosync> |
BoolFlag |
Phase 3 |
| AssociatedRuleID | <associated-rule-id> |
string |
#877 |
OPNsense 18.7.10 to 26.1 leave an empty <rule/> under <filter> once the last legacy rule is deleted. Rule.IsPlaceholder identifies it and the converter skips it.
3c. Remaining Missing Fields (LOW importance)¶
| Field | XML Element | Recommended Go Type |
|---|---|---|
| Tag | <tag> |
string |
| Tagged | <tagged> |
string |
| OS | <os> |
string |
| DSCP | <dscp> |
string |
| DNPipe | <dnpipe> |
string |
| PDNPipe | <pdnpipe> |
string |
| DefaultQueue | <defaultqueue> |
string |
| AckQueue | <ackqueue> |
string |
| Max | <max> |
string |
| MaxSrcStates | <max-src-states> |
string |
| VLANPrio | <vlanprio> |
string |
| VLANPrioSet | <vlanprioset> |
string |
| SetPrio | <set-prio> |
string |
| SetPrioLow | <set-prio-low> |
string |
| InterfaceNot | <interfacenot> |
BoolFlag |
| NotTagged | <nottagged> |
BoolFlag |
4. Schema Gaps: Source and Destination¶
RESOLVED (Phase 1): All missing fields added directly to Source/Destination structs. Fields were added directly rather than via
RuleLocationembedding to preserve the*stringAny field and maintain backward compatibility with existing consumers.
4a. Current Implementation (Post-Phase 1)¶
// security.go
type Source struct {
Any *string `xml:"any,omitempty"`
Network string `xml:"network,omitempty"`
Address string `xml:"address,omitempty"`
Port string `xml:"port,omitempty"`
Not BoolFlag `xml:"not,omitempty"`
}
type Destination struct {
Any *string `xml:"any,omitempty"`
Network string `xml:"network,omitempty"`
Address string `xml:"address,omitempty"`
Port string `xml:"port,omitempty"`
Not BoolFlag `xml:"not,omitempty"`
}
4b. Previously Missing Fields (Now Resolved)¶
| Field | XML Element | Impact | Resolution |
|---|---|---|---|
Address |
<address> |
CRITICAL - IP/CIDR/alias data | Phase 1 |
Not |
<not> |
HIGH - Negated rule semantics | Phase 1 |
Port (Source only) |
<port> |
MEDIUM - Source port matching | Phase 1 |
Helper methods IsAny(), EffectiveAddress(), and Equal() were added to both types for safe comparison and address resolution following OPNsense priority rules.
4c. Existing RuleLocation Type¶
pkg/schema/opnsense/common.go defines RuleLocation with similar fields. It remains available for other use cases but was not embedded into Source/Destination to avoid complexity with the *string Any field and XML marshaling behavior.
5. Type Mismatches: string vs BoolFlag¶
Resolution Status¶
Converted to *string (presence only, the body is ignored):
- System: IPv6Allow (system.go, and pfSense system.go)
- FilterRule: Floating, Quick, Disabled (pfSense security.go). The direction check also reads
Floating's body (section 1a) - Rule: Floating (security.go)
Converted to *string (the body is tested with !empty()):
- Rule: Disabled, Quick (security.go)
- FirewallFilterRule: Enabled, Quick (security.go)
Converted to BoolFlag (presence-based — isset() in PHP):
- Rule: Log (security.go) — Phase 2.
Rule.Quickwas here too and moved to*stringunder #976, since the firewall tests it with!empty() - NATRule: Disabled, Log (security.go) — Phase 2
- InboundRule: Disabled, Log (security.go) — Phase 2
- System: DisableConsoleMenu (system.go) — Phase 3
- Firmware: Type, Subscription, Reboot (system.go) — Phase 3
- System.RRD: Enable (system.go) — Phase 3
- Rrd: Enable (services.go) — Phase 3
- OpnSenseDocument: TriggerInitialWizard (opnsense.go) — Phase 3
User.Expires, AuthorizedKeys, IPSecPSK and OTPSeed were also on this list. They hold values, not flags, and are strings now (see GOTCHAS.md §15.0).
Kept as string (value-based — == "1" in PHP):
The following fields use OPNsense MVC value-based semantics where <field>0</field> is valid and distinct from absent. BoolFlag would read an empty <field/> as true and would drop a 0 on marshal, breaking the == "1" / == "0" distinction. These remain string:
5a. Security (security.go) — value-based, kept as string¶
| Struct | Field | Type | Rationale |
|---|---|---|---|
| IDS.General | Enabled | string |
MVC field, uses == "1" with helper method |
| IDS.General | Ips | string |
MVC field, uses == "1" with helper method |
| IDS.General | Mode | string |
netmap is IPS; divert if a rule diverts |
| IDS.General | Promisc | string |
MVC field, uses == "1" with helper method |
| IDS.EveLog.HTTP | Enable | string |
MVC field, value-based |
| IDS.EveLog.HTTP | Extended | string |
MVC field, value-based |
| IDS.EveLog.HTTP | DumpAllHeaders | string |
MVC field, value-based |
| IDS.EveLog.TLS | Enable | string |
MVC field, value-based |
| IDS.EveLog.TLS | Extended | string |
MVC field, value-based |
| IDS.EveLog.TLS | SessionResumption | string |
MVC field, value-based |
| IPsec.General | Enabled | string |
MVC field, value-based |
| IPsec.General | Disablevpnrules | string |
MVC field, value-based |
5b. Services (services.go) — value-based, kept as string¶
| Struct | Field | Type | Rationale |
|---|---|---|---|
| Unbound | Enable | string |
Heavily used with == "1" across 10+ files |
| Monit.General | Enabled | string |
MVC field, value-based |
| Monit.General | Ssl | string |
MVC field, value-based |
| Monit.General | Sslverify | string |
MVC field, value-based |
| Monit.General | HttpdEnabled | string |
MVC field, value-based |
| Monit.Alert | Enabled | string |
MVC field, value-based |
| MonitService | Enabled | string |
MVC field, value-based |
5c. OPNsense module (opnsense.go) — value-based, kept as string¶
| Struct | Field | Type | Rationale |
|---|---|---|---|
| Kea.Dhcp4.General | Enabled | string |
MVC field, value-based |
| Kea.HighAvailability | Enabled | string |
MVC field, value-based |
| UnboundPlus.General | Enabled | string |
MVC field, value-based |
| UnboundPlus.General | Stats | string |
MVC field, value-based |
| UnboundPlus.General | Dnssec | string |
MVC field, value-based |
| UnboundPlus.General | DNS64 | string |
MVC field, value-based |
| UnboundPlus.General | RegisterDHCP* (x3) | string |
MVC field, value-based |
| UnboundPlus.General | No* fields (x2) | string |
MVC field, value-based |
| UnboundPlus.General | Txtsupport | string |
MVC field, value-based |
| UnboundPlus.General | Cacheflush | string |
MVC field, value-based |
| UnboundPlus.General | EnableWpad | string |
MVC field, value-based |
| UnboundPlus.Dnsbl | Enabled | string |
MVC field, value-based |
| UnboundPlus.Dnsbl | Safesearch | string |
MVC field, value-based |
| UnboundPlus.Forwarding | Enabled | string |
MVC field, value-based |
| SyslogInternal.General | Enabled | string |
MVC field, value-based |
| Netflow.Capture | EgressOnly | string |
MVC field, value-based |
| Netflow.Collect | Enable | string |
MVC field, value-based |
| Interfaces.Settings | DisableIPv6 | string |
MVC field, inverted |
| Interfaces.Settings | *Offloading (x3) | string |
MVC field, value-based |
| Interfaces.Settings | DisableVLANHWFilter | string |
MVC option: 0, 1 or 2 |
Alias (security.go) binds the model's <enabled> as *string, since an absent element has to stay distinct from an empty one. OPNsense loads an alias with no members when <enabled> is 0 or empty (filter_tables.conf, filter_core_get_port_alias), apart from the external and internal types, whose tables it leaves as they are (has_parser in alias_init.py). The converter reads those values as disabled and reads an absent element as enabled: the model's default is 1, OPNsense writes the element on every save, and a legacy or pfSense alias has none. The same struct binds <path_expression>, <proto>, <interface>, <expire> and <description> for the model, and <descr>, <url> and repeated <aliasurl> for the legacy <aliases> block. <expire> is a number of seconds, kept as string, after which pfctl -T expire purges unused addresses from an external alias (core.inc); the firewall reads it on no other type. A legacy alias keeps <url>, <aliasurl> and <address> together in its members, because on a url alias OPNsense up to 17.7 loads <address>, a url_ports alias loads it at the 17.1 tag and nothing from 17.1.11 through 17.7, and from 18.1 both load <aliasurl>. The other model elements stay unbound: <updatefreq> sets how often a list is fetched, <authtype>, <username> and <password> how it is fetched, and <counters> and <categories> concern neither.
5d. Interfaces and DHCP (interfaces.go, dhcp.go) — platform-specific¶
pfSense and OPNsense use different boolean semantics for interface and DHCP scope enable flags:
| Struct | Field | pfSense Type | OPNsense Type | Rationale |
|---|---|---|---|---|
| Interface | Enable | opnsense.BoolFlag |
string |
pfSense: presence-based <enable/> / absent. OPNsense: value-based <enable>1</enable>. pfSense parser uses platform-specific type fork (pkg/schema/pfsense/interfaces.go) with BoolFlag, converts to "1" for API compatibility |
| DhcpdInterface | Enable | opnsense.BoolFlag |
string |
pfSense: presence-based <enable/> / absent. OPNsense: value-based <enable>1</enable>. pfSense parser uses platform-specific type fork (pkg/schema/pfsense/dhcp.go) with BoolFlag, converts to "1" for API compatibility |
Implementation: The pfSense parser uses an intermediate decodeDocument type (pkg/parser/pfsense/decode_types.go) that decodes XML with BoolFlag-aware pfsense.Interface and pfsense.DhcpdInterface types, then converts to backward-compatible opnsense.Interfaces/opnsense.Dhcpd with string Enable fields set to "1" (enabled) or "" (disabled).
Static routes (network.go): both parsers share StaticRoute, and the vendors read it differently. OPNsense 26.1.10 (Routes model 1.0.1) replaced <disabled> with <enabled>, typed *string because get_staticroutes (util.inc) separates absent from empty: a present <enabled> decides, an empty one or 0 disables the route (PHP empty(), modelFlagSet in the converter), and only an absent one falls back to <disabled>. Neither reader checks the model version, so the element decides at any version. <disabled> stays a BoolFlag for that fallback. OPNsense tested it with isset() through 17.7 and has tested it with !empty() since 18.1, where Routes model 1.0.0 writes 0 or 1. BoolFlag agrees with !empty() on those two values and not on <disabled/> or <disabled>no</disabled>, which the model does not write. pfSense has no <enabled>, writes <disabled></disabled> and tests it with isset(), so its converter takes a route's state only from Disabled. StaticRoutes.Version binds the version attribute: 1.0.0 from 18.1 to 26.1.9, 1.0.1 from 26.1.10, and none before 18.1 or on pfSense. The OPNsense converter warns on any other value.
6. NAT Structure Issues¶
RESOLVED (Phase 4): All missing NAT outbound and inbound fields have been added.
6a. NAT XML Path¶
In OPNsense config.xml, inbound NAT (port forward) rules are at <nat><rule>, not <nat><inbound><rule>. The redirect address is in <target> and the redirect port in <local-port>; the port the rule matches on is <destination><port>. Nat.Inbound binds xml:"rule". Until #877 it bound xml:"inbound>rule" and read <internalip>, a shape no release writes, so no OPNsense port forward was read.
From 26.1 the Destination NAT model is mounted on the same path (/nat/rule+), so the rules do not move. The rule body changes:
- each rule has a
uuidattribute and a<sequence>, and the ruleset is built in<sequence>order rather than document order. A rule without one is numbered after the highest on load, and equal sequences are ordered by uuid <pass>(empty,passorrule) holds the filter rule association and<associated-rule-id>is written empty<disabled>,<nordr>,<nosync>,<log>and<not>are always written, as0or1- aliases and literal addresses are in
<network>rather than<address>, next to the interface macros, and an empty<network/>means any. A name that is both an interface and an alias resolves to the alias - from 26.7.4
<audit>(base64 JSON) replaces<created>and<updated>
OPNsense 18.7.10 to 25.7 leave an empty <rule/> under <nat> once the last port forward is deleted. InboundRule.IsPlaceholder identifies it and the converter skips it.
6b. NAT Outbound Rule Fields (Added in Phase 4)¶
| Field | XML Element | Type | Status |
|---|---|---|---|
| StaticNatPort | <staticnatport> |
BoolFlag |
Added |
| NoNat | <nonat> |
BoolFlag |
Added |
| NatPort | <natport> |
string |
Added |
| PoolOptsSrcHashKey | <poolopts_sourcehashkey> |
string |
Added |
6c. NAT Inbound Rule Fields (Added in Phase 4)¶
| Field | XML Element | Type | Status |
|---|---|---|---|
| NATReflection | <natreflection> |
string |
Added |
| AssociatedRuleID | <associated-rule-id> |
string |
Added |
| NoRDR | <nordr> |
BoolFlag |
Added |
| NoSync | <nosync> |
BoolFlag |
Added |
| LocalPort | <local-port> |
string |
Added |
| Target | <target> |
string |
Added (#877) |
| Sequence | <sequence> |
string |
Added (#877) |
| Pass | <pass> |
string |
Added (#877) |
| Audit | <audit> |
string |
Added (#877) |
| Category | <category> |
string |
Added (#877) |
| Tag | <tag> |
string |
Added (#877) |
| Tagged | <tagged> |
string |
Added (#877) |
| PoolOpts | <poolopts> |
string |
Added (#877) |
6d. Outbound NAT Mode Values¶
| Value | Meaning |
|---|---|
automatic |
Automatic outbound NAT (default) |
hybrid |
Hybrid: auto + manual rules |
advanced |
Manual outbound NAT only |
disabled |
Disable outbound NAT |
7. Key XML Design Decisions¶
7a. Dynamic Interface Keys¶
The <interfaces> section uses dynamic element names (<wan>, <lan>, <opt0>) rather than repeated <interface name="wan">. This requires custom unmarshaling in Go. Our map-based approach in Interfaces and Dhcpd types is correct.
7b. Comma-Separated Lists¶
Several fields pack multiple values into a single element:
<interface>wan,lan,opt1</interface>(floating rules)<icmptype>3,11,0</icmptype>(ICMP type list)- Space-separated:
<timeservers>0.ntp.org 1.ntp.org</timeservers>
Our InterfaceList custom type correctly handles the comma-separated <interface> case.
Gateway group tiers pack gateways the same way, but InterfaceList plays no part in them. OPNsense 26.7 maps <gateways><gateway_group> with a legacy mapper, so the element stays where it was and carries no version attribute. <item> holds the tier 1 gateways as one comma-separated list, and <item2> to <item5> hold tiers 2 to 5, bound as the strings GatewayGroup.Item2 to Item5. Up to 26.1 <item> repeats once per gateway as name|tier, or name|tier|vip up to 19.1 and on pfSense, which is why GatewayGroup.Item stays a slice. GatewayGroupItemField rewrites the legacy entries into the five fields when the migrations run at boot. The OPNsense converter's gatewayGroupItems reads either form into one name|tier entry per gateway. pfSense reuses the type, and its converter exports Item as written and does not read the tier fields.
7c. UUID vs Tracker¶
- OPNsense:
uuidattribute on rule elements (<rule uuid="...">) - pfSense:
<tracker>element (integer, auto-generated frommicrotime) - Both may be present in migrated configs
7d. Legacy vs MVC Model Split¶
OPNsense maintains two parallel systems:
- Legacy: Rules in
<filter><rule>and<nat><rule>(pfSense-compatible) - MVC/New-style: Rules in
<OPNsense><Firewall><Filter>with<rules>,<snatrules>, etc.
Both are loaded into the ruleset. Firewall rules are read from both stores: <filter><rule> and <OPNsense><Firewall><Filter><rules><rule>. The converter merges them in the order OPNsense 26.7.2 evaluates them: floating rules first, then rules on an interface group by the group's <ifgroups> sequence, then rules on one interface, with the model's rules ahead of the legacy ones within each rank. A config at filter model version 1.0.0 to 1.0.2, which only the os-firewall plugin wrote, is ordered as the plugin ordered it, with every model rule ahead of every legacy rule. The converter also turns the model's vocabulary into the legacy one. GOTCHAS §3.8 covers both, and how the core releases before 26.7.2 differ.
The NAT containers of the same model, <snatrules>, <npt> and <onetoone>, are bound as strings, so opnDossier reports none of their rules (#922, #923). OPNsense reads them: from 24.1 its ruleset takes manual source NAT rules from <snatrules> as well as from <nat><outbound><rule>. It takes NPT rules only from the model from 24.1, and one-to-one rules only from the model from 24.1.9.
Port forwards are the exception to the split: the 26.1 Destination NAT model is a legacy mapper on <nat><rule>, so both generations are read from that one path (see 6a).
Gateways use a chosen store, not a merge. OPNsense 24.1 migration M1_0_0 copies <gateways><gateway_item> into <OPNsense><Gateways> and, once one is stored, deletes the originals. OPNsense then reads the model when it holds a gateway and <gateways> otherwise, and the converter does the same (GOTCHAS §3.7). A <gateway_item> with no child element does not count in either store, as on current releases (OPNsense 24.1 to 24.1.2 still loaded one under <gateways>), and one with any child element does, whatever the child holds (GOTCHAS §3.4). The model keeps its items by the uuid attribute, bound on GatewayModelItem, so of two items with one uuid it holds the later, at the place of the earlier. An item with no attribute gets a fresh uuid on every release. An empty uuid or 0 gets a fresh one from 25.7 and is the key as it stands through 25.1.12, where two such items collide. The model version does not tell those releases apart, so the converter keeps both items with a warning. GatewayModelItem.UUID is a *string to keep absent apart from empty. Gateway groups stay under <gateways>.
The model writes its flags as 0 or 1 where the 23.7 page wrote 1 or left the element out. From 24.1 defaultgw and monitor_disable read with !empty() in both stores. monitor_disable is a *string because an absent element means 1 in the model and 0 under <gateways>. fargw reads with isset() up to 24.1.7 and !empty() from 24.1.8. From 24.1 disabled reads with !empty() in both stores too. In the model it is a *string on GatewayModelItem, read the same way; under <gateways> it stays a BoolFlag, on a type pfSense shares.
8. Correctly Implemented Patterns¶
8a. Source.Any / Destination.Any as *string¶
Go's encoding/xml produces "" for both <any/> and absent elements when using plain string. Using *string correctly distinguishes presence (non-nil) from absence (nil). Validated against FilterRule.php's isset() pattern.
8b. BoolFlag Custom Type¶
Correctly implements presence-based boolean semantics: absent element → false, <tag/> (empty body) → true, <tag>body</tag> → delegates to shared.IsValueTrue(body). This means <tag>0</tag>, <tag>off</tag>, and <tag>no</tag> correctly parse as false, fixing a latent bug where any element presence previously returned true regardless of content.
Shared boolean vocabulary: The pkg/schema/shared/ package defines the canonical liberal parsers:
IsValueTrue(s)/IsValueFalse(s)— recognizes "on"/"off", "yes"/"no", "1"/"0", "true"/"false", "enable"/"disable", "enabled"/"disabled" (case-insensitive)FlexBool— value-level liberal boolean for fields where element presence is not the signal (always emitted, content carries the boolean value)FlexInt— liberal int parser sibling (delegates toIsValueTruefor non-numeric content)
BoolFlag.UnmarshalXML layers presence semantics over shared.IsValueTrue — absent → false, <tag/> → true, <tag>body</tag> → IsValueTrue(body). FlexBool delegates to the same IsValueTrue helper for its body parsing, so the two types share the liberal vocabulary without one depending on the other. pfSense converters use shared.IsValueTrue directly (retired pfsense.isPfSenseValueTrue).
8c. InterfaceList Custom Type¶
Correctly handles comma-separated interface lists for floating rules.
8d. Interfaces and Dhcpd Map Types¶
Correctly handle dynamic interface element names via custom UnmarshalXML/MarshalXML.
8e. decodeDocument Intermediate Layer (pfSense parser)¶
The pfSense parser uses a two-stage architecture for correct presence-based boolean handling:
-
Decode stage: XML is decoded into an intermediate
decodeDocumenttype (defined inpkg/parser/pfsense/decode_types.go) that uses platform-specific types with BoolFlag-aware Enable fields: -
pfsense.InterfacewithEnable opnsense.BoolFlag(frompkg/schema/pfsense/interfaces.go) -
pfsense.DhcpdInterfacewithEnable opnsense.BoolFlag(frompkg/schema/pfsense/dhcp.go) -
Conversion stage: The intermediate document is converted to a
pfsense.Documentwith backward-compatible opnsense types viatoDocument(): -
BoolFlag Enable fields are transformed to string:
true→"1",false→"" - This preserves API compatibility with existing consumers expecting string Enable fields
This architecture resolves the type mismatch issue described in Section 5 by using platform-specific type forks at the decode layer while maintaining a unified public API. It correctly distinguishes pfSense's presence-based <enable/> (element present = enabled) from absent elements (disabled), which was previously mishandled when using string types that cannot distinguish <enable/> from <enable></enable> from absent elements.
9. Action Items (Priority Order)¶
CRITICAL: Add— COMPLETEAddressfield toSourceandDestinationstructsHIGH: Add— COMPLETENotfield (BoolFlag) toSourceandDestinationHIGH: Add— COMPLETEPortfield toSourcestructHIGH: Add— COMPLETELogfield (BoolFlag) toRulestructHIGH: Convert— COMPLETERule.Disabled,NATRule.Disabled,InboundRule.DisabledfromstringtoBoolFlagHIGH: Add— COMPLETEFloating,Gateway,Directionfields toRuleMEDIUM: Convert— COMPLETEstruct{}fields in system.go toBoolFlagMEDIUM: Add rate-limiting fields to— COMPLETE (Phase 3: 14 fields added — rate-limiting, TCP/ICMP, state/advanced)Rule(max-src-nodes, etc.)- MEDIUM: Convert IDS/IPsec Enabled fields from
stringtoBoolFlag - LOW: Add remaining missing filter rule fields (tag, tagged, DSCP, etc.)
LOW: Add missing NAT rule fields— COMPLETE (Phase 4: NATRule +4 fields, InboundRule +5 fields)
10. Remaining Work¶
10a. Medium Priority¶
| Item | Description | Rationale |
|---|---|---|
| #9 | Convert IDS/IPsec Enabled fields from string to BoolFlag |
These use MVC value-based semantics (== "1") which BoolFlag cannot represent correctly. Requires a new MVCBool type or keeping as string with helper methods. Current helper methods (IsEnabled(), IsIPSMode()) work correctly with string. |
10b. Low Priority¶
| Item | Description | Fields |
|---|---|---|
| #10 | Add remaining filter rule fields | tag, tagged, os, dscp, dnpipe, pdnpipe, defaultqueue, ackqueue, max, max-src-states, vlanprio, vlanprioset, set-prio, set-prio-low, interfacenot, nottagged |
10c. Phase Summary¶
| Phase | Scope | Fields Added | Status |
|---|---|---|---|
| 1 | Source/Destination gaps | Address, Port (Source), Not | Complete |
| 2 | High-priority Rule fields | Log, Disabled→BoolFlag, Quick→BoolFlag (now *string, #976), Floating, Gateway, Direction, Tracker, StateType |
Complete |
| 3 | Rate-limiting and advanced fields | 14 fields (max-src-*, TCP/ICMP, state/advanced) | Complete |
| 4 | NAT rule enhancements | NATRule +4 fields, InboundRule +5 fields | Complete |
| 5 | Documentation and validation | Research doc updates, field reference, validator enhancements | Complete |