Real config.xml root is the lowercase <opnsense> element (the whole firewall config); plugin/core model data like WireGuard lives nested inside a separate, capitalized <OPNsense> child element. The parser was matching the capitalized name as the document root, so every real export failed with "expected element type but have ...". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VjwLYRA87o8m9a9zztgs3
144 lines
4.6 KiB
Go
144 lines
4.6 KiB
Go
// Package opnsense parses OPNsense's WireGuard config.xml export and maps
|
|
// it into staging structures that an admin can review and edit before
|
|
// wireguard-ui-multi commits them as model.Server/model.ServerSetting/
|
|
// model.Client records. Parsing and mapping never touch the store, and the
|
|
// resulting data is never auto-applied (no wg-quick / systemctl calls
|
|
// happen anywhere in this package) - see handler/routes_opnsense_import.go
|
|
// for the two-step preview/commit flow that does the actual store writes.
|
|
//
|
|
// Schema reference (verified against OPNsense core master,
|
|
// src/opnsense/mvc/app/models/OPNsense/Wireguard/{Server,Client}.xml, and
|
|
// against real config.xml exports/fixtures, which nest plugin model data
|
|
// under a capitalized <OPNsense> element inside the lowercase <opnsense>
|
|
// root - the two are NOT the same element):
|
|
//
|
|
// <opnsense>
|
|
// ...
|
|
// <OPNsense><wireguard>
|
|
// <server><servers>
|
|
// <server uuid="..."><enabled/><name/><instance/><pubkey/><privkey/>
|
|
// <port/><mtu/><dns/><tunneladdress/><disableroutes/><gateway/>
|
|
// <peers/><debug/></server>
|
|
// </servers></server>
|
|
// <client><clients>
|
|
// <client uuid="..."><enabled/><name/><pubkey/><psk/><tunneladdress/>
|
|
// <serveraddress/><serverport/><keepalive/></client>
|
|
// </clients></client>
|
|
// <general><enabled/></general>
|
|
// </wireguard></OPNsense>
|
|
// ...
|
|
// </opnsense>
|
|
package opnsense
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
// rawConfig mirrors the on-disk config.xml structure. The root element is
|
|
// the lowercase <opnsense> (the whole firewall config); plugin/core model
|
|
// data lives inside a capitalized <OPNsense> child element - the two are
|
|
// distinct tags, not a casing quirk of one.
|
|
type rawConfig struct {
|
|
XMLName xml.Name `xml:"opnsense"`
|
|
Ns struct {
|
|
Wireguard struct {
|
|
Server struct {
|
|
Servers struct {
|
|
Server []rawServer `xml:"server"`
|
|
} `xml:"servers"`
|
|
} `xml:"server"`
|
|
Client struct {
|
|
Clients struct {
|
|
Client []rawClient `xml:"client"`
|
|
} `xml:"clients"`
|
|
} `xml:"client"`
|
|
General struct {
|
|
Enabled string `xml:"enabled"`
|
|
} `xml:"general"`
|
|
} `xml:"wireguard"`
|
|
} `xml:"OPNsense"`
|
|
}
|
|
|
|
type rawServer struct {
|
|
UUID string `xml:"uuid,attr"`
|
|
Enabled string `xml:"enabled"`
|
|
Name string `xml:"name"`
|
|
Instance string `xml:"instance"`
|
|
PubKey string `xml:"pubkey"`
|
|
PrivKey string `xml:"privkey"`
|
|
Port string `xml:"port"`
|
|
MTU string `xml:"mtu"`
|
|
DNS string `xml:"dns"`
|
|
TunnelAddress string `xml:"tunneladdress"`
|
|
DisableRoutes string `xml:"disableroutes"`
|
|
Gateway string `xml:"gateway"`
|
|
Peers string `xml:"peers"`
|
|
Debug string `xml:"debug"`
|
|
}
|
|
|
|
type rawClient struct {
|
|
UUID string `xml:"uuid,attr"`
|
|
Enabled string `xml:"enabled"`
|
|
Name string `xml:"name"`
|
|
PubKey string `xml:"pubkey"`
|
|
PSK string `xml:"psk"`
|
|
TunnelAddress string `xml:"tunneladdress"`
|
|
ServerAddress string `xml:"serveraddress"`
|
|
ServerPort string `xml:"serverport"`
|
|
Keepalive string `xml:"keepalive"`
|
|
}
|
|
|
|
// ParsedConfig is the raw parsed result, before any admin-editable mapping
|
|
// is applied.
|
|
type ParsedConfig struct {
|
|
Servers []rawServer
|
|
Clients []rawClient
|
|
}
|
|
|
|
// Parse reads an OPNsense config.xml document and extracts the WireGuard
|
|
// server/client definitions. It returns a clean error (never panics) if
|
|
// the document isn't valid XML or doesn't have the expected root element.
|
|
// Go's encoding/xml does not resolve external entities, so this is not
|
|
// vulnerable to XXE; no custom entity handling is added.
|
|
func Parse(r io.Reader) (*ParsedConfig, error) {
|
|
var cfg rawConfig
|
|
dec := xml.NewDecoder(r)
|
|
if err := dec.Decode(&cfg); err != nil {
|
|
return nil, fmt.Errorf("could not parse config.xml: %w", err)
|
|
}
|
|
if cfg.XMLName.Local != "opnsense" {
|
|
return nil, fmt.Errorf("not an OPNsense config.xml (unexpected root element %q)", cfg.XMLName.Local)
|
|
}
|
|
|
|
return &ParsedConfig{
|
|
Servers: cfg.Ns.Wireguard.Server.Servers.Server,
|
|
Clients: cfg.Ns.Wireguard.Client.Clients.Client,
|
|
}, nil
|
|
}
|
|
|
|
// splitList splits an OPNsense comma-separated field (tunneladdress, dns,
|
|
// peers, ...) into trimmed, non-empty parts.
|
|
func splitList(s string) []string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isTruthy interprets OPNsense's boolean-ish "1"/"0" (or empty) fields.
|
|
func isTruthy(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
return s == "1" || strings.EqualFold(s, "true")
|
|
}
|