Files
wireguard-ui-multi/opnsense/parse.go
T
sysopsandClaude Sonnet 5 83b1da291f Add per-server NAT egress, ip_forward auto-enable, OPNsense import review checklist
- ServerSetting gains WanInterface/EgressSNATIP for optional per-server
  masquerade/SNAT of client traffic, isolated in each server's own
  nftables table
- wireguard.Start/Restart now ensure net.ipv4.ip_forward and
  net.ipv6.conf.all.forwarding are enabled before bringing an interface up
- OPNsense config.xml import now parses staticroutes/filter/nat rules and
  surfaces them as a manual-review checklist in the preview UI (never
  auto-applied)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
2026-07-29 13:08:52 +02:00

197 lines
6.3 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"`
StaticRoutes struct {
Route []rawStaticRoute `xml:"route"`
} `xml:"staticroutes"`
Filter struct {
Rule []rawFilterRule `xml:"rule"`
} `xml:"filter"`
Nat struct {
Outbound struct {
Mode string `xml:"mode"`
Rule []rawNatRule `xml:"rule"`
} `xml:"outbound"`
} `xml:"nat"`
}
// rawStaticRoute mirrors <staticroutes><route> - a manually configured
// route not otherwise expressible via WireGuard's own tunneladdress/peers
// fields. Surfaced as a review item only; never auto-applied.
type rawStaticRoute struct {
Network string `xml:"network"`
Gateway string `xml:"gateway"`
Descr string `xml:"descr"`
}
// rawFilterRule mirrors <filter><rule> - a firewall rule. Only the fields
// needed to flag rules that reference a WireGuard interface are captured.
type rawFilterRule struct {
Type string `xml:"type"`
Interface string `xml:"interface"`
Descr string `xml:"descr"`
Source struct {
Network string `xml:"network"`
} `xml:"source"`
Destination struct {
Network string `xml:"network"`
} `xml:"destination"`
}
// rawNatRule mirrors <nat><outbound><rule> - a manual outbound NAT/SNAT
// rule. Surfaced as a review item so the admin can recreate the
// equivalent via ServerSetting.WanInterface/EgressSNATIP if needed.
type rawNatRule struct {
Interface string `xml:"interface"`
Source struct {
Network string `xml:"network"`
} `xml:"source"`
Target string `xml:"target"`
Descr string `xml:"descr"`
}
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
StaticRoutes []rawStaticRoute
FilterRules []rawFilterRule
NatRules []rawNatRule
}
// 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,
StaticRoutes: cfg.StaticRoutes.Route,
FilterRules: cfg.Filter.Rule,
NatRules: cfg.Nat.Outbound.Rule,
}, 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")
}