Files
wireguard-ui-multi/handler/routes_opnsense_import.go
T
sysopsandClaude Sonnet 5 388a8377cd 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VjwLYRA87o8m9a9zztgs3
2026-07-24 00:05:08 +02:00

141 lines
4.8 KiB
Go

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)
}
}