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:
Debian
2026-05-10 20:51:25 +02:00
parent 3545b8422b
commit 85904d0c36
33 changed files with 3046 additions and 40 deletions

View File

@@ -0,0 +1,35 @@
package wireguard
import (
"fmt"
"os/exec"
)
// wg-quick is managed via systemd unit instances (wg-quick@<iface>).
// Reload-via-syncconf would be cheaper (no link flap) but needs more
// per-change diffing — for v1 we restart the unit, which takes ~1s
// and re-establishes peers cleanly. The sudoers entry shipped in
// postinst whitelists exactly these three commands.
func startWGQuick(iface string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "start", "wg-quick@"+iface+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl start wg-quick@%s: %w: %s", iface, err, string(out))
}
return nil
}
func restartWGQuick(iface string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "restart", "wg-quick@"+iface+".service")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl restart wg-quick@%s: %w: %s", iface, err, string(out))
}
return nil
}
func stopWGQuick(iface string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "stop", "wg-quick@"+iface+".service")
// Ignore failures — unit may not exist.
_ = cmd.Run()
return nil
}

View File

@@ -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)
}