Files
wireguard-ui-multi/opnsense/map.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

429 lines
15 KiB
Go

package opnsense
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/ngoduykhanh/wireguard-ui/util"
)
// PreviewClient is a single OPNsense client mapped into an admin-editable
// staging structure. It is not a model.Client yet - ToModel performs the
// final conversion at commit time.
type PreviewClient struct {
SourceUUID string `json:"source_uuid,omitempty"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Enabled bool `json:"enabled"`
PublicKey string `json:"public_key"`
PresharedKey string `json:"preshared_key,omitempty"`
AllocatedIPs []string `json:"allocated_ips"`
AllowedIPs []string `json:"allowed_ips"`
PersistentKeepalive int `json:"persistent_keepalive,omitempty"`
AdditionalNotes string `json:"additional_notes,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// PreviewServer is a single OPNsense WireGuard server instance mapped into
// an admin-editable staging structure, together with the clients OPNsense
// associated with it via its <peers> uuid list.
type PreviewServer struct {
SourceUUID string `json:"source_uuid,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Interface string `json:"interface"`
Addresses []string `json:"addresses"`
ListenPort int `json:"listen_port"`
PrivateKey string `json:"private_key,omitempty"`
PublicKey string `json:"public_key,omitempty"`
EndpointAddress string `json:"endpoint_address,omitempty"`
DNSServers []string `json:"dns_servers,omitempty"`
MTU int `json:"mtu,omitempty"`
Enabled bool `json:"enabled"`
Clients []PreviewClient `json:"clients"`
Warnings []string `json:"warnings,omitempty"`
}
// ReviewChecklistItem is one manual-review entry surfaced from parts of
// config.xml that wireguard-ui-multi does not import or model at all
// (static routes, firewall rules, outbound NAT). These are never applied -
// they exist purely so the admin knows what else the old OPNsense config
// was doing and can recreate the equivalent manually (e.g. via
// ServerSetting.WanInterface/EgressSNATIP or a custom FirewallRule) before
// cutover, instead of discovering the gap after go-live.
type ReviewChecklistItem struct {
Kind string `json:"kind"` // "static_route" | "filter_rule" | "nat_rule"
Description string `json:"description"`
}
// PreviewResult is the full response of the preview step: every server
// OPNsense defined, mapped and ready for the admin to review/edit before
// confirming the import. It is never written to the store by itself.
type PreviewResult struct {
Servers []PreviewServer `json:"servers"`
ReviewChecklist []ReviewChecklistItem `json:"review_checklist,omitempty"`
}
var slugInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
// slugify turns a free-text name into a safe record-id/interface-name
// candidate (see util.ValidateRecordID / util.ValidateInterfaceName).
func slugify(name string) string {
s := slugInvalidChars.ReplaceAllString(strings.TrimSpace(name), "-")
s = strings.Trim(s, "-_")
if s == "" {
s = "opnsense-server"
}
return s
}
// uniqueID returns slug, or slug-2, slug-3, ... until it no longer collides
// with existingIDs or anything already produced in this same batch (used
// so importing several OPNsense servers whose names collide with each
// other, or with already-existing wireguard-ui-multi servers, still
// produces distinct IDs).
func uniqueID(slug string, existingIDs map[string]bool) string {
candidate := slug
if !existingIDs[candidate] {
existingIDs[candidate] = true
return candidate
}
for i := 2; ; i++ {
candidate = fmt.Sprintf("%s-%d", slug, i)
if !existingIDs[candidate] {
existingIDs[candidate] = true
return candidate
}
}
}
// truncateInterfaceName shortens a candidate interface name to fit
// util.ValidateInterfaceName's 15-char limit while staying unique within
// this batch, reusing the numeric suffix strategy from uniqueID.
func truncateInterfaceName(slug string, existingNames map[string]bool) string {
const maxLen = 15
base := slug
if len(base) > maxLen {
base = base[:maxLen]
}
if !existingNames[base] && util.ValidateInterfaceName(base) {
existingNames[base] = true
return base
}
for i := 2; ; i++ {
suffix := fmt.Sprintf("-%d", i)
cut := maxLen - len(suffix)
if cut < 1 {
cut = 1
}
truncated := slug
if len(truncated) > cut {
truncated = truncated[:cut]
}
candidate := truncated + suffix
if !existingNames[candidate] && util.ValidateInterfaceName(candidate) {
existingNames[candidate] = true
return candidate
}
}
}
// ToPreview maps a ParsedConfig into admin-editable PreviewServer/
// PreviewClient structures. existingServerIDs should list every server ID
// already present in the store (see store.IStore.GetServers), so imported
// IDs never collide with them.
func ToPreview(cfg *ParsedConfig, existingServerIDs []string) *PreviewResult {
usedIDs := make(map[string]bool, len(existingServerIDs))
for _, id := range existingServerIDs {
usedIDs[id] = true
}
usedIfaceNames := make(map[string]bool)
// index clients by their OPNsense uuid so each server's <peers> list
// (the only place the client<->server association is recorded) can
// pull in the right ones.
clientsByUUID := make(map[string]rawClient, len(cfg.Clients))
for _, c := range cfg.Clients {
clientsByUUID[c.UUID] = c
}
result := &PreviewResult{}
for _, rs := range cfg.Servers {
ps := PreviewServer{
SourceUUID: rs.UUID,
Name: rs.Name,
Enabled: isTruthy(rs.Enabled),
DNSServers: splitList(rs.DNS),
}
if ps.Name == "" {
ps.Name = fmt.Sprintf("opnsense-server-%s", rs.Instance)
}
slug := slugify(ps.Name)
ps.ID = uniqueID(slug, usedIDs)
ps.Interface = truncateInterfaceName(slug, usedIfaceNames)
ps.Addresses = splitList(rs.TunnelAddress)
if len(ps.Addresses) == 0 {
ps.Warnings = append(ps.Warnings, "no tunnel address found; please set the server's address range manually")
} else if !util.ValidateServerAddresses(ps.Addresses) {
ps.Warnings = append(ps.Warnings, "tunnel address is not valid CIDR; please fix before importing")
}
if rs.Port != "" {
if port, err := strconv.Atoi(strings.TrimSpace(rs.Port)); err == nil && port > 0 && port <= 65535 {
ps.ListenPort = port
} else {
ps.Warnings = append(ps.Warnings, fmt.Sprintf("invalid listen port %q; please set one manually", rs.Port))
}
} else {
ps.Warnings = append(ps.Warnings, "no listen port found; please set one manually")
}
if rs.MTU != "" {
if mtu, err := strconv.Atoi(strings.TrimSpace(rs.MTU)); err == nil && mtu > 0 {
ps.MTU = mtu
}
}
if rs.PrivKey == "" {
ps.Warnings = append(ps.Warnings, "no private key found; a server cannot be imported without one")
} else {
ps.PrivateKey = rs.PrivKey
key, err := wgtypes.ParseKey(rs.PrivKey)
if err != nil {
ps.Warnings = append(ps.Warnings, fmt.Sprintf("private key does not parse as a valid WireGuard key: %v", err))
ps.PrivateKey = ""
} else {
// Always derive the public key from the private key rather
// than trusting the exported <pubkey> blindly.
ps.PublicKey = key.PublicKey().String()
}
}
peerUUIDs := splitList(rs.Peers)
ps.EndpointAddress = firstServerAddress(cfg.Clients, peerUUIDs)
for _, peerUUID := range peerUUIDs {
rc, ok := clientsByUUID[peerUUID]
if !ok {
ps.Warnings = append(ps.Warnings, fmt.Sprintf("referenced peer %q was not found among the parsed clients", peerUUID))
continue
}
ps.Clients = append(ps.Clients, mapClient(rc))
}
result.Servers = append(result.Servers, ps)
}
result.ReviewChecklist = buildReviewChecklist(cfg)
return result
}
// buildReviewChecklist summarizes static routes, firewall rules, and
// outbound NAT rules found in the source config.xml that wireguard-ui-multi
// has no equivalent import path for. Purely informational.
func buildReviewChecklist(cfg *ParsedConfig) []ReviewChecklistItem {
var items []ReviewChecklistItem
for _, r := range cfg.StaticRoutes {
desc := fmt.Sprintf("route %s via %s", r.Network, r.Gateway)
if r.Descr != "" {
desc += fmt.Sprintf(" (%s)", r.Descr)
}
items = append(items, ReviewChecklistItem{Kind: "static_route", Description: desc})
}
for _, r := range cfg.FilterRules {
desc := fmt.Sprintf("%s rule on %s: %s -> %s", orDefault(r.Type, "pass"), r.Interface, orDefault(r.Source.Network, "any"), orDefault(r.Destination.Network, "any"))
if r.Descr != "" {
desc += fmt.Sprintf(" (%s)", r.Descr)
}
items = append(items, ReviewChecklistItem{Kind: "filter_rule", Description: desc})
}
for _, r := range cfg.NatRules {
desc := fmt.Sprintf("outbound NAT on %s: %s -> %s", r.Interface, orDefault(r.Source.Network, "any"), orDefault(r.Target, "interface address"))
if r.Descr != "" {
desc += fmt.Sprintf(" (%s)", r.Descr)
}
items = append(items, ReviewChecklistItem{Kind: "nat_rule", Description: desc})
}
return items
}
func orDefault(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
func mapClient(rc rawClient) PreviewClient {
pc := PreviewClient{
SourceUUID: rc.UUID,
Name: rc.Name,
Enabled: isTruthy(rc.Enabled),
PublicKey: rc.PubKey,
PresharedKey: rc.PSK,
AdditionalNotes: "Imported from OPNsense",
}
if pc.Name == "" {
pc.Name = rc.UUID
}
if rc.PubKey == "" {
pc.Warnings = append(pc.Warnings, "no public key found; this client cannot be imported without one")
} else if _, err := wgtypes.ParseKey(rc.PubKey); err != nil {
pc.Warnings = append(pc.Warnings, fmt.Sprintf("public key does not parse as a valid WireGuard key: %v", err))
}
pc.AllocatedIPs = splitList(rc.TunnelAddress)
if len(pc.AllocatedIPs) == 0 {
pc.Warnings = append(pc.Warnings, "no tunnel address found; please set the client's allocated address manually")
} else if !util.ValidateAllowedIPs(pc.AllocatedIPs) {
pc.Warnings = append(pc.Warnings, "tunnel address is not valid CIDR; please fix before importing")
}
// OPNsense has no separate "allowed IPs for this peer" field for
// clients; default to the client's own allocated address(es), same as
// wireguard-ui-multi's own "new client" default.
pc.AllowedIPs = append([]string(nil), pc.AllocatedIPs...)
if rc.Keepalive != "" {
if ka, err := strconv.Atoi(strings.TrimSpace(rc.Keepalive)); err == nil && ka > 0 {
pc.PersistentKeepalive = ka
}
}
if rc.ServerAddress != "" {
// stashed for the caller to optionally fold into the server's
// EndpointAddress override; not part of the per-client model.
pc.AdditionalNotes += fmt.Sprintf(" (OPNsense serveraddress: %s)", rc.ServerAddress)
}
return pc
}
// firstServerAddress returns the first non-empty serveraddress[:serverport]
// combination found among rc's clients, used to prefill a server's
// EndpointAddress override in the preview.
func firstServerAddress(clients []rawClient, peerUUIDs []string) string {
wanted := make(map[string]bool, len(peerUUIDs))
for _, u := range peerUUIDs {
wanted[u] = true
}
for _, c := range clients {
if !wanted[c.UUID] || c.ServerAddress == "" {
continue
}
if c.ServerPort != "" {
return fmt.Sprintf("%s:%s", c.ServerAddress, strings.TrimSpace(c.ServerPort))
}
return c.ServerAddress
}
return ""
}
// ToModel converts an admin-reviewed PreviewServer into the model records
// wireguard-ui-multi actually stores: a Server, its ServerSetting, and the
// Clients that belong to it. It does not touch the store itself - callers
// (the commit handler) are responsible for the actual SaveXxx calls, and
// for checking public-key collisions against clients already in the store.
func ToModel(ps PreviewServer) (model.Server, model.ServerSetting, []model.Client, error) {
now := time.Now().UTC()
if !util.ValidateRecordID(ps.ID) {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid server id %q", ps.ID)
}
if !util.ValidateInterfaceName(ps.Interface) {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid interface name %q", ps.Interface)
}
if !util.ValidateServerAddresses(ps.Addresses) {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid server addresses %v", ps.Addresses)
}
if ps.ListenPort <= 0 || ps.ListenPort > 65535 {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("invalid listen port %d", ps.ListenPort)
}
if ps.PrivateKey == "" {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("server %q has no private key", ps.ID)
}
key, err := wgtypes.ParseKey(ps.PrivateKey)
if err != nil {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("server %q private key invalid: %w", ps.ID, err)
}
server := model.Server{
ID: ps.ID,
Name: ps.Name,
KeyPair: &model.ServerKeypair{
PrivateKey: key.String(),
PublicKey: key.PublicKey().String(),
UpdatedAt: now,
},
Interface: &model.ServerInterface{
Name: ps.Interface,
Addresses: ps.Addresses,
ListenPort: ps.ListenPort,
UpdatedAt: now,
},
}
settings := model.ServerSetting{
EndpointAddress: ps.EndpointAddress,
ConfigFilePath: fmt.Sprintf("/etc/wireguard/%s.conf", ps.Interface),
FirewallMark: util.DefaultFirewallMark,
Table: util.DefaultTable,
DNSServers: ps.DNSServers,
MTU: ps.MTU,
UpdatedAt: now,
}
clients := make([]model.Client, 0, len(ps.Clients))
for _, pc := range ps.Clients {
if pc.PublicKey == "" {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q has no public key", pc.Name)
}
if _, err := wgtypes.ParseKey(pc.PublicKey); err != nil {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q public key invalid: %w", pc.Name, err)
}
if !util.ValidateAllowedIPs(pc.AllocatedIPs) {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q has invalid allocated IPs", pc.Name)
}
if !util.ValidateAllowedIPs(pc.AllowedIPs) {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q has invalid allowed IPs", pc.Name)
}
presharedKey := pc.PresharedKey
if presharedKey != "" {
if _, err := wgtypes.ParseKey(presharedKey); err != nil {
return model.Server{}, model.ServerSetting{}, nil, fmt.Errorf("client %q preshared key invalid: %w", pc.Name, err)
}
}
clients = append(clients, model.Client{
ServerID: ps.ID,
PublicKey: pc.PublicKey,
PresharedKey: presharedKey,
Name: pc.Name,
Email: pc.Email,
AllocatedIPs: pc.AllocatedIPs,
AllowedIPs: pc.AllowedIPs,
AdditionalNotes: pc.AdditionalNotes,
PersistentKeepalive: pc.PersistentKeepalive,
Enabled: pc.Enabled,
CreatedAt: now,
UpdatedAt: now,
})
}
return server, settings, clients, nil
}