pfSense Schema Implementation Status#
Lead Section#
The pfSense schema implementation enables parsing and analysis of pfSense firewall configuration files alongside OPNsense support in opnDossier. The implementation was completed in PR #459, which delivered full pfSense support including schema DTOs, DeviceParser implementation, validator, comprehensive test fixtures, and integration tests.
The pfSense schema follows the established OPNsense pattern while implementing strategic code reuse. The implementation comprises 5 struct files in pkg/schema/pfsense/ (document.go, system.go, security.go, services.go, network.go) that import and reuse 25+ OPNsense types where XML structures are identical—including BoolFlag, InterfaceList, container patterns, and Source/Destination structs—while forking locally at divergence points like root element naming (<pfsense> vs <opnsense>), password field format (<bcrypt-hash> vs <passwd>), rule tracking (<tracker> element vs uuid attribute), and NAT inbound structure (<target> vs <internalip>).
The pfSense parser (pkg/parser/pfsense/) implements a complete converter pipeline (system, network, security, services) that transforms pfsense.Document → CommonDevice, with automatic registration via init() for factory auto-detection of <pfsense> root elements. Test fixtures cover pfSense 2.6.x and 2.7.x configurations, and integration tests verify end-to-end processing.
Implementation Status#
Current State#
pfSense support is fully implemented in opnDossier as of PR #459, with 18 CommonDevice subsystems not yet implemented (see the device support matrix for per-subsystem coverage). The pfSense converter emits a ConversionWarning at SeverityMedium with the stable message text "not yet implemented in pfSense converter" for each unimplemented subsystem so compliance consumers can detect the gap programmatically.
The complete implementation includes:
Schema Layer (pkg/schema/pfsense/)
- document.go (93 lines): Root
Documentstruct withxml:"pfsense"tag, 30+ top-level sections - system.go (98 lines): pfSense-specific
User(with<bcrypt-hash>),Group(with[]stringprivileges),Systemwith DNS server array - security.go (104 lines):
FilterRule(with<tracker>element),InboundRule(with<target>field), NAT structures - services.go (64 lines):
UnboundConfig,SyslogConfig,Cron,Widgets,Diag - network.go (91 lines):
DHCPv6(with RA mode/priority), network interface types - README.md (838 lines): Comprehensive pfSense config.xml structural reference documenting 50+ sections, listtags, version mapping
Parser Layer (pkg/parser/pfsense/)
- parser.go (106 lines):
DeviceParserimplementation with secure XML decoding, context cancellation, auto-registration viainit() - converter.go (239+ lines): Main converter with warning accumulation, gap detection API (
PfsenseKnownGapMessageconst,IsKnownGap(),KnownGaps()) - converter_system.go (part of converter.go): System configuration transformation
- converter_network.go (182 lines): Network subsystem conversion (interfaces, DHCPv4/v6, gateways, static routes)
- converter_security.go (304 lines): Security conversion (filter rules with tracker ID, NAT inbound/outbound)
- converter_services.go (285 lines): Services conversion (Unbound, SNMP, OpenVPN, syslog)
- helpers.go (18 lines): Utility functions
- constants.go (14 lines): pfSense-specific constants
Validation Layer (internal/validator/)
- pfsense.go (488 lines): Semantic validator for pfSense configurations (system, filter, NAT, users, groups)
- pfsense_test.go (622 lines): Comprehensive validation test coverage
Shared XML Utilities (pkg/parser/)
- xmlutil.go (50 lines): Shared
NewSecureXMLDecoderandCharsetReaderfor XXE protection, size limits, Windows-1252 support - xmlutil_test.go (116 lines): Charset and security hardening tests
- parity_test.go (127 lines): Cross-platform subsystem parity test that asserts pfSense either populates what OPNsense populates or lists it in
pfsense.KnownGaps()
Test Fixtures (testdata/pfsense/)
- config-2.6.x.xml (145 lines): pfSense 2.6.x sample configuration
- config-2.7.x.xml (137 lines): pfSense 2.7.x sample configuration
- config-pfSense.xml (1146 lines): Full-featured production-like configuration
Test Coverage
- parser_test.go (1241 lines): Round-trip parsing, security hardening, converter subsystems, edge cases
- integration_test.go (81 lines added): End-to-end pfSense config processing
Infrastructure Changes
cmd/root.go: Blank import for pfSense parser registrationpkg/model/device.go: AddedIPProtocolInet46constant,DisplayName()method forDeviceTypeinternal/converter/markdown.go: Dynamic device type names in report headers- Factory auto-detection:
<pfsense>root element dispatch
User-Facing Documentation
docs/user-guide/device-support-matrix.md(64 lines): Per-subsystem coverage table showing OPNsense/pfSense ✅/❌ status, cross-referenced from README and mkdocs nav
Dependency Resolution#
The pfSense implementation resolved all architectural blockers:
v1.3.0: Public APIs (#199) ✅ Completed (PR #404, Mar 2026)
↓
v2.0.0: Registry Pattern (#302) ✅ Completed (PR #437)
↓
v2.1.0: pfSense Support (#197) ✅ DELIVERED (PR #459, Mar 2026)
Issue #197 is now closed.
Public Gap Detection API#
The pfSense converter provides a programmatic interface for external consumers (compliance plugins, audit filters) to detect which CommonDevice subsystems are not yet implemented:
Constants
pfsense.PfsenseKnownGapMessage(string): The stable message text"not yet implemented in pfSense converter"emitted for every unimplemented subsystem. Consumers can match on this exact substring to filter warnings without parsing field names. The wording is part of the public contract and will remain stable across minor releases.
Functions
pfsense.IsKnownGap(field string) bool: Returnstrueiffieldnames a CommonDevice subsystem the pfSense converter knowingly does not populate. The comparison is case-sensitive and matches the exact field name (e.g.,"HighAvailability","KeaDHCP"). Used by the parity test and by external tools that need to distinguish "feature absent in config" from "converter gap."pfsense.KnownGaps() []string: Returns a fresh copy of the unimplemented subsystem list (18 entries: Theme, Bridges, GIFs, GREs, LAGGs, VirtualIPs, InterfaceGroups, NTP, HighAvailability, IDS, Sysctl, Packages, Monit, Netflow, TrafficShaper, CaptivePortal, Trust, KeaDHCP). Callers can iterate the full list without risking mutation of the package-level slice.
Parity Test
pkg/parser/parity_test.go(127 lines): Asserts that every CommonDevice subsystem OPNsense populates from a representative fixture is either populated by pfSense or listed inpfsense.KnownGaps(). Runs in default CI (no build tag). Adding a new OPNsense subsystem without wiring pfSense coverage (or adding the field to the gap list) fails the test loudly.
User-Facing Coverage Table
- See device support matrix for the complete per-subsystem coverage table (OPNsense vs pfSense, ✅/❌ status).
File Organization#
Implemented Schema Structure#
The pfSense schema is organized across 5 core files in pkg/schema/pfsense/:
| File | Lines | Responsibilities | OPNsense Reuse |
|---|---|---|---|
document.go | 93 | Root Document struct with xml:"pfsense" tag; 30+ top-level sections | Imports Interfaces, Dhcpd, Snmpd, OpenVPN, Gateways, StaticRoutes, VLANs, PPPs, Rrd, LoadBalancer, Revision, CA/Cert types |
system.go | 98 | System config, WebGUI, SSH, users (with <bcrypt-hash>), groups (with []string privileges), DNS servers (array) | Imports SSHConfig, WebGUI base; forks User, Group, System.DNSServers |
security.go | 104 | FirewallRules (with <tracker>), NAT inbound (with <target>), NAT outbound | Imports Source, Destination, Outbound (NAT); forks FilterRule, InboundRule |
services.go | 64 | Unbound, Syslog, Cron, Widgets, Diag | pfSense-specific implementations |
network.go | 91 | DHCPv6 (with RAMode/RAPriority) | Forks DHCPv6 structure, otherwise imports OPNsense network types |
Total: 450 lines of pfSense-specific schema code, plus extensive OPNsense type reuse.
Schema Reuse Pattern#
The implementation follows the "fork at divergence" strategy documented in AGENTS.md:
Direct OPNsense Imports (25+ types):
- BoolFlag: Presence-based boolean (
<disabled/>= true, absent = false) - InterfaceList: Parses comma-separated interface strings
- Source/Destination: Address resolution logic
- Container patterns: VLANs/VLAN, Bridges/Bridge, Gateways/Gateway
- Map-based parsers: Interfaces (custom UnmarshalXML), Dhcpd
pfSense-Specific Forks (6 divergence points):
- User: Uses
<bcrypt-hash>instead of<passwd> - Group: Uses
[]stringfor privileges instead of singlestring - System.DNSServers: Uses
[]string(repeating<dnsserver>elements) instead of single string - FilterRule: Adds
<tracker>element (integer), includes<tag>/<os>fields - InboundRule: Uses
<target>instead of<internalip>for NAT destination IP - DHCPv6Interface: Adds
RAModeandRAPriorityfields
Key Architectural Patterns#
1. OPNsense Type Reuse#
The pfSense schema imports and reuses 25+ OPNsense types where XML structures are identical:
Direct Reuse (Zero Changes Required)
- BoolFlag:
UnmarshalXMLfor empty-element booleans (<log/>,<disabled/>) - InterfaceList: Parses comma-separated interface strings (
wan,lan,opt1) - Source/Destination: Address resolution logic (
Network > Address > Any) - Container patterns: VLANs/VLAN, Bridges/Bridge, Gateways/Gateway
- Map-based parsers: Interfaces (custom UnmarshalXML), Dhcpd
Requires pfSense Variants (Implemented)
- User: Uses
<bcrypt-hash>instead of<passwd>(implemented insystem.go) - Group: Uses
[]stringfor privileges instead of singlestring(implemented insystem.go) - FilterRule: Adds
<tracker>element (integer), includes<tag>/<os>fields (implemented insecurity.go) - InboundRule: Uses
<target>instead of<internalip>for NAT destination IP (implemented insecurity.go) - System.DNSServers: Uses
[]stringarray for repeating<dnsserver>elements (implemented insystem.go) - DHCPv6Interface: Adds
RAModeandRAPriorityfields (implemented innetwork.go)
2. Container Pattern#
All XML collection elements follow the plural-parent/singular-child pattern with XMLName as the first field:
type VLANs struct {
XMLName xml.Name `xml:"vlans"` // Must be first field
VLAN []VLAN `xml:"vlan,omitempty"`
}
type VLAN struct {
XMLName xml.Name `xml:"vlan"` // Must be first field
If string `xml:"if,omitempty"`
Tag string `xml:"tag,omitempty"`
Descr string `xml:"descr,omitempty"`
}
This pattern applies to: VLANs, Bridges, Gateways, StaticRoutes, GIFs, GREs, LAGGs, and all repeating elements.
3. Temp-Variable-Append Pattern#
The converter must follow a 5-step pattern for every slice conversion:
func (c *Converter) convertBridges(doc *schema.PfSenseDocument) []common.Bridge {
// Step 1: Return nil (not []Bridge{}) for empty input
if len(doc.Bridges.Bridge) == 0 {
return nil
}
// Step 2: Pre-allocate with capacity
result := make([]common.Bridge, 0, len(doc.Bridges.Bridge))
// Step 3: Loop
for _, b := range doc.Bridges.Bridge {
// Step 4: Append inline struct literal
result = append(result, common.Bridge{
BridgeIf: b.Bridgeif,
Members: splitNonEmpty(b.Members, ","),
Description: b.Descr,
})
}
// Step 5: Return result
return result
}
Critical rules:
- Return
nilfor empty (ensures consistent JSON/YAML serialization) - Pre-allocate:
make([]T, 0, len(source)) - Interface group members use space separator, all others use comma
4. Warning Accumulation#
The converter must return three values: (*common.CommonDevice, []common.ConversionWarning, error). Warnings include:
- Field: Dot-path notation (
FirewallRules[0].Type) - Value: Contextual identifier (UUID, name, or sibling field)
- Message: Human-readable description
- Severity: Critical, High, Medium, Low, Info
Example from firewall rule converter:
if rule.Type == "" {
c.addWarning(
fmt.Sprintf("FirewallRules[%d].Type", i),
rule.Tracker, // pfSense-specific: use tracker instead of UUID
"rule has empty type",
common.SeverityHigh,
)
}
5. Copy-on-Write Enrichment#
The prepareForExport function is platform-agnostic and requires no changes for pfSense:
- Shallow copy original
CommonDevice - Compute statistics from unredacted data
- Apply selective redaction (certificates, API keys, HA passwords)
- Return enriched copy
This enables multiple exports (redacted vs unredacted) from a single parse.
6. Parser Registration#
The pfSense parser must self-register via init():
// pkg/parser/pfsense/parser.go
func init() {
parser.Register("pfsense", NewParserFactory)
}
Critical requirement: Add blank import to cmd/root.go:
import (
_ "github.com/EvilBit-Labs/opnDossier/pkg/parser/opnsense"
_ "github.com/EvilBit-Labs/opnDossier/pkg/parser/pfsense" // Required!
)
Without this, the registry remains empty and auto-detection fails.
pfSense vs OPNsense Structural Differences#
XML Element Differences#
| Feature | OPNsense | pfSense | Priority |
|---|---|---|---|
| Root element | <opnsense> | <pfsense> | CRITICAL (auto-detection) |
| Default user | root | admin | High |
| User password | <passwd> | <bcrypt-hash> | CRITICAL (system.go) |
| Rule ID | uuid attribute | <tracker> element (integer) | CRITICAL (security.go) |
| NAT inbound dest IP | <target> | <internalip> | CRITICAL (security.go) |
| Rule tag/OS fields | Not modeled | <tag>, <os> present | Low |
| MVC namespace | <OPNsense><Firewall> | Not present | N/A (pfSense-only has legacy) |
Shared Patterns (No Changes Needed)#
Both platforms use identical patterns for:
- Boolean representation: Presence-based (
BoolFlag) vs value-based (stringwith "1") - Dynamic interface keys:
<wan>,<lan>,<opt0>(not<interface name="wan">) - Comma-separated lists:
<interface>wan,lan,opt1</interface> - Character encoding: Both support ISO-8859-1, Windows-1252, UTF-8
Implementation Roadmap#
The pfSense implementation is complete. The following sections document what was delivered and what remains unimplemented.
Implemented Sections (Delivered in PR #459)#
| Section | XML Path | Implementation Details |
|---|---|---|
| System | <system> | pfSense-specific User (<bcrypt-hash>), Group ([]string privileges), DNS server array |
| Filter Rules | <filter><rule> | pfSense-specific FilterRule with <tracker> element, <tag>, <os> fields |
| NAT Inbound | <nat><rule> | pfSense-specific InboundRule with <target> field |
| NAT Outbound | <nat><outbound> | Direct OPNsense type reuse |
| Interfaces | <interfaces> | Direct OPNsense Interfaces map type reuse |
| DHCP (v4) | <dhcpd> | Direct OPNsense Dhcpd map type reuse |
| DHCPv6 | <dhcpdv6> | pfSense-specific DHCPv6 with RAMode/RAPriority |
| Unbound | <unbound> | pfSense-specific UnboundConfig (core fields) |
| SNMP | <snmpd> | Direct OPNsense Snmpd reuse |
| OpenVPN | <openvpn> | Direct OPNsense OpenVPN reuse |
| Syslog | <syslog> | pfSense-specific SyslogConfig (minimal) |
| Cron | <cron> | pfSense-specific Cron with item[] array |
| Widgets | <widgets> | pfSense-specific Widgets |
| Diag | <diag> | pfSense-specific Diag (IPv6NAT only) |
| VLANs | <vlans> | Direct OPNsense VLANs/VLAN reuse |
| Gateways | <gateways> | Direct OPNsense Gateways reuse |
| Static Routes | <staticroutes> | Direct OPNsense StaticRoutes reuse |
| PPPs | <ppps> | Direct OPNsense PPPs reuse |
| Certificates/CAs | <cert>, <ca> | Direct OPNsense CA/Cert reuse |
| Revision | <revision> | Direct OPNsense Revision reuse |
High-Priority Sections (Not Yet Implemented)#
| Section | XML Path | Notes |
|---|---|---|
| Aliases | <aliases><alias> | Firewall aliases (host/network/port/URL types) — not in OPNsense schema |
| IPsec | <ipsec> | IPsec VPN (Phase 1, Phase 2, mobile client) — verify vs OPNsense Swanctl |
| Virtual IPs | <virtualip> | CARP, IP alias, Proxy ARP types — similar to OPNsense |
| HA Sync | <hasync> | High availability sync with 23+ sync flags |
| Bridges | <bridges> | Bridge interfaces with STP/RSTP — nearly identical to OPNsense |
| GIFs | <gifs> | GIF tunnels — nearly identical to OPNsense |
| GREs | <gres> | GRE tunnels — nearly identical to OPNsense |
| LAGGs | <laggs> | Link aggregation — adds failovermaster, lagghash vs OPNsense |
| CRL | <crl> | Certificate revocation lists |
| NAT 1:1 | <nat><onetoone> | BiNAT rules |
| NAT NPt | <nat><npt> | IPv6 network prefix translation |
Medium-Priority Sections (Not Yet Implemented)#
| Section | XML Path | Notes |
|---|---|---|
| NTPd | <ntpd> | NTP daemon configuration |
| DNSMasq | <dnsmasq> | DNS forwarder (lower priority than Unbound) |
| Schedules | <schedules> | Firewall rule scheduling |
| Dynamic DNS | <dyndnses> | DDNS client configuration |
| DHCP Relay | <dhcrelay>, <dhcrelay6> | DHCP relay agent |
| Interface Groups | <ifgroups> | Interface grouping — identical to OPNsense |
| QinQ | <qinqs> | 802.1ad QinQ VLAN |
| Wake on LAN | <wol> | WOL entries |
| Installed Packages | <installedpackages> | Package-specific configuration |
| IGMP Proxy | <igmpproxy> | Multicast routing |
Low-Priority Sections (Not Yet Implemented)#
| Section | XML Path | Notes |
|---|---|---|
| Captive Portal | <captiveportal> | Zone-keyed map, pfSense-specific implementation |
| ALTQ Traffic Shaper | <shaper> | ALTQ queue hierarchy (OPNsense uses dummynet) |
| Dummynet Limiters | <dnshaper> | Dummynet pipe configuration |
| L2TP Server | <l2tp> | L2TP VPN server (legacy) |
| PPPoE Server | <pppoes> | PPPoE server configuration |
| PPTP Server | <pptpd> | PPTP daemon (deprecated, insecure) |
| Kea DHCP | <kea>, <kea6> | Kea backend (pfSense 2.7+) |
| Auth Servers | <system><authserver> | LDAP/RADIUS authentication servers |
| Notifications | <system><notifications> | SMTP/Telegram/Pushover/Slack channels |
| Sysctl Tunables | <system><sysctl> | Kernel tunable overrides |
Testing Requirements#
Implemented Test Fixtures#
The testdata/pfsense/ directory contains 3 test configurations:
- config-2.6.x.xml (145 lines): pfSense 2.6.x/Plus 21.02 sample configuration covering system, interfaces, DHCP, filter rules, NAT, OpenVPN, CA/cert
- config-2.7.x.xml (137 lines): pfSense 2.7.x/Plus 23.05 sample configuration
- config-pfSense.xml (1146 lines): Full-featured production-like configuration with comprehensive coverage of implemented sections
Test Coverage#
Parser Tests (pkg/parser/pfsense/parser_test.go, 1241 lines):
- Round-trip XML parsing (decode → encode → decode)
- Security hardening (XXE protection, size limits, charset handling including Windows-1252)
- Converter subsystem tests (system, network, security, services)
- Edge cases (empty config, missing root element)
- Context cancellation
- Warning accumulation
Validation Tests (internal/validator/pfsense_test.go, 622 lines):
- System validation (hostname, domain, timezone, optimization modes)
- Filter rule validation (types, protocols, interfaces, network specs)
- NAT validation (outbound modes, reflection modes)
- User/group validation (uniqueness, UIDs/GIDs, scopes, group membership)
Integration Tests (integration_test.go, 81 lines added):
- End-to-end pfSense config processing
- Factory auto-detection of
<pfsense>root element
Golden Test Pattern#
The implementation follows the OPNsense golden test approach:
- Parse
testdata/pfsense/*.config.xml - Convert to
CommonDevice - Export to JSON/YAML
- Compare against committed golden files
- Regenerate with
go test -update-golden
Relevant Code Files#
OPNsense Schema (Reference Implementation)#
| File | Lines | Structs | URL |
|---|---|---|---|
opnsense.go | 431 | 3 | Link |
security.go | 561 | 16 | Link |
interfaces.go | 298 | 20 | Link |
services.go | 246 | 16 | Link |
network.go | 129 | 8 | Link |
common.go | 110 | 2 | Link |
system.go | 174 | 10 | Link |
OPNsense Converter (Pattern Reference)#
| File | Purpose | URL |
|---|---|---|
converter.go | Entry point, warning accumulation | Link |
converter_network.go | Temp-variable-append examples | Link |
parser.go | Parser registration pattern | Link |
Platform-Agnostic Infrastructure#
| File | Purpose | URL |
|---|---|---|
pkg/model/device.go | CommonDevice (target model) | Link |
pkg/parser/registry.go | Parser registration system | Link |
internal/converter/enrichment.go | Copy-on-write prepareForExport | Link |
Documentation#
| Document | Purpose | URL |
|---|---|---|
| xml-structure-research.md | pfSense/OPNsense schema comparison | Link |
| plugin-development.md | Parser implementation guide | Link |
| Issue #197 | pfSense support tracking | Link |
| Issue #196 | Multi-device refactoring (blocker) | Link |
Related Topics#
Multi-Vendor Support Architecture#
The pfSense implementation is part of a broader multi-vendor strategy. The parser registry pattern enables compile-time registration of vendor-specific parsers (OPNsense, pfSense, potentially Fortinet/Juniper) that all convert to a unified CommonDevice model. Auto-detection works by peeking at the root XML element (<pfsense> vs <opnsense>), with optional --device-type override.
Configuration Migration Patterns#
Configs migrated from pfSense to OPNsense may contain both <tracker> elements and uuid attributes. The schema should gracefully handle this by checking for presence of either field. Similarly, the schema must distinguish between presence-based booleans (BoolFlag) and value-based MVC fields (<enable>1</enable>)—BoolFlag cannot be used for the latter because element presence always resolves to true.
Copy-on-Write Data Enrichment#
The prepareForExport function implements a critical design pattern: compute statistics and analysis from unredacted original data, then apply selective redaction (HA passwords, certificate keys, API secrets) to the copy. This ensures accurate metrics (e.g., "is SNMP configured?" checks the real community string) while enabling multiple exports with different redaction settings from the same parsed config. The pattern is platform-agnostic and requires no changes for pfSense.
XML Custom Unmarshaling#
Both pfSense and OPNsense use dynamic element names (<wan>, <lan>, <opt0>) instead of attributes (<interface name="wan">). This requires custom UnmarshalXML implementations that parse element names as map keys. Similarly, InterfaceList implements custom unmarshaling to split comma-separated strings into Go slices.