From 388a8377cd204f91fe80d0584973144f9abe8a3f Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 24 Jul 2026 00:05:08 +0200 Subject: [PATCH] Add per-server Interface/KeyPair edit hardening and OPNsense config import Redact the private key from the /servers/:id/keypair response body - the UI never rendered it, but the raw key was still returned over the wire (json:"private_key,omitempty" plus explicit clearing before the JSON response). Add a new import flow: an admin can upload an OPNsense config.xml, preview the WireGuard servers/clients it defines (editable before committing), and confirm to create the corresponding Server/ServerSetting/Client records. Nothing is auto-applied - no wg-quick/systemctl call happens, matching the existing manual "Apply" step for regular server management. Schema verified against OPNsense core (WireGuard has been in core since 22.1, not a plugin) - see opnsense/parse.go for the confirmed tag reference. Public keys are always re-derived from private keys rather than trusted from the export; client public-key collisions against existing store data are skipped and reported per-batch rather than aborting the whole import. Since OPNsense stores DNS/MTU per-server and keepalive per-client, but this fork only had those app-wide (GlobalSetting), extended ServerSetting with DNSServers/MTU and Client with PersistentKeepalive as optional overrides that fall back to the global default when unset - existing single-server behavior is unchanged when the override is empty/zero. Manual UI editing of the per-client keepalive override outside the import flow is left for a later pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019VjwLYRA87o8m9a9zztgs3 --- handler/routes.go | 23 +- handler/routes_opnsense_import.go | 140 +++++++++++ main.go | 2 + model/client.go | 40 ++-- model/server.go | 2 +- model/setting.go | 9 +- opnsense/map.go | 373 ++++++++++++++++++++++++++++++ opnsense/parse.go | 131 +++++++++++ templates/servers.html | 244 ++++++++++++++++++- util/util.go | 10 +- 10 files changed, 943 insertions(+), 31 deletions(-) create mode 100644 handler/routes_opnsense_import.go create mode 100644 opnsense/map.go create mode 100644 opnsense/parse.go diff --git a/handler/routes.go b/handler/routes.go index 49c1135..9c4a52f 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -48,17 +48,27 @@ func resolveServerID(c echo.Context) string { } // buildEffectiveSettings merges the app-wide GlobalSetting (DNS/MTU/ -// PersistentKeepalive) with a server's own EndpointAddress override (from -// ServerSetting) into a single model.GlobalSetting, so util.BuildClientConfig -// can keep its existing single-struct signature unchanged. +// PersistentKeepalive) with a server's own EndpointAddress/DNSServers/MTU +// overrides (from ServerSetting) into a single model.GlobalSetting, so +// util.BuildClientConfig can keep its existing single-struct signature +// unchanged. Empty/zero overrides fall back to the global default, so +// existing single-server installs (no override ever set) are unaffected. func buildEffectiveSettings(db store.IStore, serverID string) (model.GlobalSetting, error) { globalSettings, err := db.GetGlobalSettings() if err != nil { return globalSettings, err } serverSettings, err := db.GetServerSettings(serverID) - if err == nil && serverSettings.EndpointAddress != "" { - globalSettings.EndpointAddress = serverSettings.EndpointAddress + if err == nil { + if serverSettings.EndpointAddress != "" { + globalSettings.EndpointAddress = serverSettings.EndpointAddress + } + if len(serverSettings.DNSServers) > 0 { + globalSettings.DNSServers = serverSettings.DNSServers + } + if serverSettings.MTU > 0 { + globalSettings.MTU = serverSettings.MTU + } } return globalSettings, nil } @@ -1408,6 +1418,9 @@ func UpdateServerKeyPairHandler(db store.IStore) echo.HandlerFunc { log.Infof("Updated wireguard server key pair for server %s", serverID) + // Never return the private key in the HTTP response body; the + // caller only needs the public key to update its view. + serverKeyPair.PrivateKey = "" return c.JSON(http.StatusOK, serverKeyPair) } } diff --git a/handler/routes_opnsense_import.go b/handler/routes_opnsense_import.go new file mode 100644 index 0000000..3c72dc7 --- /dev/null +++ b/handler/routes_opnsense_import.go @@ -0,0 +1,140 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" + "github.com/rs/xid" + + "github.com/ngoduykhanh/wireguard-ui/opnsense" + "github.com/ngoduykhanh/wireguard-ui/store" +) + +// maxOPNsenseUploadSize caps the accepted config.xml upload size. +const maxOPNsenseUploadSize = 5 << 20 // 5 MiB + +// PreviewOPNsenseImport handler accepts an uploaded OPNsense config.xml, +// parses and maps its WireGuard server/client definitions, and returns the +// staged (admin-editable) preview as JSON. It never touches the store - +// nothing is written, nothing is applied. Admin-only. +func PreviewOPNsenseImport(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + fileHeader, err := c.FormFile("config") + if err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please upload a config.xml file"}) + } + if fileHeader.Size > maxOPNsenseUploadSize { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "config.xml is too large (max 5 MiB)"}) + } + + src, err := fileHeader.Open() + if err != nil { + log.Error("Cannot open uploaded OPNsense config.xml: ", err) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Cannot read uploaded file"}) + } + defer src.Close() + + parsed, err := opnsense.Parse(src) + if err != nil { + log.Error("Cannot parse OPNsense config.xml: ", err) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("Cannot parse config.xml: %v", err)}) + } + + existingServers, err := db.GetServers() + if err != nil { + log.Error("Cannot list existing servers: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot list existing servers"}) + } + existingIDs := make([]string, 0, len(existingServers)) + for _, s := range existingServers { + existingIDs = append(existingIDs, s.ID) + } + + preview := opnsense.ToPreview(parsed, existingIDs) + return c.JSON(http.StatusOK, preview) + } +} + +// CommitOPNsenseImport handler accepts the (possibly admin-edited) preview +// JSON produced by PreviewOPNsenseImport - not the original XML - and +// writes the corresponding model.Server/ServerSetting/Client records via +// the store. Servers/clients that fail validation, or clients whose public +// key collides with one already in the store, are skipped and reported; +// the rest of the batch still commits. This never applies the resulting +// WireGuard config (no wg-quick/systemctl calls) - that remains a separate, +// manual "Apply" step elsewhere in the app. Admin-only. +func CommitOPNsenseImport(db store.IStore) echo.HandlerFunc { + return func(c echo.Context) error { + var payload opnsense.PreviewResult + if err := c.Bind(&payload); err != nil { + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"}) + } + + existingClients, err := db.GetClients(false) + if err != nil { + log.Error("Cannot list existing clients for duplicate check: ", err) + return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot list existing clients"}) + } + knownPublicKeys := make(map[string]bool, len(existingClients)) + for _, cd := range existingClients { + knownPublicKeys[cd.Client.PublicKey] = true + } + + type serverResult struct { + ID string `json:"id"` + Imported bool `json:"imported"` + Error string `json:"error,omitempty"` + ClientsCreated int `json:"clients_created"` + ClientsSkipped []string `json:"clients_skipped,omitempty"` + } + results := make([]serverResult, 0, len(payload.Servers)) + + for _, ps := range payload.Servers { + res := serverResult{ID: ps.ID} + + server, settings, clients, err := opnsense.ToModel(ps) + if err != nil { + res.Error = err.Error() + results = append(results, res) + continue + } + + if _, err := db.GetServerByID(server.ID); err == nil { + res.Error = "a server with this ID already exists" + results = append(results, res) + continue + } + + if err := db.CreateServer(server); err != nil { + res.Error = fmt.Sprintf("cannot create server: %v", err) + results = append(results, res) + continue + } + if err := db.SaveServerSettings(server.ID, settings); err != nil { + log.Errorf("Server %s created but settings failed: %v", server.ID, err) + } + + for _, client := range clients { + if knownPublicKeys[client.PublicKey] { + res.ClientsSkipped = append(res.ClientsSkipped, fmt.Sprintf("%s (duplicate public key)", client.Name)) + continue + } + client.ID = xid.New().String() + if err := db.SaveClient(client); err != nil { + res.ClientsSkipped = append(res.ClientsSkipped, fmt.Sprintf("%s (%v)", client.Name, err)) + continue + } + knownPublicKeys[client.PublicKey] = true + res.ClientsCreated++ + } + + res.Imported = true + log.Infof("Imported server %s from OPNsense config (%d clients)", server.ID, res.ClientsCreated) + results = append(results, res) + } + + return c.JSON(http.StatusOK, results) + } +} diff --git a/main.go b/main.go index 9bddc4a..2533652 100644 --- a/main.go +++ b/main.go @@ -266,6 +266,8 @@ func main() { app.GET(util.BasePath+"/servers-settings", handler.ServersPage(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin) app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession) app.POST(util.BasePath+"/servers", handler.CreateServer(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) + app.POST(util.BasePath+"/servers/import/opnsense/preview", handler.PreviewOPNsenseImport(db), handler.ValidSession, handler.NeedsAdmin) + app.POST(util.BasePath+"/servers/import/opnsense/commit", handler.CommitOPNsenseImport(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.GET(util.BasePath+"/servers/:id/clients", handler.ServerClientsPage(db), handler.ValidSession, handler.RefreshSession, handler.RequireServerAccess(db)) app.GET(util.BasePath+"/servers/:id/api/clients", handler.GetServerClients(db), handler.ValidSession, handler.RequireServerAccess(db)) app.GET(util.BasePath+"/servers/:id/api/client/:cid", handler.GetServerClient(db), handler.ValidSession, handler.RequireServerAccess(db)) diff --git a/model/client.go b/model/client.go index a15b33a..3e09248 100644 --- a/model/client.go +++ b/model/client.go @@ -6,24 +6,28 @@ import ( // Client model type Client struct { - ID string `json:"id"` - ServerID string `json:"server_id,omitempty"` - PrivateKey string `json:"private_key"` - PublicKey string `json:"public_key"` - PresharedKey string `json:"preshared_key"` - Name string `json:"name"` - TgUserid string `json:"telegram_userid"` - Email string `json:"email"` - SubnetRanges []string `json:"subnet_ranges,omitempty"` - AllocatedIPs []string `json:"allocated_ips"` - AllowedIPs []string `json:"allowed_ips"` - ExtraAllowedIPs []string `json:"extra_allowed_ips"` - Endpoint string `json:"endpoint"` - AdditionalNotes string `json:"additional_notes"` - UseServerDNS bool `json:"use_server_dns"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + ServerID string `json:"server_id,omitempty"` + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` + PresharedKey string `json:"preshared_key"` + Name string `json:"name"` + TgUserid string `json:"telegram_userid"` + Email string `json:"email"` + SubnetRanges []string `json:"subnet_ranges,omitempty"` + AllocatedIPs []string `json:"allocated_ips"` + AllowedIPs []string `json:"allowed_ips"` + ExtraAllowedIPs []string `json:"extra_allowed_ips"` + Endpoint string `json:"endpoint"` + AdditionalNotes string `json:"additional_notes"` + UseServerDNS bool `json:"use_server_dns"` + // PersistentKeepalive is an optional per-client override of the + // app-wide GlobalSetting.PersistentKeepalive. 0 means "use the global + // default" (see handler.buildEffectiveSettings / util.BuildClientConfig). + PersistentKeepalive int `json:"persistent_keepalive,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ClientData includes the Client and extra data diff --git a/model/server.go b/model/server.go index 4e6bc62..5b1d6d6 100644 --- a/model/server.go +++ b/model/server.go @@ -14,7 +14,7 @@ type Server struct { // ServerKeypair model type ServerKeypair struct { - PrivateKey string `json:"private_key"` + PrivateKey string `json:"private_key,omitempty"` PublicKey string `json:"public_key"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/model/setting.go b/model/setting.go index f1639fc..70aa2fb 100644 --- a/model/setting.go +++ b/model/setting.go @@ -28,6 +28,11 @@ type ServerSetting struct { // this server's WireGuard traffic should be allowed to forward to/from. // Only used to generate the nftables ruleset preview; left empty means // the preview only covers the WireGuard interface itself. - LanInterface string `json:"lan_interface,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + LanInterface string `json:"lan_interface,omitempty"` + // DNSServers and MTU are optional per-server overrides of the app-wide + // GlobalSetting.DNSServers/GlobalSetting.MTU. Leave empty/zero to fall + // back to the global default (see handler.buildEffectiveSettings). + DNSServers []string `json:"dns_servers,omitempty"` + MTU int `json:"mtu,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/opnsense/map.go b/opnsense/map.go new file mode 100644 index 0000000..80588b5 --- /dev/null +++ b/opnsense/map.go @@ -0,0 +1,373 @@ +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 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"` +} + +// 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"` +} + +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 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 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) + } + + return result +} + +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 +} diff --git a/opnsense/parse.go b/opnsense/parse.go new file mode 100644 index 0000000..b3560f0 --- /dev/null +++ b/opnsense/parse.go @@ -0,0 +1,131 @@ +// 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): +// +// +// +// +// +// +// +// +// +// +// +// +// +package opnsense + +import ( + "encoding/xml" + "fmt" + "io" + "strings" +) + +// rawConfig mirrors the on-disk config.xml structure. +type rawConfig struct { + XMLName xml.Name `xml:"OPNsense"` + 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"` +} + +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.Wireguard.Server.Servers.Server, + Clients: cfg.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") +} diff --git a/templates/servers.html b/templates/servers.html index 0f04746..6f95d05 100644 --- a/templates/servers.html +++ b/templates/servers.html @@ -107,6 +107,17 @@ All Servers Optional. Used only for the Firewall Preview - lets peers forward to this interface. +
+ + + Comma-separated. Leave empty to fall back to the app-wide default DNS servers. +
+
+ + + Leave empty to fall back to the app-wide default MTU. +
+ + +