feat(wireguard): selektiver .conf-Import — Auswahl einzelner Interfaces per Modal
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.94"
|
var version = "1.1.95"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"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
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
@@ -26,7 +26,10 @@ Commands:
|
|||||||
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
||||||
initdb Create PostgreSQL role + database (idempotent)
|
initdb Create PostgreSQL role + database (idempotent)
|
||||||
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
|
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
|
||||||
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
|
wg-import [--path <dir>] [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
|
reset-password Generate a one-time token for the /reset-password UI flow
|
||||||
cluster-join <primary> --token <…>
|
cluster-join <primary> --token <…>
|
||||||
Provision Cluster-TLS material on this node by
|
Provision Cluster-TLS material on this node by
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ func cmdWGImport(args []string) int {
|
|||||||
wireguard.NewPeersRepo(pool),
|
wireguard.NewPeersRepo(pool),
|
||||||
box,
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "wg-import:", err)
|
fmt.Fprintln(os.Stderr, "wg-import:", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.94"
|
var version = "1.1.95"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ func (h *WireguardHandler) Register(rg *gin.RouterGroup) {
|
|||||||
// row per (iface, peer) with last_handshake + transfer counters.
|
// row per (iface, peer) with last_handshake + transfer counters.
|
||||||
// Polled by the UI every 10s; no DB write.
|
// Polled by the UI every 10s; no DB write.
|
||||||
g.GET("/status", h.Status)
|
g.GET("/status", h.Status)
|
||||||
|
g.GET("/importable", h.ListImportable)
|
||||||
|
g.POST("/import", h.Import)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live wg-show status ─────────────────────────────────────────────
|
// ── Live wg-show status ─────────────────────────────────────────────
|
||||||
@@ -142,6 +144,45 @@ func (h *WireguardHandler) Status(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"status": rows})
|
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 ────────────────────────────────────────────────────────
|
// ── Keygen ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {
|
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {
|
||||||
|
|||||||
@@ -47,6 +47,68 @@ func NewImporter(ifaces *InterfacesRepo, peers *PeersRepo, box *secrets.Box) *Im
|
|||||||
return &Importer{Ifaces: ifaces, Peers: peers, Box: box}
|
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) {
|
func (im *Importer) ImportDir(ctx context.Context, dir string) (*ImportResult, error) {
|
||||||
res := &ImportResult{}
|
res := &ImportResult{}
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
|
|||||||
@@ -701,6 +701,17 @@
|
|||||||
"online": "Online",
|
"online": "Online",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"traffic": "Traffic"
|
"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": {
|
"dashboard": {
|
||||||
|
|||||||
@@ -701,6 +701,17 @@
|
|||||||
"online": "Online",
|
"online": "Online",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"traffic": "Traffic"
|
"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": {
|
"dashboard": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
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,
|
Modal, Row, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import {
|
import {
|
||||||
DownloadOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
|
DownloadOutlined, ImportOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
|
||||||
TeamOutlined, ThunderboltOutlined,
|
TeamOutlined, ThunderboltOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -139,6 +139,38 @@ export default function ServersTab() {
|
|||||||
onError: (e: Error) => message.error(e.message),
|
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<string[]>([])
|
||||||
|
|
||||||
|
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<WGInterface> = [
|
const cols: ColumnsType<WGInterface> = [
|
||||||
{ title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
{ title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
||||||
{ title: t('wg.iface.address'), dataIndex: 'address_cidr', key: 'address_cidr' },
|
{ title: t('wg.iface.address'), dataIndex: 'address_cidr', key: 'address_cidr' },
|
||||||
@@ -211,11 +243,18 @@ export default function ServersTab() {
|
|||||||
dataSource={servers ?? []}
|
dataSource={servers ?? []}
|
||||||
columns={cols}
|
columns={cols}
|
||||||
extraActions={
|
extraActions={
|
||||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
<Space>
|
||||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||||
{t('wg.iface.addServer')}
|
<Button icon={<ImportOutlined />} disabled={isViewer} onClick={() => setImportOpen(true)}>
|
||||||
</Button>
|
{t('wg.import.btn')}
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
||||||
|
{t('wg.iface.addServer')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
emptyContent={
|
emptyContent={
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -326,6 +365,54 @@ export default function ServersTab() {
|
|||||||
iface={peersDrawer}
|
iface={peersDrawer}
|
||||||
onClose={() => setPeersDrawer(null)}
|
onClose={() => setPeersDrawer(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('wg.import.modalTitle')}
|
||||||
|
open={importOpen}
|
||||||
|
onCancel={() => { 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
|
||||||
|
>
|
||||||
|
<Alert type="info" showIcon className="mb-12" message={t('wg.import.hint')} />
|
||||||
|
{importableQuery.isLoading && <Typography.Text type="secondary">{t('common.loading')}</Typography.Text>}
|
||||||
|
{!importableQuery.isLoading && importEntries.length === 0 && (
|
||||||
|
<Typography.Text type="secondary">{t('wg.import.noFiles')}</Typography.Text>
|
||||||
|
)}
|
||||||
|
{importEntries.length > 0 && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||||
|
<Space style={{ marginBottom: 4 }}>
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={selectedNames.length > 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')}
|
||||||
|
</Checkbox>
|
||||||
|
</Space>
|
||||||
|
{importEntries.map(entry => (
|
||||||
|
<Checkbox
|
||||||
|
key={entry.name}
|
||||||
|
checked={selectedNames.includes(entry.name)}
|
||||||
|
disabled={entry.already_in}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedNames(prev =>
|
||||||
|
e.target.checked ? [...prev, entry.name] : prev.filter(n => n !== entry.name)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code>{entry.name}.conf</code>
|
||||||
|
{entry.already_in && (
|
||||||
|
<Tag color="green" style={{ marginLeft: 8, fontSize: 11 }}>{t('wg.import.alreadyIn')}</Tag>
|
||||||
|
)}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user