Skip to content

feat: Add pfSense configuration parser with multi-device abstraction layer #197

Description

@unclesp1d3r

Summary

Add comprehensive support for parsing pfSense config.xml configuration files using the existing multi-device abstraction layer. The device-agnostic CommonDevice model, parser factory, and --device-type CLI flag are already in place — what remains is the pfSense-specific schema, parser, and validator, plus updating the markdown converter title and adding test coverage.

Background

pfSense and OPNsense share a common heritage (OPNsense forked from pfSense in 2014), resulting in similar but distinct XML configuration formats. Both store firewall state in a single config.xml file, but the root structure and several field names differ enough to require explicit support.

Key structural differences:

Feature OPNsense pfSense
Root element <opnsense> <pfsense>
Password field <passwd> <bcrypt-hash>
Default admin user root admin
Version field <version> <version>
Interface naming Minor variations Minor variations

Dependencies

Blocked by: #196 (Model restructuring for multi-device support)

Note: As of the current branch, Phase 1 and most of Phase 3 from the original plan have already been implemented as part of #196. This issue now covers the pfSense-specific work only.

Current Architecture (Already Implemented)

The multi-device abstraction layer is complete. The design uses a flat CommonDevice struct (not an interface) with an embedded DeviceType field, and a registry-based parser factory pattern.

Platform-Agnostic Model (pkg/model/device.go) ✅ DONE

type DeviceType string

const (
    DeviceTypeOPNsense DeviceType = "opnsense"
    DeviceTypePfSense  DeviceType = "pfsense"
    DeviceTypeUnknown  DeviceType = ""
)

// CommonDevice is the platform-agnostic root struct — all parsers produce this.
type CommonDevice struct {
    DeviceType DeviceType `json:"device_type" yaml:"device_type"`
    Version    string
    System     System
    // ... all configuration fields
}

Parser Factory (pkg/parser/factory.go) ✅ DONE

// Factory auto-detects device type from the XML root element and delegates
// to the appropriate registered DeviceParser.
func (f *Factory) CreateDevice(
    ctx context.Context,
    r io.Reader,
    deviceTypeOverride common.DeviceType,
    validateMode bool,
) (*common.CommonDevice, []common.ConversionWarning, error)

Parser Registry (pkg/parser/registry.go) ✅ DONE

// DeviceParser implementations register themselves via init() + blank import.
type DeviceParser interface {
    Parse(ctx context.Context, r io.Reader) (*common.CommonDevice, []common.ConversionWarning, error)
    ParseAndValidate(ctx context.Context, r io.Reader) (*common.CommonDevice, []common.ConversionWarning, error)
}

// Register adds a constructor for a device type (called from init()).
func Register(deviceType string, fn ConstructorFunc)

CLI --device-type Flag (cmd/shared_flags.go) ✅ DONE

The --device-type flag, validateDeviceType(), and resolveDeviceType() are already wired into the CLI.

Proposed Solution — What Remains

Proposed Package Layout

pkg/
  schema/
    pfsense/
      document.go      # PfSenseDocument XML structs (xml:"pfsense" root)   (NEW)
  parser/
    pfsense/
      parser.go        # pfSense DeviceParser + init() registration         (NEW)
      parser_test.go   # Unit tests                                         (NEW)
internal/
  validator/
    pfsense.go         # ValidatePfSenseDocument()                          (NEW)
  converter/
    markdown.go        # Update title to use device_type (not hardcoded)   (MODIFY)
testdata/
  pfsense/
    config-2.6.x.xml   # pfSense 2.6.x fixture                             (NEW)
    config-2.7.x.xml   # pfSense 2.7.x fixture                             (NEW)

pfSense Schema Root (pkg/schema/pfsense/document.go)

type PfSenseDocument struct {
    XMLName xml.Name      `xml:"pfsense"`
    Version string        `xml:"version"`
    System  PfSenseSystem `xml:"system"`
    // ...
}

pfSense Parser (pkg/parser/pfsense/parser.go)

// Parser implements DeviceParser for pfSense configuration files.
type Parser struct {
    decoder parser.XMLDecoder
}

// init registers this parser with the global registry.
func init() {
    parser.Register("pfsense", func(d parser.XMLDecoder) parser.DeviceParser {
        return NewParser(d)
    })
}

func (p *Parser) Parse(ctx context.Context, r io.Reader) (*common.CommonDevice, []common.ConversionWarning, error)
func (p *Parser) ParseAndValidate(ctx context.Context, r io.Reader) (*common.CommonDevice, []common.ConversionWarning, error)

Implementation Phases

