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/
|
// buildEffectiveSettings merges the app-wide GlobalSetting (DNS/MTU/
|
||||||
// PersistentKeepalive) with a server's own EndpointAddress override (from
|
// PersistentKeepalive) with a server's own EndpointAddress/DNSServers/MTU
|
||||||
// ServerSetting) into a single model.GlobalSetting, so util.BuildClientConfig
|
// overrides (from ServerSetting) into a single model.GlobalSetting, so
|
||||||
// can keep its existing single-struct signature unchanged.
|
// 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) {
|
func buildEffectiveSettings(db store.IStore, serverID string) (model.GlobalSetting, error) {
|
||||||
globalSettings, err := db.GetGlobalSettings()
|
globalSettings, err := db.GetGlobalSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return globalSettings, err
|
return globalSettings, err
|
||||||
}
|
}
|
||||||
serverSettings, err := db.GetServerSettings(serverID)
|
serverSettings, err := db.GetServerSettings(serverID)
|
||||||
if err == nil && serverSettings.EndpointAddress != "" {
|
if err == nil {
|
||||||
globalSettings.EndpointAddress = serverSettings.EndpointAddress
|
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
|
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)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-settings", handler.ServersPage(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
|
||||||
app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession)
|
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", 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/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/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))
|
app.GET(util.BasePath+"/servers/:id/api/client/:cid", handler.GetServerClient(db), handler.ValidSession, handler.RequireServerAccess(db))
|
||||||
|
|||||||
+22
-18
@@ -6,24 +6,28 @@ import (
|
|||||||
|
|
||||||
// Client model
|
// Client model
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ServerID string `json:"server_id,omitempty"`
|
ServerID string `json:"server_id,omitempty"`
|
||||||
PrivateKey string `json:"private_key"`
|
PrivateKey string `json:"private_key"`
|
||||||
PublicKey string `json:"public_key"`
|
PublicKey string `json:"public_key"`
|
||||||
PresharedKey string `json:"preshared_key"`
|
PresharedKey string `json:"preshared_key"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
TgUserid string `json:"telegram_userid"`
|
TgUserid string `json:"telegram_userid"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
SubnetRanges []string `json:"subnet_ranges,omitempty"`
|
SubnetRanges []string `json:"subnet_ranges,omitempty"`
|
||||||
AllocatedIPs []string `json:"allocated_ips"`
|
AllocatedIPs []string `json:"allocated_ips"`
|
||||||
AllowedIPs []string `json:"allowed_ips"`
|
AllowedIPs []string `json:"allowed_ips"`
|
||||||
ExtraAllowedIPs []string `json:"extra_allowed_ips"`
|
ExtraAllowedIPs []string `json:"extra_allowed_ips"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
AdditionalNotes string `json:"additional_notes"`
|
AdditionalNotes string `json:"additional_notes"`
|
||||||
UseServerDNS bool `json:"use_server_dns"`
|
UseServerDNS bool `json:"use_server_dns"`
|
||||||
Enabled bool `json:"enabled"`
|
// PersistentKeepalive is an optional per-client override of the
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// app-wide GlobalSetting.PersistentKeepalive. 0 means "use the global
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// 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
|
// ClientData includes the Client and extra data
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ type Server struct {
|
|||||||
|
|
||||||
// ServerKeypair model
|
// ServerKeypair model
|
||||||
type ServerKeypair struct {
|
type ServerKeypair struct {
|
||||||
PrivateKey string `json:"private_key"`
|
PrivateKey string `json:"private_key,omitempty"`
|
||||||
PublicKey string `json:"public_key"`
|
PublicKey string `json:"public_key"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -28,6 +28,11 @@ type ServerSetting struct {
|
|||||||
// this server's WireGuard traffic should be allowed to forward to/from.
|
// this server's WireGuard traffic should be allowed to forward to/from.
|
||||||
// Only used to generate the nftables ruleset preview; left empty means
|
// Only used to generate the nftables ruleset preview; left empty means
|
||||||
// the preview only covers the WireGuard interface itself.
|
// the preview only covers the WireGuard interface itself.
|
||||||
LanInterface string `json:"lan_interface,omitempty"`
|
LanInterface string `json:"lan_interface,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
+373
@@ -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 <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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 <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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
//
|
||||||
|
// <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>
|
||||||
|
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")
|
||||||
|
}
|
||||||
+241
-3
@@ -107,6 +107,17 @@ All Servers
|
|||||||
<input type="text" class="form-control" id="_settings_lan_interface" placeholder="e.g. eth0, br-lan">
|
<input type="text" class="form-control" id="_settings_lan_interface" placeholder="e.g. eth0, br-lan">
|
||||||
<small class="form-text text-muted">Optional. Used only for the Firewall Preview - lets peers forward to this interface.</small>
|
<small class="form-text text-muted">Optional. Used only for the Firewall Preview - lets peers forward to this interface.</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="_settings_dns_servers" class="control-label">DNS Servers (override)</label>
|
||||||
|
<input type="text" class="form-control" id="_settings_dns_servers"
|
||||||
|
placeholder="e.g. 1.1.1.1, 8.8.8.8">
|
||||||
|
<small class="form-text text-muted">Comma-separated. Leave empty to fall back to the app-wide default DNS servers.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="_settings_mtu" class="control-label">MTU (override)</label>
|
||||||
|
<input type="text" class="form-control" id="_settings_mtu" placeholder="e.g. 1420">
|
||||||
|
<small class="form-text text-muted">Leave empty to fall back to the app-wide default MTU.</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer justify-content-between">
|
<div class="modal-footer justify-content-between">
|
||||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
@@ -180,6 +191,36 @@ All Servers
|
|||||||
</div>
|
</div>
|
||||||
<!-- /.modal -->
|
<!-- /.modal -->
|
||||||
|
|
||||||
|
<div class="modal fade" id="modal_import_opnsense">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title">Import from OPNsense</h4>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted">Upload an OPNsense <code>config.xml</code>. This only stages the data below for
|
||||||
|
you to review and edit - nothing is written until you click "Confirm Import", and the resulting
|
||||||
|
servers are never started automatically (use the normal per-server "Apply" flow for that).</p>
|
||||||
|
<div class="form-inline mb-2">
|
||||||
|
<input type="file" id="_opnsense_file" accept=".xml">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm ml-2" id="btn_opnsense_preview">Preview</button>
|
||||||
|
</div>
|
||||||
|
<div id="_opnsense_preview_area"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-between">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-success" id="btn_opnsense_confirm" style="display:none;">Confirm Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /.modal-content -->
|
||||||
|
</div>
|
||||||
|
<!-- /.modal-dialog -->
|
||||||
|
</div>
|
||||||
|
<!-- /.modal -->
|
||||||
|
|
||||||
<div class="modal fade" id="modal_firewall">
|
<div class="modal fade" id="modal_firewall">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -309,10 +350,13 @@ All Servers
|
|||||||
// load server list
|
// load server list
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
populateServersList();
|
populateServersList();
|
||||||
let newServerHtml = '<div class="col-sm-2 offset-md-4" style=" text-align: right;">' +
|
let newServerHtml = '<div class="col-sm-3 offset-md-3" style=" text-align: right;">' +
|
||||||
'<button style="" id="btn_new_server" type="button" class="btn btn-outline-primary btn-sm" ' +
|
'<button style="" id="btn_new_server" type="button" class="btn btn-outline-primary btn-sm" ' +
|
||||||
'data-toggle="modal" data-target="#modal_new_server">' +
|
'data-toggle="modal" data-target="#modal_new_server">' +
|
||||||
'<i class="nav-icon fas fa-plus"></i> New Server</button></div>';
|
'<i class="nav-icon fas fa-plus"></i> New Server</button> ' +
|
||||||
|
'<button id="btn_import_opnsense" type="button" class="btn btn-outline-secondary btn-sm" ' +
|
||||||
|
'data-toggle="modal" data-target="#modal_import_opnsense">' +
|
||||||
|
'<i class="nav-icon fas fa-file-import"></i> Import from OPNsense</button></div>';
|
||||||
$('h1').parents(".row").append(newServerHtml);
|
$('h1').parents(".row").append(newServerHtml);
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -377,6 +421,8 @@ All Servers
|
|||||||
modal.find("#_settings_firewall_mark").val("");
|
modal.find("#_settings_firewall_mark").val("");
|
||||||
modal.find("#_settings_table").val("");
|
modal.find("#_settings_table").val("");
|
||||||
modal.find("#_settings_lan_interface").val("");
|
modal.find("#_settings_lan_interface").val("");
|
||||||
|
modal.find("#_settings_dns_servers").val("");
|
||||||
|
modal.find("#_settings_mtu").val("");
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
cache: false,
|
cache: false,
|
||||||
@@ -390,6 +436,8 @@ All Servers
|
|||||||
modal.find("#_settings_firewall_mark").val(settings.firewall_mark);
|
modal.find("#_settings_firewall_mark").val(settings.firewall_mark);
|
||||||
modal.find("#_settings_table").val(settings.table);
|
modal.find("#_settings_table").val(settings.table);
|
||||||
modal.find("#_settings_lan_interface").val(settings.lan_interface);
|
modal.find("#_settings_lan_interface").val(settings.lan_interface);
|
||||||
|
modal.find("#_settings_dns_servers").val((settings.dns_servers || []).join(", "));
|
||||||
|
modal.find("#_settings_mtu").val(settings.mtu || "");
|
||||||
},
|
},
|
||||||
error: function (jqXHR, exception) {
|
error: function (jqXHR, exception) {
|
||||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
@@ -400,12 +448,19 @@ All Servers
|
|||||||
|
|
||||||
function submitServerSettings() {
|
function submitServerSettings() {
|
||||||
const serverId = $("#_settings_server_id").val();
|
const serverId = $("#_settings_server_id").val();
|
||||||
|
const dnsServers = $("#_settings_dns_servers").val().split(",").map(function (a) {
|
||||||
|
return a.trim();
|
||||||
|
}).filter(function (a) {
|
||||||
|
return a !== "";
|
||||||
|
});
|
||||||
const data = {
|
const data = {
|
||||||
"endpoint_address": $("#_settings_endpoint_address").val(),
|
"endpoint_address": $("#_settings_endpoint_address").val(),
|
||||||
"config_file_path": $("#_settings_config_file_path").val(),
|
"config_file_path": $("#_settings_config_file_path").val(),
|
||||||
"firewall_mark": $("#_settings_firewall_mark").val(),
|
"firewall_mark": $("#_settings_firewall_mark").val(),
|
||||||
"table": $("#_settings_table").val(),
|
"table": $("#_settings_table").val(),
|
||||||
"lan_interface": $("#_settings_lan_interface").val()
|
"lan_interface": $("#_settings_lan_interface").val(),
|
||||||
|
"dns_servers": dnsServers,
|
||||||
|
"mtu": parseInt($("#_settings_mtu").val(), 10) || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@@ -712,6 +767,189 @@ All Servers
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OPNsense import: preview step (multipart upload, no store writes)
|
||||||
|
var opnsensePreviewData = null;
|
||||||
|
|
||||||
|
function csvList(arr) {
|
||||||
|
return (arr || []).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOPNsensePreview(preview) {
|
||||||
|
opnsensePreviewData = preview;
|
||||||
|
const area = $("#_opnsense_preview_area");
|
||||||
|
area.empty();
|
||||||
|
|
||||||
|
if (!preview.servers || preview.servers.length === 0) {
|
||||||
|
area.append('<p class="text-muted">No WireGuard servers found in this config.xml.</p>');
|
||||||
|
$("#btn_opnsense_confirm").hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.each(preview.servers, function (si, server) {
|
||||||
|
let warnings = "";
|
||||||
|
if (server.warnings && server.warnings.length) {
|
||||||
|
warnings = '<div class="alert alert-warning py-1 px-2 mb-2">' + server.warnings.join("<br>") + '</div>';
|
||||||
|
}
|
||||||
|
let html = '<div class="card mb-3" data-serverindex="' + si + '">' +
|
||||||
|
'<div class="card-header py-1"><strong>Server</strong></div>' +
|
||||||
|
'<div class="card-body py-2">' + warnings +
|
||||||
|
'<div class="form-row">' +
|
||||||
|
'<div class="col-md-2"><label>ID</label><input type="text" class="form-control form-control-sm _f_id" value="' + (server.id || "") + '"></div>' +
|
||||||
|
'<div class="col-md-2"><label>Name</label><input type="text" class="form-control form-control-sm _f_name" value="' + (server.name || "") + '"></div>' +
|
||||||
|
'<div class="col-md-2"><label>Interface</label><input type="text" class="form-control form-control-sm _f_interface" value="' + (server.interface || "") + '"></div>' +
|
||||||
|
'<div class="col-md-3"><label>Addresses</label><input type="text" class="form-control form-control-sm _f_addresses" value="' + csvList(server.addresses) + '"></div>' +
|
||||||
|
'<div class="col-md-1"><label>Port</label><input type="text" class="form-control form-control-sm _f_port" value="' + (server.listen_port || "") + '"></div>' +
|
||||||
|
'<div class="col-md-2"><label>MTU</label><input type="text" class="form-control form-control-sm _f_mtu" value="' + (server.mtu || "") + '"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="form-row mt-1">' +
|
||||||
|
'<div class="col-md-4"><label>DNS servers</label><input type="text" class="form-control form-control-sm _f_dns" value="' + csvList(server.dns_servers) + '"></div>' +
|
||||||
|
'<div class="col-md-4"><label>Endpoint address</label><input type="text" class="form-control form-control-sm _f_endpoint" value="' + (server.endpoint_address || "") + '"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<table class="table table-sm mt-2 mb-0"><thead><tr><th>Client name</th><th>Allocated IP</th><th>Allowed IPs</th><th>Keepalive</th><th>Public key</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
$.each(server.clients || [], function (ci, client) {
|
||||||
|
let cwarn = "";
|
||||||
|
if (client.warnings && client.warnings.length) {
|
||||||
|
cwarn = '<br><small class="text-warning">' + client.warnings.join("; ") + '</small>';
|
||||||
|
}
|
||||||
|
html += '<tr data-clientindex="' + ci + '">' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm _c_name" value="' + (client.name || "") + '">' + cwarn + '</td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm _c_allocated" value="' + csvList(client.allocated_ips) + '"></td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm _c_allowed" value="' + csvList(client.allowed_ips) + '"></td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm _c_keepalive" value="' + (client.persistent_keepalive || "") + '"></td>' +
|
||||||
|
'<td><small class="text-muted">' + (client.public_key || "(missing)") + '</small></td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div></div>';
|
||||||
|
area.append(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn_opnsense_confirm").show();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#btn_opnsense_preview").click(function () {
|
||||||
|
const fileInput = document.getElementById('_opnsense_file');
|
||||||
|
if (!fileInput.files || fileInput.files.length === 0) {
|
||||||
|
toastr.error("Please choose a config.xml file first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('config', fileInput.files[0]);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
cache: false,
|
||||||
|
method: 'POST',
|
||||||
|
url: '{{.basePath}}/servers/import/opnsense/preview',
|
||||||
|
data: formData,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
dataType: 'json',
|
||||||
|
success: function (data) {
|
||||||
|
renderOPNsensePreview(data);
|
||||||
|
},
|
||||||
|
error: function (jqXHR) {
|
||||||
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
|
toastr.error(responseJson['message'] || "Could not parse config.xml");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read the (possibly edited) preview form back into the same shape the
|
||||||
|
// preview endpoint returned, so the commit endpoint gets full PreviewServer/
|
||||||
|
// PreviewClient structures - not the original XML.
|
||||||
|
function collectOPNsensePreviewEdits() {
|
||||||
|
const servers = [];
|
||||||
|
$("#_opnsense_preview_area > .card").each(function () {
|
||||||
|
const card = $(this);
|
||||||
|
const clients = [];
|
||||||
|
card.find("tbody tr").each(function () {
|
||||||
|
const row = $(this);
|
||||||
|
clients.push({
|
||||||
|
name: row.find("._c_name").val(),
|
||||||
|
allocated_ips: row.find("._c_allocated").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
|
||||||
|
allowed_ips: row.find("._c_allowed").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
|
||||||
|
persistent_keepalive: parseInt(row.find("._c_keepalive").val(), 10) || 0,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
servers.push({
|
||||||
|
id: card.find("._f_id").val(),
|
||||||
|
name: card.find("._f_name").val(),
|
||||||
|
interface: card.find("._f_interface").val(),
|
||||||
|
addresses: card.find("._f_addresses").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
|
||||||
|
listen_port: parseInt(card.find("._f_port").val(), 10) || 0,
|
||||||
|
mtu: parseInt(card.find("._f_mtu").val(), 10) || 0,
|
||||||
|
dns_servers: card.find("._f_dns").val().split(",").map(function (a) { return a.trim(); }).filter(function (a) { return a !== ""; }),
|
||||||
|
endpoint_address: card.find("._f_endpoint").val(),
|
||||||
|
enabled: true,
|
||||||
|
clients: clients
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The public key is display-only (not editable) and matched back to
|
||||||
|
// the original preview payload by position, since the input form
|
||||||
|
// doesn't carry it. Merge it in from the last preview response before
|
||||||
|
// sending the commit request.
|
||||||
|
function mergeOriginalPreviewFields(edited) {
|
||||||
|
if (!opnsensePreviewData || !opnsensePreviewData.servers) return edited;
|
||||||
|
$.each(edited, function (si, server) {
|
||||||
|
const orig = opnsensePreviewData.servers[si] || {};
|
||||||
|
server.private_key = orig.private_key;
|
||||||
|
server.public_key = orig.public_key;
|
||||||
|
server.source_uuid = orig.source_uuid;
|
||||||
|
$.each(server.clients, function (ci, client) {
|
||||||
|
const origClient = (orig.clients || [])[ci] || {};
|
||||||
|
client.public_key = origClient.public_key;
|
||||||
|
client.preshared_key = origClient.preshared_key;
|
||||||
|
client.additional_notes = origClient.additional_notes;
|
||||||
|
client.source_uuid = origClient.source_uuid;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return edited;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#btn_opnsense_confirm").click(function () {
|
||||||
|
if (!opnsensePreviewData) return;
|
||||||
|
const edited = mergeOriginalPreviewFields(collectOPNsensePreviewEdits());
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
cache: false,
|
||||||
|
method: 'POST',
|
||||||
|
url: '{{.basePath}}/servers/import/opnsense/commit',
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: "application/json",
|
||||||
|
data: JSON.stringify({ servers: edited }),
|
||||||
|
success: function (results) {
|
||||||
|
let created = 0, failed = 0;
|
||||||
|
$.each(results, function (i, r) {
|
||||||
|
if (r.imported) { created++; } else { failed++; }
|
||||||
|
if (r.error) { toastr.error(r.id + ": " + r.error); }
|
||||||
|
if (r.clients_skipped && r.clients_skipped.length) {
|
||||||
|
toastr.warning(r.id + " skipped clients: " + r.clients_skipped.join(", "));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toastr.success("Imported " + created + " server(s)" + (failed ? (", " + failed + " failed") : ""));
|
||||||
|
$("#modal_import_opnsense").modal('hide');
|
||||||
|
$('#servers-list').empty();
|
||||||
|
populateServersList();
|
||||||
|
},
|
||||||
|
error: function (jqXHR) {
|
||||||
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
|
toastr.error(responseJson['message'] || "Import failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#modal_import_opnsense").on('show.bs.modal', function () {
|
||||||
|
$("#_opnsense_file").val("");
|
||||||
|
$("#_opnsense_preview_area").empty();
|
||||||
|
$("#btn_opnsense_confirm").hide();
|
||||||
|
opnsensePreviewData = null;
|
||||||
|
});
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$.validator.setDefaults({
|
$.validator.setDefaults({
|
||||||
submitHandler: function (form) {
|
submitHandler: function (form) {
|
||||||
|
|||||||
+8
-2
@@ -68,9 +68,15 @@ func BuildClientConfig(client model.Client, server model.Server, setting model.G
|
|||||||
}
|
}
|
||||||
peerEndpoint := fmt.Sprintf("Endpoint = %s:%d\n", desiredHost, desiredPort)
|
peerEndpoint := fmt.Sprintf("Endpoint = %s:%d\n", desiredHost, desiredPort)
|
||||||
|
|
||||||
|
// per-client PersistentKeepalive overrides the app-wide/per-server
|
||||||
|
// default when set (> 0); 0 means "use the default".
|
||||||
|
effectiveKeepalive := setting.PersistentKeepalive
|
||||||
|
if client.PersistentKeepalive > 0 {
|
||||||
|
effectiveKeepalive = client.PersistentKeepalive
|
||||||
|
}
|
||||||
peerPersistentKeepalive := ""
|
peerPersistentKeepalive := ""
|
||||||
if setting.PersistentKeepalive > 0 {
|
if effectiveKeepalive > 0 {
|
||||||
peerPersistentKeepalive = fmt.Sprintf("PersistentKeepalive = %d\n", setting.PersistentKeepalive)
|
peerPersistentKeepalive = fmt.Sprintf("PersistentKeepalive = %d\n", effectiveKeepalive)
|
||||||
}
|
}
|
||||||
|
|
||||||
// build the config as string
|
// build the config as string
|
||||||
|
|||||||
Reference in New Issue
Block a user