From 8d7a43bc8c3b8e144af1ad9c6e4460307b6981d1 Mon Sep 17 00:00:00 2001 From: Debian Date: Sun, 24 May 2026 22:09:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(wireguard):=20selektiver=20.conf-Import=20?= =?UTF-8?q?=E2=80=94=20Auswahl=20einzelner=20Interfaces=20per=20Modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /wireguard/importable listet alle .conf-Dateien in /etc/wireguard/ mit already_in-Flag (bereits in DB) - POST /wireguard/import nimmt optionale Names-Liste; ohne Namen → alles - edgeguard-ctl wg-import [iface…] importiert nur die genannten Interfaces - UI: Checkbox-Modal mit "Alle neuen auswählen" + Einzelauswahl; bereits importierte Interfaces disabled + grüner Tag Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 7 +- cmd/edgeguard-ctl/wg_import.go | 4 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/wireguard.go | 41 +++++++ internal/services/wireguard/import.go | 62 +++++++++++ management-ui/src/i18n/locales/de/common.json | 11 ++ management-ui/src/i18n/locales/en/common.json | 11 ++ management-ui/src/pages/Wireguard/Servers.tsx | 101 ++++++++++++++++-- 10 files changed, 230 insertions(+), 13 deletions(-) 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={ - - - + + + + + + + + } 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')} + )} + + ))} + + )} + ) }