Phase 1: Multi-Device Abstraction Layer ✅ COMPLETE

  • pkg/model/device.goDeviceType constants, CommonDevice struct with DeviceType field
  • pkg/parser/factory.goFactory.CreateDevice() with auto-detection via root-element peek
  • pkg/parser/registry.goDeviceParserRegistry, DefaultRegistry(), Register()
  • pkg/parser/opnsense/parser.go — OPNsense DeviceParser, registered in init()
  • internal/cfgparser/ — low-level XML streaming parser (unchanged)

Phase 2: pfSense Parser Implementation ⬜ TODO

  • Define pfSense schema structs with xml:"pfsense" root tag in pkg/schema/pfsense/
  • Implement streaming token-based DeviceParser in pkg/parser/pfsense/parser.go, registered via init()
  • Add ValidatePfSenseDocument() in internal/validator/pfsense.go, reusing existing IP/CIDR/MTU helpers from internal/validator/
  • Apply same security hardening: XXE prevention, XML bomb protection, charset normalization (via shared internal/cfgparser reader)

Phase 3: CLI and Converter Integration ⬜ PARTIALLY DONE

  • Converters (markdown.go, json.go, yaml.go) already accept *common.CommonDevicedevice_type field is already in JSON/YAML output ✅
  • --device-type flag already wired in cmd/shared_flags.go
  • Remaining: Update the markdown output title in internal/converter/markdown.go — currently hardcoded as "OPNsense Configuration", should be device-type-aware (e.g. "pfSense Configuration")
  • Remaining: Optionally add a typed ErrUnsupportedConfigType sentinel to internal/cfgparser/errors.go (currently the factory returns inline fmt.Errorf)

Phase 4: Testing and Documentation ⬜ TODO

  • Add pfSense 2.6.x and 2.7.x test fixtures in testdata/pfsense/
  • Unit tests in pkg/parser/pfsense/parser_test.go
  • Integration tests for Factory.CreateDevice() auto-detection with pfSense configs
  • Update README.md to announce pfSense support
  • Update docs/dev-guide/architecture.md to describe the registry-based parser pattern

Research & Analysis

Acceptance Criteria

Functional Requirements:

  • pfSense config.xml files parse successfully for versions 2.6.x and 2.7.x
  • Auto-detection correctly identifies OPNsense vs pfSense via root element (<pfsense>)
  • All output formats (JSON, YAML, Markdown) work with pfSense configurations
  • device_type: pfsense appears in JSON/YAML output
  • Markdown report title reflects device type (e.g. "pfSense Configuration")
  • --device-type pfsense flag allows manual override

Code Quality:

  • pfSense parser follows same DeviceParser interface as pkg/parser/opnsense/parser.go
  • pfSense parser registered via init() in pkg/parser/pfsense/parser.go
  • Common validation rules reused via internal/validator/ helpers
  • Comprehensive unit and integration tests (target: ≥ 80% coverage on new code)
  • No regression in existing OPNsense parsing functionality

Security:

  • XXE injection prevented (Go's xml.Decoder + explicit dec.Entity = map[string]string{})
  • XML bomb / entity expansion attacks mitigated (io.LimitReader with DefaultMaxInputSize)
  • Proper charset handling via shared internal/cfgparser.charsetReader

Documentation:

  • README updated to announce pfSense support
  • Usage examples for pfSense configuration files
  • Architecture docs describe DeviceParserRegistry and factory pattern

Technical Notes

How to add a new device type (based on existing OPNsense implementation):

  1. Create schema structs in pkg/schema/<device>/
  2. Create a converter that maps schema → *common.CommonDevice (set DeviceType field)
  3. Create a DeviceParser in pkg/parser/<device>/parser.go that calls the converter
  4. Register in init(): parser.Register("<root-element>", func(d parser.XMLDecoder) parser.DeviceParser { ... })
  5. Blank-import the parser package from main.go to trigger init()

Security — reuse existing infrastructure:

  • Input size limiting: use io.LimitReader wrapping the incoming reader (mirrors internal/cfgparser.DefaultMaxInputSize)
  • Charset handling: delegate to internal/cfgparser.charsetReader or replicate its logic
  • Entity map: set dec.Entity = map[string]string{} on the XML decoder

Backward Compatibility:

  • All existing OPNsense tests must continue to pass without modification
  • The low-level internal/cfgparser.XMLParser is unchanged; pfSense uses its own XML decoder

Related Issues & PRs

Metadata

Metadata

Assignees

Labels

architectureArchitectural changes and design decisionscliconfigurationConfiguration management and settingsdocumentationImprovements or additions to documentationenhancementNew feature or requestepicgoPull requests that update go codemodelModel layer and domain typespriority:highHigh priority issuerefactoringCode refactoring and restructuringsecuritySecurity-related features and issuestestingTest infrastructure and test-related issuestype:featureNew feature implementation

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions