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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0dbb916866
commit
388a8377cd
+18
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user