Documents
PfSense Schema Implementation Status
PfSense Schema Implementation Status
Type
Topic
Status
Published
Created
Mar 22, 2026
Updated
Apr 19, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

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.DocumentCommonDevice, 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 Document struct with xml:"pfsense" tag, 30+ top-level sections
  • system.go (98 lines): pfSense-specific User (with <bcrypt-hash>), Group (with []string privileges), System with 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): DeviceParser implementation with secure XML decoding, context cancellation, auto-registration via init()
  • converter.go (239+ lines): Main converter with warning accumulation, gap detection API (PfsenseKnownGapMessage const, 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 NewSecureXMLDecoder and CharsetReader for 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 registration
  • pkg/model/device.go: Added IPProtocolInet46 constant, DisplayName() method for DeviceType
  • internal/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: Returns true if field names 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 in pfsense.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/:

FileLinesResponsibilitiesOPNsense Reuse
document.go93Root Document struct with xml:"pfsense" tag; 30+ top-level sectionsImports Interfaces, Dhcpd, Snmpd, OpenVPN, Gateways, StaticRoutes, VLANs, PPPs, Rrd, LoadBalancer, Revision, CA/Cert types
system.go98System config, WebGUI, SSH, users (with <bcrypt-hash>), groups (with []string privileges), DNS servers (array)Imports SSHConfig, WebGUI base; forks User, Group, System.DNSServers
security.go104FirewallRules (with <tracker>), NAT inbound (with <target>), NAT outboundImports Source, Destination, Outbound (NAT); forks FilterRule, InboundRule
services.go64Unbound, Syslog, Cron, Widgets, DiagpfSense-specific implementations
network.go91DHCPv6 (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):

pfSense-Specific Forks (6 divergence points):

  • User: Uses <bcrypt-hash> instead of <passwd>
  • Group: Uses []string for privileges instead of single string
  • 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 RAMode and RAPriority fields

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)

Requires pfSense Variants (Implemented)

  • User: Uses <bcrypt-hash> instead of <passwd> (implemented in system.go)
  • Group: Uses []string for privileges instead of single string (implemented in system.go)
  • FilterRule: Adds <tracker> element (integer), includes <tag>/<os> fields (implemented in security.go)
  • InboundRule: Uses <target> instead of <internalip> for NAT destination IP (implemented in security.go)
  • System.DNSServers: Uses []string array for repeating <dnsserver> elements (implemented in system.go)
  • DHCPv6Interface: Adds RAMode and RAPriority fields (implemented in network.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 nil for 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:

  1. Shallow copy original CommonDevice
  2. Compute statistics from unredacted data
  3. Apply selective redaction (certificates, API keys, HA passwords)
  4. 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#

FeatureOPNsensepfSensePriority
Root element<opnsense><pfsense>CRITICAL (auto-detection)
Default userrootadminHigh
User password<passwd><bcrypt-hash>CRITICAL (system.go)
Rule IDuuid attribute<tracker> element (integer)CRITICAL (security.go)
NAT inbound dest IP<target><internalip>CRITICAL (security.go)
Rule tag/OS fieldsNot modeled<tag>, <os> presentLow
MVC namespace<OPNsense><Firewall>Not presentN/A (pfSense-only has legacy)

Shared Patterns (No Changes Needed)#

Both platforms use identical patterns for:


Implementation Roadmap#

The pfSense implementation is complete. The following sections document what was delivered and what remains unimplemented.

Implemented Sections (Delivered in PR #459)#

SectionXML PathImplementation 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)#

SectionXML PathNotes
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)#

SectionXML PathNotes
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)#

SectionXML PathNotes
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:

  1. 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
  2. config-2.7.x.xml (137 lines): pfSense 2.7.x/Plus 23.05 sample configuration
  3. 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:

  1. Parse testdata/pfsense/*.config.xml
  2. Convert to CommonDevice
  3. Export to JSON/YAML
  4. Compare against committed golden files
  5. Regenerate with go test -update-golden

Relevant Code Files#

OPNsense Schema (Reference Implementation)#

FileLinesStructsURL
opnsense.go4313Link
security.go56116Link
interfaces.go29820Link
services.go24616Link
network.go1298Link
common.go1102Link
system.go17410Link

OPNsense Converter (Pattern Reference)#

FilePurposeURL
converter.goEntry point, warning accumulationLink
converter_network.goTemp-variable-append examplesLink
parser.goParser registration patternLink

Platform-Agnostic Infrastructure#

FilePurposeURL
pkg/model/device.goCommonDevice (target model)Link
pkg/parser/registry.goParser registration systemLink
internal/converter/enrichment.goCopy-on-write prepareForExportLink

Documentation#

DocumentPurposeURL
xml-structure-research.mdpfSense/OPNsense schema comparisonLink
plugin-development.mdParser implementation guideLink
Issue #197pfSense support trackingLink
Issue #196Multi-device refactoring (blocker)Link

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.