diff --git a/VERSION b/VERSION
index ead65f0..08435f6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.94
+1.1.95
diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go
index 6aeb8d8..b4ab18c 100644
--- a/cmd/edgeguard-api/main.go
+++ b/cmd/edgeguard-api/main.go
@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
-var version = "1.1.94"
+var version = "1.1.95"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go
index 2f4764e..2ec2151 100644
--- a/cmd/edgeguard-ctl/main.go
+++ b/cmd/edgeguard-ctl/main.go
@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
-var version = "1.1.94"
+var version = "1.1.95"
const usage = `edgeguard-ctl — EdgeGuard CLI
@@ -26,7 +26,10 @@ Commands:
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
initdb Create PostgreSQL role + database (idempotent)
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
- wg-import [--path
] Import existing /etc/wireguard/*.conf files into the DB
+ wg-import [--path ] [iface…]
+ Import /etc/wireguard/*.conf files into the DB.
+ Without iface arguments: imports all .conf files.
+ With iface args: imports only the named interfaces.
reset-password Generate a one-time token for the /reset-password UI flow
cluster-join --token <…>
Provision Cluster-TLS material on this node by
diff --git a/cmd/edgeguard-ctl/wg_import.go b/cmd/edgeguard-ctl/wg_import.go
index e9ea8df..fc28e6f 100644
--- a/cmd/edgeguard-ctl/wg_import.go
+++ b/cmd/edgeguard-ctl/wg_import.go
@@ -46,7 +46,9 @@ func cmdWGImport(args []string) int {
wireguard.NewPeersRepo(pool),
box,
)
- res, err := im.ImportDir(ctx, *path)
+ // Positional args after flags = specific interface names to import.
+ names := fs.Args()
+ res, err := im.ImportSelected(ctx, *path, names)
if err != nil {
fmt.Fprintln(os.Stderr, "wg-import:", err)
return 1
diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go
index 2de08aa..641cb6e 100644
--- a/cmd/edgeguard-scheduler/main.go
+++ b/cmd/edgeguard-scheduler/main.go
@@ -35,7 +35,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
-var version = "1.1.94"
+var version = "1.1.95"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
diff --git a/internal/handlers/wireguard.go b/internal/handlers/wireguard.go
index 2858617..57a7e7d 100644
--- a/internal/handlers/wireguard.go
+++ b/internal/handlers/wireguard.go
@@ -83,6 +83,8 @@ func (h *WireguardHandler) Register(rg *gin.RouterGroup) {
// row per (iface, peer) with last_handshake + transfer counters.
// Polled by the UI every 10s; no DB write.
g.GET("/status", h.Status)
+ g.GET("/importable", h.ListImportable)
+ g.POST("/import", h.Import)
}
// ── Live wg-show status ─────────────────────────────────────────────
@@ -142,6 +144,45 @@ func (h *WireguardHandler) Status(c *gin.Context) {
response.OK(c, gin.H{"status": rows})
}
+// ── Import ────────────────────────────────────────────────────────
+
+const wgImportDir = "/etc/wireguard"
+
+// ListImportable scans /etc/wireguard for *.conf files and reports
+// which are new (not yet in the DB) vs. already imported.
+func (h *WireguardHandler) ListImportable(c *gin.Context) {
+ im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
+ entries, err := im.ListImportable(c.Request.Context(), wgImportDir)
+ if err != nil {
+ response.Internal(c, err)
+ return
+ }
+ response.OK(c, gin.H{"entries": entries})
+}
+
+// Import imports selected (or all) WireGuard .conf files from
+// /etc/wireguard into the DB. Body: { "names": ["wg0","wg1"] }.
+// Empty or missing names → import all.
+func (h *WireguardHandler) Import(c *gin.Context) {
+ var req struct {
+ Names []string `json:"names"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, err)
+ return
+ }
+ im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
+ res, err := im.ImportSelected(c.Request.Context(), wgImportDir, req.Names)
+ if err != nil {
+ response.Internal(c, err)
+ return
+ }
+ _ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.import",
+ fmt.Sprintf("%d ifaces", res.IfacesAdded), res, h.NodeID)
+ h.reload(c.Request.Context(), "import")
+ response.OK(c, res)
+}
+
// ── Keygen ────────────────────────────────────────────────────────
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {
diff --git a/internal/services/wireguard/import.go b/internal/services/wireguard/import.go
index ec6e02f..67d96a9 100644
--- a/internal/services/wireguard/import.go
+++ b/internal/services/wireguard/import.go
@@ -47,6 +47,68 @@ func NewImporter(ifaces *InterfacesRepo, peers *PeersRepo, box *secrets.Box) *Im
return &Importer{Ifaces: ifaces, Peers: peers, Box: box}
}
+// ImportableEntry describes one .conf file that can be imported.
+type ImportableEntry struct {
+ Name string `json:"name"` // interface name without .conf
+ AlreadyIn bool `json:"already_in"` // already present in the DB
+}
+
+// ListImportable scans dir for *.conf files and reports which are new
+// (not yet in the DB) and which are already imported.
+func (im *Importer) ListImportable(ctx context.Context, dir string) ([]ImportableEntry, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ existing, err := im.Ifaces.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ inDB := make(map[string]bool, len(existing))
+ for _, x := range existing {
+ inDB[x.Name] = true
+ }
+ var out []ImportableEntry
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
+ continue
+ }
+ name := strings.TrimSuffix(e.Name(), ".conf")
+ if !validIfaceName(name) {
+ continue
+ }
+ out = append(out, ImportableEntry{Name: name, AlreadyIn: inDB[name]})
+ }
+ return out, nil
+}
+
+// ImportSelected imports only the named interfaces from dir.
+// An empty names slice is treated as "import all" (same as ImportDir).
+func (im *Importer) ImportSelected(ctx context.Context, dir string, names []string) (*ImportResult, error) {
+ if len(names) == 0 {
+ return im.ImportDir(ctx, dir)
+ }
+ res := &ImportResult{}
+ want := make(map[string]bool, len(names))
+ for _, n := range names {
+ want[n] = true
+ }
+ for name := range want {
+ if !validIfaceName(name) {
+ res.Skipped = append(res.Skipped, name+" (invalid name)")
+ continue
+ }
+ path := filepath.Join(dir, name+".conf")
+ if err := im.importFile(ctx, name, path, res); err != nil {
+ res.Skipped = append(res.Skipped, name+": "+err.Error())
+ }
+ }
+ return res, nil
+}
+
func (im *Importer) ImportDir(ctx context.Context, dir string) (*ImportResult, error) {
res := &ImportResult{}
entries, err := os.ReadDir(dir)
diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json
index f5085d9..3caef33 100644
--- a/management-ui/src/i18n/locales/de/common.json
+++ b/management-ui/src/i18n/locales/de/common.json
@@ -701,6 +701,17 @@
"online": "Online",
"offline": "Offline",
"traffic": "Traffic"
+ },
+ "import": {
+ "btn": ".conf importieren",
+ "modalTitle": "WireGuard-Konfigurationen aus /etc/wireguard importieren",
+ "hint": "Wähle aus, welche .conf-Dateien importiert werden sollen. Bereits importierte Interfaces sind ausgegraut.",
+ "selectAll": "Alle neuen auswählen",
+ "alreadyIn": "bereits importiert",
+ "noFiles": "Keine .conf-Dateien in /etc/wireguard gefunden.",
+ "okBtn": "Ausgewählte importieren",
+ "ok": "{{ifaces}} Interface(s), {{peers}} Peer(s) importiert",
+ "failed": "Import fehlgeschlagen"
}
},
"dashboard": {
diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json
index d1a77bd..d209f09 100644
--- a/management-ui/src/i18n/locales/en/common.json
+++ b/management-ui/src/i18n/locales/en/common.json
@@ -701,6 +701,17 @@
"online": "Online",
"offline": "Offline",
"traffic": "Traffic"
+ },
+ "import": {
+ "btn": "Import .conf",
+ "modalTitle": "Import WireGuard configs from /etc/wireguard",
+ "hint": "Select which .conf files to import. Already-imported interfaces are greyed out.",
+ "selectAll": "Select all new",
+ "alreadyIn": "already imported",
+ "noFiles": "No .conf files found in /etc/wireguard.",
+ "okBtn": "Import selected",
+ "ok": "Imported {{ifaces}} interface(s), {{peers}} peer(s)",
+ "failed": "Import failed"
}
},
"dashboard": {
diff --git a/management-ui/src/pages/Wireguard/Servers.tsx b/management-ui/src/pages/Wireguard/Servers.tsx
index 05a1875..7b6e720 100644
--- a/management-ui/src/pages/Wireguard/Servers.tsx
+++ b/management-ui/src/pages/Wireguard/Servers.tsx
@@ -1,11 +1,11 @@
import { useState } from 'react'
import {
- Alert, Button, Card, Col, Drawer, Form, Input, InputNumber,
+ Alert, Button, Card, Checkbox, Col, Drawer, Form, Input, InputNumber,
Modal, Row, Select, Space, Switch, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
- DownloadOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
+ DownloadOutlined, ImportOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
TeamOutlined, ThunderboltOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -139,6 +139,38 @@ export default function ServersTab() {
onError: (e: Error) => message.error(e.message),
})
+ // ── Import from /etc/wireguard ───────────────────────────────────
+ interface ImportableEntry { name: string; already_in: boolean }
+ const [importOpen, setImportOpen] = useState(false)
+ const [selectedNames, setSelectedNames] = useState([])
+
+ const importableQuery = useQuery({
+ queryKey: ['wg', 'importable'],
+ queryFn: async () => {
+ const r = await apiClient.get('/wireguard/importable')
+ return isEnvelope(r.data) ? (r.data.data as { entries?: ImportableEntry[] }).entries ?? [] : []
+ },
+ enabled: importOpen,
+ })
+
+ const doImport = useMutation({
+ mutationFn: async (names: string[]) => {
+ const r = await apiClient.post('/wireguard/import', { names })
+ return isEnvelope(r.data) ? (r.data.data as { ifaces_added: number; peers_added: number; skipped?: string[] }) : null
+ },
+ onSuccess: (res) => {
+ message.success(t('wg.import.ok', { ifaces: res?.ifaces_added ?? 0, peers: res?.peers_added ?? 0 }))
+ setImportOpen(false)
+ setSelectedNames([])
+ void qc.invalidateQueries({ queryKey: ['wg', 'servers'] })
+ void qc.invalidateQueries({ queryKey: ['wg', 'importable'] })
+ },
+ onError: (e: Error) => message.error(t('wg.import.failed') + ': ' + e.message),
+ })
+
+ const importEntries = importableQuery.data ?? []
+ const importableNew = importEntries.filter(e => !e.already_in)
+
const cols: ColumnsType = [
{ title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} },
{ title: t('wg.iface.address'), dataIndex: 'address_cidr', key: 'address_cidr' },
@@ -211,11 +243,18 @@ export default function ServersTab() {
dataSource={servers ?? []}
columns={cols}
extraActions={
-
- } disabled={isViewer} onClick={openCreate}>
- {t('wg.iface.addServer')}
-
-
+
+
+ } disabled={isViewer} onClick={() => setImportOpen(true)}>
+ {t('wg.import.btn')}
+
+
+
+ } disabled={isViewer} onClick={openCreate}>
+ {t('wg.iface.addServer')}
+
+
+
}
emptyContent={
setPeersDrawer(null)}
/>
+
+ { setImportOpen(false); setSelectedNames([]) }}
+ onOk={() => doImport.mutate(selectedNames)}
+ okText={t('wg.import.okBtn')}
+ okButtonProps={{ disabled: selectedNames.length === 0, loading: doImport.isPending }}
+ cancelText={t('common.cancel')}
+ width={480}
+ destroyOnHidden
+ >
+
+ {importableQuery.isLoading && {t('common.loading')}}
+ {!importableQuery.isLoading && importEntries.length === 0 && (
+ {t('wg.import.noFiles')}
+ )}
+ {importEntries.length > 0 && (
+
+
+ 0 && selectedNames.length < importableNew.length}
+ checked={importableNew.length > 0 && selectedNames.length === importableNew.length}
+ onChange={(e) => setSelectedNames(e.target.checked ? importableNew.map(x => x.name) : [])}
+ >
+ {t('wg.import.selectAll')}
+
+
+ {importEntries.map(entry => (
+ {
+ setSelectedNames(prev =>
+ e.target.checked ? [...prev, entry.name] : prev.filter(n => n !== entry.name)
+ )
+ }}
+ >
+ {entry.name}.conf
+ {entry.already_in && (
+ {t('wg.import.alreadyIn')}
+ )}
+
+ ))}
+
+ )}
+
>
)
}