feat: WireGuard (server + client + peers + QR) + shared UI components
WireGuard --------- * Migration 0013: wireguard_interfaces (server|client mode, key envelope- encrypted) + wireguard_peers (per-server roster). Drop old empty 0005-Schema (Option-A peer_type, kein Iface-FK), neuer Aufbau mit zwei Tabellen + FK. * internal/services/secrets: Box mit AES-256-GCM, Master-Key in /var/lib/edgeguard/.master_key (lazy-create, 0600). Sealed/Open für PrivateKey + PSK. * internal/services/wireguard: KeyGen (Curve25519 mit clamping), PublicFromPrivate (für Import), InterfacesRepo, PeersRepo, Importer (parst /etc/wireguard/*.conf, server vs. client heuristisch nach ListenPort + Peer-Anzahl). * internal/wireguard: Renderer schreibt /etc/edgeguard/wireguard/<iface>.conf (0600), restartet wg-quick@<iface> via sudo (sudoers im postinst erweitert). Idempotent — re-render nur wenn content geändert. * internal/handlers/wireguard.go: REST CRUD für interfaces+peers, /generate-keypair, /peers/:id/config (text/plain wg-quick conf), /peers/:id/qr (PNG via go-qrcode). Auto-reload nach Mutation. * edgeguard-ctl wg-import [--path /etc/wireguard]: liest existierende conf-Files in die DB. Idempotent (überspringt vorhandene Iface-Namen). Shared UI components (proxy-lb-waf design pattern) -------------------------------------------------- * PageHeader: icon + title + subtitle + extras row, einheitlich oben auf jeder Page. * ActionButtons: Edit + Delete combo mit Popconfirm + Tooltip. * StatusDot: AntD Badge pattern statt "Yes/No" — schneller scanbar in dichten Tabellen. * DataTable: pageSizeOptions [20,50,100,200] + extraActions-Alias + optional renderMobileCard für Card-Liste auf < md Breakpoint. * enterprise.css: .page-header* + .datatable-toolbar Klassen. Frontend WireGuard ------------------ * /vpn/wireguard mit zwei Tabs (Server / Client) im neuen Pattern. * Server-Tab: Modal mit Generate-Keypair-Toggle, Peer-Roster im Drawer per Server. Pro Peer: QR-Code-Modal + .conf-Download. * Client-Tab: Upstream-Card im Modal, full-tunnel-Default (0.0.0.0/0,::/0), Keepalive 25. * i18n DE/EN für wg.* Block + common.* Erweiterung. Misc ---- * Sidebar: WireGuard unter Security-Sektion. * Nav-i18n: "Firewall (v2)" → "Firewall". * Version 1.0.8 → 1.0.11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,165 @@
|
||||
// Package wireguard will render /etc/edgeguard/wireguard/wg0.conf in
|
||||
// Phase 3 (and run `wg syncconf` on reload). v1 ships a stub.
|
||||
// Package wireguard renders /etc/edgeguard/wireguard/<iface>.conf
|
||||
// from the relational state in PG (wireguard_interfaces +
|
||||
// wireguard_peers) and brings the corresponding wg-quick@<iface>
|
||||
// service up. Each iface gets its own conf file; the renderer is
|
||||
// idempotent — running it twice produces the same files and only
|
||||
// reloads wg if the contents actually changed (mtime + content
|
||||
// compare).
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
)
|
||||
|
||||
type Generator struct{}
|
||||
const ConfDir = "/etc/edgeguard/wireguard"
|
||||
|
||||
func New() *Generator { return &Generator{} }
|
||||
type Generator struct {
|
||||
Pool *pgxpool.Pool
|
||||
Box *secrets.Box
|
||||
Ifaces *wgsvc.InterfacesRepo
|
||||
Peers *wgsvc.PeersRepo
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
||||
return &Generator{
|
||||
Pool: pool,
|
||||
Box: box,
|
||||
Ifaces: wgsvc.NewInterfacesRepo(pool),
|
||||
Peers: wgsvc.NewPeersRepo(pool),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Generator) Name() string { return "wireguard" }
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
return configgen.ErrNotImplemented
|
||||
if err := os.MkdirAll(ConfDir, 0o700); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", ConfDir, err)
|
||||
}
|
||||
|
||||
ifs, err := g.Ifaces.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list ifaces: %w", err)
|
||||
}
|
||||
|
||||
wantNames := map[string]bool{}
|
||||
for _, ifc := range ifs {
|
||||
if !ifc.Active {
|
||||
continue
|
||||
}
|
||||
wantNames[ifc.Name] = true
|
||||
if err := g.renderIface(ctx, ifc); err != nil {
|
||||
return fmt.Errorf("iface %s: %w", ifc.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Tidy up: any .conf in ConfDir that doesn't correspond to an
|
||||
// active iface gets removed and its wg-quick@ stopped — keeps
|
||||
// kernel state in sync after a delete.
|
||||
entries, err := os.ReadDir(ConfDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
|
||||
continue
|
||||
}
|
||||
ifaceName := strings.TrimSuffix(e.Name(), ".conf")
|
||||
if wantNames[ifaceName] {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
|
||||
_ = stopWGQuick(ifaceName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterface) error {
|
||||
priv, err := g.Box.Open(ifc.PrivateKeyEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt private key: %w", err)
|
||||
}
|
||||
var body bytes.Buffer
|
||||
body.WriteString("# Generated by edgeguard — do not edit by hand.\n")
|
||||
body.WriteString("[Interface]\n")
|
||||
fmt.Fprintf(&body, "Address = %s\n", ifc.AddressCIDR)
|
||||
fmt.Fprintf(&body, "PrivateKey = %s\n", string(priv))
|
||||
if ifc.ListenPort != nil {
|
||||
fmt.Fprintf(&body, "ListenPort = %d\n", *ifc.ListenPort)
|
||||
}
|
||||
if ifc.MTU != nil {
|
||||
fmt.Fprintf(&body, "MTU = %d\n", *ifc.MTU)
|
||||
}
|
||||
body.WriteString("\n")
|
||||
|
||||
switch ifc.Mode {
|
||||
case "client":
|
||||
if ifc.PeerPublicKey == nil || ifc.PeerEndpoint == nil {
|
||||
return errors.New("client iface missing peer_endpoint or peer_public_key")
|
||||
}
|
||||
body.WriteString("[Peer]\n")
|
||||
fmt.Fprintf(&body, "PublicKey = %s\n", *ifc.PeerPublicKey)
|
||||
fmt.Fprintf(&body, "Endpoint = %s\n", *ifc.PeerEndpoint)
|
||||
if ifc.AllowedIPs != nil && *ifc.AllowedIPs != "" {
|
||||
fmt.Fprintf(&body, "AllowedIPs = %s\n", *ifc.AllowedIPs)
|
||||
} else {
|
||||
body.WriteString("AllowedIPs = 0.0.0.0/0,::/0\n")
|
||||
}
|
||||
if ifc.PersistentKeepalive != nil {
|
||||
fmt.Fprintf(&body, "PersistentKeepalive = %d\n", *ifc.PersistentKeepalive)
|
||||
}
|
||||
if len(ifc.PeerPSKEnc) > 0 {
|
||||
psk, err := g.Box.Open(ifc.PeerPSKEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt peer psk: %w", err)
|
||||
}
|
||||
fmt.Fprintf(&body, "PresharedKey = %s\n", string(psk))
|
||||
}
|
||||
case "server":
|
||||
peers, err := g.Peers.ListForInterface(ctx, ifc.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list peers: %w", err)
|
||||
}
|
||||
sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
|
||||
for _, p := range peers {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
body.WriteString("[Peer]\n")
|
||||
fmt.Fprintf(&body, "# %s\n", p.Name)
|
||||
fmt.Fprintf(&body, "PublicKey = %s\n", p.PublicKey)
|
||||
fmt.Fprintf(&body, "AllowedIPs = %s\n", p.AllowedIPs)
|
||||
if p.Keepalive != nil {
|
||||
fmt.Fprintf(&body, "PersistentKeepalive = %d\n", *p.Keepalive)
|
||||
}
|
||||
if len(p.PSKEnc) > 0 {
|
||||
psk, err := g.Box.Open(p.PSKEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt peer %s psk: %w", p.Name, err)
|
||||
}
|
||||
fmt.Fprintf(&body, "PresharedKey = %s\n", string(psk))
|
||||
}
|
||||
body.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(ConfDir, ifc.Name+".conf")
|
||||
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
||||
return startWGQuick(ifc.Name)
|
||||
}
|
||||
if err := os.WriteFile(path, body.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return restartWGQuick(ifc.Name)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user