Liberal Boolean Parsing for OPNsense/pfSense config.xml#
Problem#
opndossier audit <config.xml> crashed on an OPNsense 26.1 config.xml with:
opnsense parser: strconv.ParseInt: parsing "on": invalid syntax
Reported in #558. Root cause: several schema fields that semantically represent boolean toggles were typed as Go int, but OPNsense and pfSense both emit any of 1|on|yes|true|enable|enabled for a truthy toggle. encoding/xml dispatches int fields to strconv.ParseInt, which fails fast on non-numeric input and aborts the whole parse with no element path — the user sees only strconv.ParseInt: parsing "on" and can't locate the field.
Symptoms#
- Opaque
strconv.ParseInt: parsing "on"error surfaced to the CLI with no element path to identify the offending field. - 10 OPNsense
Systemfields (DNSAllowOverride,UseVirtualTerminal,DisableVLANHWFilter,DisableChecksumOffloading,DisableSegmentationOffloading,DisableLargeReceiveOffloading,PfShareForward,LbUseSticky,RrdBackup,NetflowBackup) and 3 pfSenseSystemfields (DNSAllowOverride,DisableSegmentationOffloading,DisableLargeReceiveOffloading) were declaredintdespite being boolean toggles. - Truthy parsing was duplicated and inconsistent across the codebase:
pfsense.isPfSenseValueTrueaccepted1|on|yesonly (session history).internal/converter/formatters/IsTruthyaccepted a broader set at the formatter layer.- OPNsense converter sites used
strings.EqualFold(x, xmlBoolYes)wherexmlBoolYes = "yes"— rejectingon/1/true/enabled.
- Latent bug:
opnsense.BoolFlag.UnmarshalXMLsettrueon any element presence regardless of body — so<tag>0</tag>incorrectly decoded totrue. Predated #558 but only surfaced under the new BoolFlag delegation path.
What Didn't Work#
- Escape-hatch
FlexInton the toggle fields. First draft introduced a liberal int that coerced truthy strings to1. Abandoned — the fields were never semantically int.FlexIntstill ships as a sibling type for fields that genuinely need int semantics (none in this change; reserved for future hot-fixes), but it is the wrong tool for schema fields whose intent is boolean. - Collapsing
BoolFlagandFlexBoolinto one type. Rejected:BoolFlagis presence-based (absent → false,<tag/>→ true),FlexBoolis always-value-based (element always emitted, body carries the signal). Merging would force every consumer to express intent inline and push call-site decisions that the type system was meant to encode. FlexBool.UnmarshalJSONwith hand-stripped quotes. First pass extracted the string value by stripping surrounding"bytes, then callingIsValueTrue. Review caught this silently drops JSON escape sequences —"\u006fn"(legal JSON for "on") compared literally and returned false. Rewrote to cascadejson.Unmarshalthrough nativebool→ nativeint→string, so escapes are decoded before the truthy check.- An intermediate pfSense decode layer was already tried in PR #461 (March 2026) for a related presence-based bug (
<enable/>on pfSense interfaces) — adecodeDocumenttype that converted betweenpfsense.*andopnsense.*mid-decode. That approach was rejected in favor of a clean fork. The #558 fix follows the same architectural direction: one shared helper package, native schema types, no shadow decode layer. (session history)
Solution#
1. Canonical truthy parser in pkg/schema/shared/#
// pkg/schema/shared/bool.go
package shared
import "strings"
func IsValueTrue(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "on", "yes", "true", "enable", "enabled":
return true
}
return false
}
func IsValueFalse(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "0", "off", "no", "false", "disable", "disabled", "":
return true
}
return false
}
Both opnsense and pfSense converter code consumes this. No device-specific truthy parser remains.
2. FlexBool — value-level liberal bool#
shared.FlexBool is a type FlexBool bool with XML/JSON/YAML Marshal/Unmarshal. Use it when the element is always emitted and the body carries the signal. Marshals as 1/0 for canonical output. JSON/YAML round-trip as native booleans. The UnmarshalJSON cascade delegates string decoding to encoding/json so escape sequences are decoded before comparison.
3. FlexInt — liberal int sibling#
Same vocabulary, keeps int semantics. Unknown non-numeric input returns a wrapped error (stricter than FlexBool, which maps unknown → false). No field migrates to it in this change; reserved for future hot-fixes where the field must stay int-typed but may receive truthy strings.
4. Upgraded BoolFlag.UnmarshalXML#
The latent "any presence → true" bug is fixed. Before/after:
// Before: element presence always set true — <tag>0</tag> wrongly decoded to true.
func (bf *BoolFlag) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
*bf = true
var content string
return d.DecodeElement(&content, &start)
}
// After: absent → false (UnmarshalXML not called), <tag/> → true,
// <tag>body</tag> → IsValueTrue(body). Marshal side unchanged to preserve
// GOTCHAS §15.1 pointer-receiver invariants.
func (bf *BoolFlag) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var content string
if err := d.DecodeElement(&content, &start); err != nil {
return err
}
if strings.TrimSpace(content) == "" {
*bf = true
return nil
}
*bf = BoolFlag(shared.IsValueTrue(content))
return nil
}
5. Field migrations#
- OPNsense
System: 10 fieldsint→BoolFlag(enumerated above). - pfSense
System: 3 fieldsint→BoolFlag. pfsense.isPfSenseValueTruedeleted; call sites (converter_network.goforBlockPriv,BlockBogons,FarGW) moved toshared.IsValueTrue.pkg/parser/opnsense/converter.go: bothDisableNATReflectioncall sites replacedstrings.EqualFold(x, xmlBoolYes)withshared.IsValueTrue(x).
Consumers read bool(field) or field.Bool() instead of == 1.
6. Universal element-path error annotation#
New helper in pkg/parser/xmlutil.go:
func WrapDecodeError(err error, elementPath string) error {
if err == nil {
return nil
}
return fmt.Errorf("field %q: %w", elementPath, err)
}
Wired into:
internal/cfgparser/xml.go(decodeElement/decodeSection) — annotates with/opnsense/<section>so the user seesfield "/opnsense/system": strconv.ParseInt: parsing "banana"when a numeric field gets a malformed body.pkg/parser/pfsense/parser.go(decode) — annotates with/pfsense. Single-pass decode architecture limits depth to the root; tracked in todo 099 for deeper refactor.
Why This Works#
- Root cause addressed at the type level. The offending fields are now typed as what they semantically are (
BoolFlag), soencoding/xmldispatches to the liberal unmarshaler instead ofstrconv.ParseInt. Changingpkg/schema/opnsensetypes is safe becausecommon.CommonDevice(pkg/model) is the documented public surface per the NATS-144 audit (commit33ccf82) — raw schema types can change without semver break. - Single canonical truthy vocabulary. OPNsense and pfSense share the same liberal encoding (
1|on|yes|true|enable|enabledcase-insensitive). Replacing three divergent parsers with oneshared.IsValueTrueeliminates the possibility that one site acceptsonand another rejects it. - Pointer-receiver addressability invariant preserved.
BoolFlag.MarshalXMLstays on the pointer receiver; onlyUnmarshalXMLchanged. pfSense forks that embed migrated fields continue to follow GOTCHAS §15.1 (interfaceAlias+ pointer-receiverMarshalXMLon the parent struct) so value-marshaled parent structs still emit<enable/>rather than<enable>true</enable>. - Failure diagnostics locatable. When a decode still fails (non-numeric in a genuinely int field like
NextUID),WrapDecodeErrorpins the element path. Reporters on future schema/config mismatches can share the exact offending element without having to reproduce on our side.
Prevention#
Converter-site rule#
Never write strings.EqualFold(x, "yes"), x == "1", or any ad-hoc truthy parser for XML toggle values. Use shared.IsValueTrue / shared.IsValueFalse. Grep for new EqualFold checks against "yes"|"on"|"enabled"|"1" during review.
Type-choice rubric#
Four boolean/int-like styles coexist in the schema layer. Pick by the rubric (see GOTCHAS §15.0 for the full table):
| Type | Semantics | Use when |
|---|---|---|
opnsense.BoolFlag | presence + liberal body | Absence = false is correct in OPNsense/pfSense semantics; marshal may drop false to element absence. |
shared.FlexBool | always-value-based | Element is always emitted, body carries the signal. Marshals as 1/0 every time. |
shared.FlexInt | liberal int | Field must stay int but may receive truthy strings; unknown → error (strict). |
strict bool / int | canonical only | Schema guarantees numeric/native values (rare for device exports). |
BoolFlag is NOT a drop-in for every value-based boolean. Because MarshalXML emits nothing when false, a field whose on-wire form must always be <tag>0</tag> or <tag>1</tag> cannot migrate to BoolFlag — the false case would disappear. Criteria for safe migration:
- Absence of the element must be semantically equivalent to
falsein the device's behavior. - No round-trip consumer must depend on the literal
<tag>0</tag>form when false.
If either fails, prefer FlexBool or keep as string + IsValueTrue at the converter.
Element-path annotation#
Any new XML decode surface (new device parser, new section decoder) must wrap errors through parser.WrapDecodeError(err, "/<root>/<section>"). Future reporters will hand you back the exact field that failed.
Testing requirements for new schema types#
Exercise both marshal and unmarshal, including value-marshaled parent structs (per GOTCHAS §15.1 — pointer-receiver methods are invisible when a parent is encoded by value). Cover:
<tag/>self-closing → true (for BoolFlag)<tag>0</tag>,<tag>on</tag>,<tag>enabled</tag>explicit bodies- Absent element → false
- Unknown body → false (BoolFlag, FlexBool) or error (FlexInt)
- JSON/YAML round-trip parity
Schema-audit trigger#
Whenever a bug report shows strconv.ParseInt, strconv.ParseBool, or strconv.Atoi in the parser stack, treat it as a miscategorization signal first. Confirm the field is genuinely numeric/strict before adding an escape hatch. OPNsense/pfSense fields whose PHP source uses $config[...] == "1" are booleans in disguise, not integers.
Receiver conventions for new liberal-parsing types#
FlexBool and FlexInt use pointer receivers for Marshal*/Unmarshal* (required by the encoding interfaces) and value receivers for accessor methods (Bool(), Int()) so they work on non-addressable values like shared.FlexBool(true).Bool(). recvcheck must be suppressed on the type declaration (not on the accessor method) with a directive explaining the mixed-receiver convention is intentional. CI will fail otherwise.
Historical Context (session history)#
- PR #461 (March 2026) fixed a related presence-based bug where pfSense interfaces with
<enable/>always reported as disabled becauseopnsense.Interface.EnablewasstringandisPfSenseValueTrue("")returned false for both the empty-body case and the missing-element case. The clean-fork architecture adopted there (nativepfsense.*types,.Bool()readers, no shadow decode layer) set the precedent followed by #558. - GOTCHAS §15.1 origin (session
41a2bf64, March 23, 2026): theinterfaceAlias+ pointer-receiverMarshalXMLpattern was introduced whenInterfaces.MarshalXMLiterated a map and callede.EncodeElement(value, ...)by value, causingBoolFlagfields to fall back to defaultboolserialization. The addressability gotcha now extends toFlexBoolandFlexInttoo. BlockPriv/BlockBogonsdeferred from #461: both pfSense interface fields were flagged as additional presence-based booleans during #461's review but scoped out. They still useshared.IsValueTrueat the converter site rather thanBoolFlagtyping — a follow-up migration opportunity.- No prior
shared/package existed. The cross-device helper package (pkg/schema/shared/) is new. Prior sessions usedopnsense.BoolFlagdirectly; there was no cross-cutting primitive. - No prior
WrapDecodeErrorpattern. Element-path annotation on XML decode errors is new with this fix.
Related Work#
- GOTCHAS §15 — Liberal Boolean and Integer Parsing — decision rubric and pointer-receiver caveats.
- PR #577 — this fix.
- Issue #558 — reporter-filed bug.
- Plan: 2026-04-18-002-fix-issue-558-parser-on-value.md — implementation plan.
cli-flag-wiring-silent-ignore— related silent-failure class of bug (different root cause, same symptom shape).pluggable-deviceparser-registry-pattern— parser registry architecture thatWrapDecodeErrorlives within.