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

@@ -1 +1 @@
1.0.8 1.0.11

View File

@@ -22,6 +22,7 @@ import (
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
@@ -31,12 +32,14 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
) )
var version = "1.0.8" var version = "1.0.11"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -135,6 +138,9 @@ func main() {
fwSvcGrp := firewall.NewServiceGroupsRepo(pool) fwSvcGrp := firewall.NewServiceGroupsRepo(pool)
fwRules := firewall.NewRulesRepo(pool) fwRules := firewall.NewRulesRepo(pool)
fwNAT := firewall.NewNATRulesRepo(pool) fwNAT := firewall.NewNATRulesRepo(pool)
secretsBox := secrets.New("")
wgIfaces := wgsvc.NewInterfacesRepo(pool)
wgPeers := wgsvc.NewPeersRepo(pool)
// ACME (Let's Encrypt). Email comes from setup.json — the // ACME (Let's Encrypt). Email comes from setup.json — the
// wizard collects acme_email and the issuer registers an // wizard collects acme_email and the issuer registers an
@@ -168,6 +174,14 @@ func main() {
return firewallrender.New(pool).Render(ctx) return firewallrender.New(pool).Render(ctx)
} }
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed) handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed)
// WireGuard reload: re-render /etc/edgeguard/wireguard/*.conf
// + restart wg-quick@<iface>. Same pattern as the haproxy +
// firewall reloaders.
wgReloader := func(ctx context.Context) error {
return wgrender.New(pool, secretsBox).Render(ctx)
}
handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, wgReloader).Register(authed)
} }
mountUI(r) mountUI(r)

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
) )
var version = "1.0.8" var version = "1.0.11"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI
@@ -24,6 +24,7 @@ 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
cluster-join Join an existing cluster (Phase 3, not yet implemented) cluster-join Join an existing cluster (Phase 3, not yet implemented)
promote Promote this node's PG to primary (Phase 3, not yet implemented) promote Promote this node's PG to primary (Phase 3, not yet implemented)
dump-config Print effective config (Phase 3, not yet implemented) dump-config Print effective config (Phase 3, not yet implemented)
@@ -45,6 +46,8 @@ func main() {
os.Exit(cmdInitDB(os.Args[2:])) os.Exit(cmdInitDB(os.Args[2:]))
case "render-config": case "render-config":
os.Exit(cmdRenderConfig(os.Args[2:])) os.Exit(cmdRenderConfig(os.Args[2:]))
case "wg-import":
os.Exit(cmdWGImport(os.Args[2:]))
case "cluster-join", "cluster-leave", "promote", "dump-config": case "cluster-join", "cluster-leave", "promote", "dump-config":
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1]) fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1) os.Exit(1)

View File

@@ -12,6 +12,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch" "git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/squid" "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
"git.netcell-it.de/projekte/edgeguard-native/internal/unbound" "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
"git.netcell-it.de/projekte/edgeguard-native/internal/wireguard" "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
@@ -54,7 +55,7 @@ func cmdRenderConfig(args []string) int {
hap := haproxy.New(pool) hap := haproxy.New(pool)
fw := firewall.New(pool) fw := firewall.New(pool)
sq := squid.New() sq := squid.New()
wg := wireguard.New() wg := wireguard.New(pool, secrets.New(""))
ub := unbound.New() ub := unbound.New()
if skipReload { if skipReload {
hap.SkipReload = true hap.SkipReload = true

View File

@@ -0,0 +1,62 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
)
// cmdWGImport reads /etc/wireguard/*.conf (or a custom dir via
// --path) and translates each [Interface]+[Peer] block into rows in
// wireguard_interfaces / wireguard_peers. Idempotent: ifaces with a
// name that already exists in the DB are skipped (no overwrite).
//
// Use after a fresh EdgeGuard install on a box that already had a
// hand-rolled WireGuard setup — keeps existing tunnels live across
// the migration. After import, run `edgeguard-ctl render-config` to
// re-emit the conf files under /etc/edgeguard/wireguard/ and start
// the wg-quick@ units. The original /etc/wireguard files are left
// in place for fallback.
func cmdWGImport(args []string) int {
fs := flag.NewFlagSet("wg-import", flag.ExitOnError)
path := fs.String("path", "/etc/wireguard", "directory holding *.conf files")
if err := fs.Parse(args); err != nil {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := database.Open(ctx, database.ConnStringFromEnv())
if err != nil {
fmt.Fprintln(os.Stderr, "wg-import: open db:", err)
return 1
}
defer pool.Close()
box := secrets.New("")
im := wireguard.NewImporter(
wireguard.NewInterfacesRepo(pool),
wireguard.NewPeersRepo(pool),
box,
)
res, err := im.ImportDir(ctx, *path)
if err != nil {
fmt.Fprintln(os.Stderr, "wg-import:", err)
return 1
}
fmt.Printf("wg-import: %d ifaces added, %d peers added\n", res.IfacesAdded, res.PeersAdded)
for _, s := range res.Skipped {
fmt.Printf(" skipped: %s\n", s)
}
if res.IfacesAdded > 0 {
fmt.Println("\nNext: edgeguard-ctl render-config (re-emits configs + starts wg-quick@<iface>)")
}
return 0
}

View File

@@ -5,7 +5,7 @@ import (
"time" "time"
) )
var version = "1.0.8" var version = "1.0.11"
func main() { func main() {
log.Printf("edgeguard-scheduler %s starting", version) log.Printf("edgeguard-scheduler %s starting", version)

7
go.mod
View File

@@ -7,7 +7,7 @@ require (
github.com/go-acme/lego/v4 v4.35.2 github.com/go-acme/lego/v4 v4.35.2
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.51.0
) )
require ( require (
@@ -37,6 +37,7 @@ require (
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
@@ -44,8 +45,8 @@ require (
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

8
go.sum
View File

@@ -88,6 +88,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -112,6 +114,8 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
@@ -121,8 +125,12 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -0,0 +1,79 @@
-- +goose Up
-- 0005 hat schon mal eine wireguard_peers-Tabelle angelegt (Option-A
-- Schema mit peer_type 'server|s2s|roadwarrior' ohne Interface-FK).
-- Wir bauen das Modell hier um: zwei Tabellen mit FK, getrennt nach
-- Modus (server-mode iface mit Peer-Roster vs. client-mode iface mit
-- inline upstream-peer). Da das Feature noch nicht produktiv genutzt
-- wurde (Tabelle leer beim ersten Rollout), droppen wir sie und
-- bauen frisch.
-- +goose StatementBegin
DROP TABLE IF EXISTS wireguard_peers;
-- +goose StatementEnd
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS wireguard_interfaces (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
mode TEXT NOT NULL,
address_cidr TEXT NOT NULL,
listen_port INTEGER,
private_key_enc BYTEA NOT NULL,
public_key TEXT NOT NULL,
peer_endpoint TEXT,
peer_public_key TEXT,
peer_psk_enc BYTEA,
allowed_ips TEXT,
persistent_keepalive INTEGER,
mtu INTEGER,
role TEXT NOT NULL DEFAULT 'wan',
active BOOLEAN NOT NULL DEFAULT TRUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT wg_iface_mode_check CHECK (mode IN ('server', 'client')),
CONSTRAINT wg_iface_name_check CHECK (name ~ '^wg[a-z0-9-]{0,13}$'),
CONSTRAINT wg_iface_server_check CHECK (
mode <> 'server' OR (listen_port BETWEEN 1 AND 65535)
),
CONSTRAINT wg_iface_client_check CHECK (
mode <> 'client' OR (peer_endpoint IS NOT NULL AND peer_public_key IS NOT NULL)
)
);
-- +goose StatementEnd
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS wireguard_peers (
id BIGSERIAL PRIMARY KEY,
interface_id BIGINT NOT NULL REFERENCES wireguard_interfaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key_enc BYTEA,
psk_enc BYTEA,
allowed_ips TEXT NOT NULL,
keepalive INTEGER,
last_handshake TIMESTAMPTZ,
transfer_rx BIGINT NOT NULL DEFAULT 0,
transfer_tx BIGINT NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT wg_peer_iface_pubkey_unique UNIQUE (interface_id, public_key)
);
-- +goose StatementEnd
-- +goose StatementBegin
CREATE INDEX IF NOT EXISTS idx_wg_peers_iface ON wireguard_peers (interface_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS wireguard_peers;
-- +goose StatementEnd
-- +goose StatementBegin
DROP TABLE IF EXISTS wireguard_interfaces;
-- +goose StatementEnd

View File

@@ -0,0 +1,645 @@
package handlers
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
qrcode "github.com/skip2/go-qrcode"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
)
// WireguardHandler exposes /api/v1/wireguard/* — interfaces, peers,
// per-peer config download (wg-quick text + QR PNG) and a one-shot
// keypair generator. Mutations trigger Reloader so the kernel state
// stays in sync.
type WireguardHandler struct {
Ifaces *wireguard.InterfacesRepo
Peers *wireguard.PeersRepo
Box *secrets.Box
Audit *audit.Repo
NodeID string
Reloader func(ctx context.Context) error
}
func NewWireguardHandler(
ifaces *wireguard.InterfacesRepo,
peers *wireguard.PeersRepo,
box *secrets.Box,
a *audit.Repo,
nodeID string,
reloader func(context.Context) error,
) *WireguardHandler {
return &WireguardHandler{Ifaces: ifaces, Peers: peers, Box: box, Audit: a, NodeID: nodeID, Reloader: reloader}
}
func (h *WireguardHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil {
return
}
if err := h.Reloader(ctx); err != nil {
slog.Warn("wireguard: render after mutation failed", "op", op, "error", err)
}
}
func (h *WireguardHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/wireguard")
// Standalone keygen — no DB write. Frontend uses this to fill
// the form when the operator wants a fresh keypair without
// committing.
g.POST("/generate-keypair", h.GenerateKeypair)
g.GET("/interfaces", h.ListIfaces)
g.POST("/interfaces", h.CreateIface)
g.GET("/interfaces/:id", h.GetIface)
g.PUT("/interfaces/:id", h.UpdateIface)
g.DELETE("/interfaces/:id", h.DeleteIface)
g.GET("/interfaces/:id/peers", h.ListPeers)
g.POST("/interfaces/:id/peers", h.CreatePeer)
g.GET("/peers/:pid", h.GetPeer)
g.PUT("/peers/:pid", h.UpdatePeer)
g.DELETE("/peers/:pid", h.DeletePeer)
// Per-peer config download — wg-quick text and QR PNG. The QR
// embeds the same text so mobile apps can import directly.
g.GET("/peers/:pid/config", h.PeerConfig)
g.GET("/peers/:pid/qr", h.PeerQR)
}
// ── Keygen ────────────────────────────────────────────────────────
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {
kp, err := wireguard.GenerateKeypair()
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{
"private_key": kp.Private,
"public_key": kp.Public,
})
}
// ── Interfaces ────────────────────────────────────────────────────
// ifaceCreateReq splits the wire shape from the model so we can
// accept a private key by value (operator paste) or auto-generate
// (operator ticked "generate keypair") without leaking the
// encrypted-bytes field into the JSON contract.
type ifaceCreateReq struct {
Name string `json:"name"`
Mode string `json:"mode"`
AddressCIDR string `json:"address_cidr"`
ListenPort *int `json:"listen_port,omitempty"`
PeerEndpoint *string `json:"peer_endpoint,omitempty"`
PeerPublicKey *string `json:"peer_public_key,omitempty"`
PeerPSK *string `json:"peer_psk,omitempty"`
AllowedIPs *string `json:"allowed_ips,omitempty"`
PersistentKeepalive *int `json:"persistent_keepalive,omitempty"`
MTU *int `json:"mtu,omitempty"`
Role string `json:"role"`
Active bool `json:"active"`
Description *string `json:"description,omitempty"`
// Either GenerateKeypair=true (server picks one) or PrivateKey
// is filled in by the operator. PublicKey is always derived.
GenerateKeypair bool `json:"generate_keypair,omitempty"`
PrivateKey string `json:"private_key,omitempty"`
}
func (h *WireguardHandler) CreateIface(c *gin.Context) {
var req ifaceCreateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
priv := req.PrivateKey
if req.GenerateKeypair || priv == "" {
kp, err := wireguard.GenerateKeypair()
if err != nil {
response.Internal(c, err)
return
}
priv = kp.Private
}
pub, err := wireguard.PublicFromPrivate(priv)
if err != nil {
response.BadRequest(c, fmt.Errorf("private_key: %w", err))
return
}
encPriv, err := h.Box.Seal([]byte(priv))
if err != nil {
response.Internal(c, err)
return
}
var encPSK []byte
if req.PeerPSK != nil && *req.PeerPSK != "" {
encPSK, err = h.Box.Seal([]byte(*req.PeerPSK))
if err != nil {
response.Internal(c, err)
return
}
}
ifc := models.WireguardInterface{
Name: req.Name, Mode: req.Mode, AddressCIDR: req.AddressCIDR,
ListenPort: req.ListenPort, PublicKey: pub, PrivateKeyEnc: encPriv,
PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey,
PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs,
PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU,
Role: req.Role, Active: req.Active, Description: req.Description,
}
if ifc.Role == "" {
ifc.Role = "wan"
}
out, err := h.Ifaces.Create(c.Request.Context(), ifc)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.iface.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "iface.create")
}
// ifaceUpdateReq mirrors ifaceCreateReq but PrivateKey is optional
// — an empty PrivateKey + GenerateKeypair=false leaves the existing
// key untouched. This lets the operator edit metadata (description,
// allowed_ips, etc.) without re-rolling the tunnel keypair.
type ifaceUpdateReq struct {
ifaceCreateReq
}
func (h *WireguardHandler) UpdateIface(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req ifaceUpdateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
cur, err := h.Ifaces.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, wireguard.ErrIfaceNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
encPriv := cur.PrivateKeyEnc
pub := cur.PublicKey
if req.GenerateKeypair {
kp, err := wireguard.GenerateKeypair()
if err != nil {
response.Internal(c, err)
return
}
encPriv, err = h.Box.Seal([]byte(kp.Private))
if err != nil {
response.Internal(c, err)
return
}
pub = kp.Public
} else if req.PrivateKey != "" {
p, err := wireguard.PublicFromPrivate(req.PrivateKey)
if err != nil {
response.BadRequest(c, fmt.Errorf("private_key: %w", err))
return
}
encPriv, err = h.Box.Seal([]byte(req.PrivateKey))
if err != nil {
response.Internal(c, err)
return
}
pub = p
}
encPSK := cur.PeerPSKEnc
if req.PeerPSK != nil {
if *req.PeerPSK == "" {
encPSK = nil
} else {
encPSK, err = h.Box.Seal([]byte(*req.PeerPSK))
if err != nil {
response.Internal(c, err)
return
}
}
}
ifc := models.WireguardInterface{
Name: req.Name, Mode: req.Mode, AddressCIDR: req.AddressCIDR,
ListenPort: req.ListenPort, PublicKey: pub, PrivateKeyEnc: encPriv,
PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey,
PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs,
PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU,
Role: req.Role, Active: req.Active, Description: req.Description,
}
if ifc.Role == "" {
ifc.Role = cur.Role
}
out, err := h.Ifaces.Update(c.Request.Context(), id, ifc)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.iface.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "iface.update")
}
func (h *WireguardHandler) ListIfaces(c *gin.Context) {
out, err := h.Ifaces.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"interfaces": out})
}
func (h *WireguardHandler) GetIface(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.Ifaces.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, wireguard.ErrIfaceNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *WireguardHandler) DeleteIface(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.Ifaces.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, wireguard.ErrIfaceNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.iface.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
h.reload(c.Request.Context(), "iface.delete")
}
// ── Peers ─────────────────────────────────────────────────────────
func (h *WireguardHandler) ListPeers(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
out, err := h.Peers.ListForInterface(c.Request.Context(), id)
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"peers": out})
}
type peerReq struct {
Name string `json:"name"`
PublicKey string `json:"public_key,omitempty"`
PrivateKey string `json:"private_key,omitempty"`
PSK *string `json:"psk,omitempty"`
AllowedIPs string `json:"allowed_ips"`
Keepalive *int `json:"keepalive,omitempty"`
Enabled bool `json:"enabled"`
Description *string `json:"description,omitempty"`
GenerateKeypair bool `json:"generate_keypair,omitempty"`
GeneratePSK bool `json:"generate_psk,omitempty"`
}
func (h *WireguardHandler) CreatePeer(c *gin.Context) {
ifaceID, ok := parseID(c)
if !ok {
return
}
var req peerReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
pub := req.PublicKey
var encPriv []byte
if req.GenerateKeypair || (req.PublicKey == "" && req.PrivateKey == "") {
kp, err := wireguard.GenerateKeypair()
if err != nil {
response.Internal(c, err)
return
}
pub = kp.Public
encPriv, err = h.Box.Seal([]byte(kp.Private))
if err != nil {
response.Internal(c, err)
return
}
} else if req.PrivateKey != "" {
p, err := wireguard.PublicFromPrivate(req.PrivateKey)
if err != nil {
response.BadRequest(c, fmt.Errorf("private_key: %w", err))
return
}
pub = p
encPriv, err = h.Box.Seal([]byte(req.PrivateKey))
if err != nil {
response.Internal(c, err)
return
}
}
var encPSK []byte
switch {
case req.GeneratePSK:
psk, err := wireguard.GeneratePresharedKey()
if err != nil {
response.Internal(c, err)
return
}
encPSK, err = h.Box.Seal([]byte(psk))
if err != nil {
response.Internal(c, err)
return
}
case req.PSK != nil && *req.PSK != "":
var err error
encPSK, err = h.Box.Seal([]byte(*req.PSK))
if err != nil {
response.Internal(c, err)
return
}
}
p := models.WireguardPeer{
InterfaceID: ifaceID,
Name: req.Name,
PublicKey: pub,
PrivateKeyEnc: encPriv,
PSKEnc: encPSK,
AllowedIPs: req.AllowedIPs,
Keepalive: req.Keepalive,
Enabled: req.Enabled,
Description: req.Description,
}
out, err := h.Peers.Create(c.Request.Context(), p)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.peer.create", out.Name, out, h.NodeID)
response.Created(c, out)
h.reload(c.Request.Context(), "peer.create")
}
func (h *WireguardHandler) UpdatePeer(c *gin.Context) {
id := parsePeerID(c)
if id == 0 {
return
}
var req peerReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
cur, err := h.Peers.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, wireguard.ErrPeerNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
pub := cur.PublicKey
encPriv := cur.PrivateKeyEnc
if req.GenerateKeypair {
kp, err := wireguard.GenerateKeypair()
if err != nil {
response.Internal(c, err)
return
}
pub = kp.Public
encPriv, err = h.Box.Seal([]byte(kp.Private))
if err != nil {
response.Internal(c, err)
return
}
} else if req.PrivateKey != "" {
p, err := wireguard.PublicFromPrivate(req.PrivateKey)
if err != nil {
response.BadRequest(c, fmt.Errorf("private_key: %w", err))
return
}
pub = p
encPriv, err = h.Box.Seal([]byte(req.PrivateKey))
if err != nil {
response.Internal(c, err)
return
}
} else if req.PublicKey != "" {
pub = req.PublicKey
encPriv = nil
}
encPSK := cur.PSKEnc
if req.GeneratePSK {
psk, err := wireguard.GeneratePresharedKey()
if err != nil {
response.Internal(c, err)
return
}
encPSK, err = h.Box.Seal([]byte(psk))
if err != nil {
response.Internal(c, err)
return
}
} else if req.PSK != nil {
if *req.PSK == "" {
encPSK = nil
} else {
var err error
encPSK, err = h.Box.Seal([]byte(*req.PSK))
if err != nil {
response.Internal(c, err)
return
}
}
}
p := models.WireguardPeer{
Name: req.Name, PublicKey: pub, PrivateKeyEnc: encPriv, PSKEnc: encPSK,
AllowedIPs: req.AllowedIPs, Keepalive: req.Keepalive,
Enabled: req.Enabled, Description: req.Description,
}
out, err := h.Peers.Update(c.Request.Context(), id, p)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.peer.update", out.Name, out, h.NodeID)
response.OK(c, out)
h.reload(c.Request.Context(), "peer.update")
}
func (h *WireguardHandler) GetPeer(c *gin.Context) {
id := parsePeerID(c)
if id == 0 {
return
}
x, err := h.Peers.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, wireguard.ErrPeerNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *WireguardHandler) DeletePeer(c *gin.Context) {
id := parsePeerID(c)
if id == 0 {
return
}
if err := h.Peers.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, wireguard.ErrPeerNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.peer.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c)
h.reload(c.Request.Context(), "peer.delete")
}
// ── Per-peer config download ──────────────────────────────────────
// peerConfigText assembles a wg-quick-style client config from a
// peer row + its parent interface. Requires the peer to have a
// stored private key (generated server-side); manual-pubkey rows
// can't have a config because we never knew their private half.
func (h *WireguardHandler) peerConfigText(ctx context.Context, peerID int64) (string, error) {
p, err := h.Peers.Get(ctx, peerID)
if err != nil {
return "", err
}
if len(p.PrivateKeyEnc) == 0 {
return "", errors.New("no private key stored for this peer — config download only works for peers whose keypair was generated by edgeguard")
}
priv, err := h.Box.Open(p.PrivateKeyEnc)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
ifc, err := h.Ifaces.Get(ctx, p.InterfaceID)
if err != nil {
return "", err
}
if ifc.Mode != "server" {
return "", errors.New("config download only available for server-mode interfaces")
}
var b bytes.Buffer
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", string(priv))
fmt.Fprintf(&b, "Address = %s\n", p.AllowedIPs)
if p.Keepalive == nil || *p.Keepalive == 0 {
// PersistentKeepalive defaults to 25s for client side so
// NAT traversal stays alive.
b.WriteString("\n")
}
b.WriteString("\n[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", ifc.PublicKey)
if len(p.PSKEnc) > 0 {
psk, err := h.Box.Open(p.PSKEnc)
if err != nil {
return "", fmt.Errorf("decrypt psk: %w", err)
}
fmt.Fprintf(&b, "PresharedKey = %s\n", string(psk))
}
// AllowedIPs on the client side is "everything that should go
// through the tunnel". For a server hosting an internal LAN
// this is typically 10.x/8 or the server's address range. We
// default to the iface address (so the client can at least
// reach the gateway) — operator can edit downloaded conf.
fmt.Fprintf(&b, "AllowedIPs = %s\n", ifc.AddressCIDR)
// Endpoint — the operator's public host:port that peers dial.
// We don't know this here (could be a CNAME or behind a load
// balancer); leave a placeholder the operator must fill in.
if ifc.ListenPort != nil {
fmt.Fprintf(&b, "Endpoint = REPLACE_WITH_PUBLIC_HOST:%d\n", *ifc.ListenPort)
}
if p.Keepalive != nil && *p.Keepalive > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive)
} else {
b.WriteString("PersistentKeepalive = 25\n")
}
return b.String(), nil
}
func (h *WireguardHandler) PeerConfig(c *gin.Context) {
id := parsePeerID(c)
if id == 0 {
return
}
conf, err := h.peerConfigText(c.Request.Context(), id)
if err != nil {
response.BadRequest(c, err)
return
}
c.Header("Content-Disposition", `attachment; filename="peer-`+strconv.FormatInt(id, 10)+`.conf"`)
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(conf))
}
func (h *WireguardHandler) PeerQR(c *gin.Context) {
id := parsePeerID(c)
if id == 0 {
return
}
conf, err := h.peerConfigText(c.Request.Context(), id)
if err != nil {
response.BadRequest(c, err)
return
}
png, err := qrcode.Encode(conf, qrcode.Medium, 512)
if err != nil {
response.Internal(c, err)
return
}
c.Data(http.StatusOK, "image/png", png)
}
// parsePeerID parses the :pid path param. Returns 0 on parse error
// after writing a 400 response.
func parsePeerID(c *gin.Context) int64 {
id, err := strconv.ParseInt(c.Param("pid"), 10, 64)
if err != nil {
response.BadRequest(c, errors.New("invalid peer id"))
return 0
}
return id
}

View File

@@ -0,0 +1,65 @@
package models
import "time"
// WireguardInterface is the local end of a WireGuard tunnel — server
// (we listen for peers) or client (we dial out to a fixed peer).
// PrivateKey + PeerPSK never appear in JSON; they are handled inside
// the handler as encrypted blobs (sealed via internal/services/secrets).
type WireguardInterface struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
Mode string `gorm:"column:mode" json:"mode"` // server|client
AddressCIDR string `gorm:"column:address_cidr" json:"address_cidr"`
ListenPort *int `gorm:"column:listen_port" json:"listen_port,omitempty"`
PublicKey string `gorm:"column:public_key" json:"public_key"`
PeerEndpoint *string `gorm:"column:peer_endpoint" json:"peer_endpoint,omitempty"`
PeerPublicKey *string `gorm:"column:peer_public_key" json:"peer_public_key,omitempty"`
AllowedIPs *string `gorm:"column:allowed_ips" json:"allowed_ips,omitempty"`
PersistentKeepalive *int `gorm:"column:persistent_keepalive" json:"persistent_keepalive,omitempty"`
MTU *int `gorm:"column:mtu" json:"mtu,omitempty"`
Role string `gorm:"column:role" json:"role"`
Active bool `gorm:"column:active" json:"active"`
Description *string `gorm:"column:description" json:"description,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
// PrivateKeyEnc / PeerPSKEnc are loaded from the DB as raw bytes
// — handler never serialises them. JSON tag uses '-' so they
// don't leak into responses if a developer accidentally returns
// the model directly.
PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"`
PeerPSKEnc []byte `gorm:"column:peer_psk_enc" json:"-"`
}
func (WireguardInterface) TableName() string { return "wireguard_interfaces" }
// WireguardPeer is a single roster entry on a server-mode interface.
// PrivateKey + PSK are encrypted at-rest and never returned in list
// payloads — only via the explicit /config download endpoint, and
// only once we generated the keypair server-side (nullable).
type WireguardPeer struct {
ID int64 `gorm:"primaryKey" json:"id"`
InterfaceID int64 `gorm:"column:interface_id" json:"interface_id"`
Name string `gorm:"column:name" json:"name"`
PublicKey string `gorm:"column:public_key" json:"public_key"`
AllowedIPs string `gorm:"column:allowed_ips" json:"allowed_ips"`
Keepalive *int `gorm:"column:keepalive" json:"keepalive,omitempty"`
LastHandshake *time.Time `gorm:"column:last_handshake" json:"last_handshake,omitempty"`
TransferRX int64 `gorm:"column:transfer_rx" json:"transfer_rx"`
TransferTX int64 `gorm:"column:transfer_tx" json:"transfer_tx"`
Enabled bool `gorm:"column:enabled" json:"enabled"`
Description *string `gorm:"column:description" json:"description,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"`
PSKEnc []byte `gorm:"column:psk_enc" json:"-"`
// HasPrivateKey is a derived flag for the UI: "is the QR-code
// download going to work for this peer, or is this a roster row
// where the operator only pasted a pubkey?"
HasPrivateKey bool `gorm:"-" json:"has_private_key"`
}
func (WireguardPeer) TableName() string { return "wireguard_peers" }

View File

@@ -0,0 +1,133 @@
// Package secrets provides envelope encryption for sensitive
// at-rest values (WireGuard private keys, peer PSKs, possibly more
// later). The master key lives in /var/lib/edgeguard/.master_key
// (32 bytes, 0600 root-owned via postinst — but readable by the
// edgeguard user via group), generated lazily on first use if
// missing.
//
// On-disk format per ciphertext: nonce (12 byte) || aes-gcm ciphertext.
// Plaintext is never logged or returned outside this package's
// callers.
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
)
const masterKeyLen = 32
// DefaultMasterKeyPath is the file that holds the box master key.
// Override via the EDGEGUARD_MASTER_KEY env var for tests.
const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key"
// Box uses AES-256-GCM with a static master key to seal/unseal
// values. Concurrency-safe; the cipher is initialised once.
type Box struct {
once sync.Once
aead cipher.AEAD
err error
path string
}
// New returns a Box that loads / lazily creates the master key at
// the given path. Pass empty string to use DefaultMasterKeyPath.
func New(path string) *Box {
if path == "" {
path = DefaultMasterKeyPath
}
return &Box{path: path}
}
func (b *Box) init() {
b.once.Do(func() {
key, err := loadOrCreateMasterKey(b.path)
if err != nil {
b.err = fmt.Errorf("master key: %w", err)
return
}
blk, err := aes.NewCipher(key)
if err != nil {
b.err = fmt.Errorf("aes cipher: %w", err)
return
}
aead, err := cipher.NewGCM(blk)
if err != nil {
b.err = fmt.Errorf("gcm: %w", err)
return
}
b.aead = aead
})
}
// Seal returns nonce||ciphertext. Empty input → empty output (so
// callers can store NULL for "not set" without a special case).
func (b *Box) Seal(plaintext []byte) ([]byte, error) {
if len(plaintext) == 0 {
return nil, nil
}
b.init()
if b.err != nil {
return nil, b.err
}
nonce := make([]byte, b.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
out := b.aead.Seal(nil, nonce, plaintext, nil)
return append(nonce, out...), nil
}
// Open is the inverse of Seal. Empty input → empty output.
func (b *Box) Open(blob []byte) ([]byte, error) {
if len(blob) == 0 {
return nil, nil
}
b.init()
if b.err != nil {
return nil, b.err
}
ns := b.aead.NonceSize()
if len(blob) < ns+1 {
return nil, errors.New("ciphertext too short")
}
nonce, ct := blob[:ns], blob[ns:]
pt, err := b.aead.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("decrypt: %w", err)
}
return pt, nil
}
func loadOrCreateMasterKey(path string) ([]byte, error) {
if data, err := os.ReadFile(path); err == nil {
if len(data) != masterKeyLen {
return nil, fmt.Errorf("master key file has wrong length %d, want %d", len(data), masterKeyLen)
}
return data, nil
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
// Generate. Make sure the parent dir exists; postinst should
// have created /var/lib/edgeguard already, but in dev (`go run`
// without sudo) we create it best-effort.
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("mkdir parent: %w", err)
}
key := make([]byte, masterKeyLen)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("rand: %w", err)
}
if err := os.WriteFile(path, key, 0o600); err != nil {
return nil, fmt.Errorf("write: %w", err)
}
return key, nil
}

View File

@@ -0,0 +1,346 @@
package wireguard
import (
"bufio"
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
)
// ImportResult summarises what an Import call did so the CLI can
// report it back to the operator.
type ImportResult struct {
IfacesAdded int `json:"ifaces_added"`
PeersAdded int `json:"peers_added"`
Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason
}
// Importer takes existing /etc/wireguard/*.conf files and translates
// them into wireguard_interfaces + wireguard_peers rows so the
// operator can keep their pre-EdgeGuard tunnels live across the
// migration. Heuristics:
//
// - Iface name = filename without .conf (so wg0.conf → wg0)
// - One [Interface] block becomes the wireguard_interfaces row
// - [Peer] blocks with Endpoint+ no AllowedIPs/0.0.0.0/0 → mode=client
// (rare — a wg conf usually has many peers in server mode)
// - >1 [Peer] block → mode=server, peers go to the peer roster
// - Single [Peer] with Endpoint set → mode=client (the peer is the
// upstream we dial)
//
// Existing iface names in the DB are skipped (idempotent re-run).
type Importer struct {
Ifaces *InterfacesRepo
Peers *PeersRepo
Box *secrets.Box
}
func NewImporter(ifaces *InterfacesRepo, peers *PeersRepo, box *secrets.Box) *Importer {
return &Importer{Ifaces: ifaces, Peers: peers, Box: box}
}
func (im *Importer) ImportDir(ctx context.Context, dir string) (*ImportResult, error) {
res := &ImportResult{}
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return res, nil
}
return nil, err
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
continue
}
path := filepath.Join(dir, e.Name())
ifaceName := strings.TrimSuffix(e.Name(), ".conf")
// Skip names that violate our regex — operator can rename
// the file and re-run.
if !validIfaceName(ifaceName) {
res.Skipped = append(res.Skipped, ifaceName+" (invalid name)")
continue
}
if err := im.importFile(ctx, ifaceName, path, res); err != nil {
res.Skipped = append(res.Skipped, ifaceName+": "+err.Error())
}
}
return res, nil
}
func (im *Importer) importFile(ctx context.Context, ifaceName, path string, res *ImportResult) error {
// Skip if already present.
all, err := im.Ifaces.List(ctx)
if err != nil {
return err
}
for _, x := range all {
if x.Name == ifaceName {
res.Skipped = append(res.Skipped, ifaceName+" (already in DB)")
return nil
}
}
parsed, err := parseWGConf(path)
if err != nil {
return err
}
if parsed.PrivateKey == "" {
return errors.New("no PrivateKey in [Interface]")
}
if parsed.Address == "" {
return errors.New("no Address in [Interface]")
}
// Mode heuristic: an [Interface] without ListenPort plus a single
// [Peer] with Endpoint set is a client tunnel. Anything else
// (multiple peers, ListenPort set, peers without Endpoint) we
// treat as server.
mode := "server"
if parsed.ListenPort == 0 && len(parsed.Peers) == 1 && parsed.Peers[0].Endpoint != "" {
mode = "client"
}
pub, err := PublicFromPrivate(parsed.PrivateKey)
if err != nil {
return fmt.Errorf("derive pubkey: %w", err)
}
encPriv, err := im.Box.Seal([]byte(parsed.PrivateKey))
if err != nil {
return fmt.Errorf("seal: %w", err)
}
ifc := models.WireguardInterface{
Name: ifaceName,
Mode: mode,
AddressCIDR: parsed.Address,
PublicKey: pub,
PrivateKeyEnc: encPriv,
Role: "wan",
Active: true,
MTU: intPtrIfNonzero(parsed.MTU),
}
if mode == "server" {
port := parsed.ListenPort
if port == 0 {
port = 51820
}
ifc.ListenPort = &port
} else {
// client mode → fold the single peer into the iface row.
p := parsed.Peers[0]
ep := p.Endpoint
pk := p.PublicKey
ifc.PeerEndpoint = &ep
ifc.PeerPublicKey = &pk
if p.AllowedIPs != "" {
ai := p.AllowedIPs
ifc.AllowedIPs = &ai
}
if p.Keepalive > 0 {
k := p.Keepalive
ifc.PersistentKeepalive = &k
}
if p.PSK != "" {
pskEnc, err := im.Box.Seal([]byte(p.PSK))
if err != nil {
return fmt.Errorf("seal psk: %w", err)
}
ifc.PeerPSKEnc = pskEnc
}
}
created, err := im.Ifaces.Create(ctx, ifc)
if err != nil {
return fmt.Errorf("insert iface: %w", err)
}
res.IfacesAdded++
if mode == "server" {
for i, p := range parsed.Peers {
if p.PublicKey == "" {
res.Skipped = append(res.Skipped, fmt.Sprintf("%s peer #%d (no PublicKey)", ifaceName, i))
continue
}
peer := models.WireguardPeer{
InterfaceID: created.ID,
Name: p.Name,
PublicKey: p.PublicKey,
AllowedIPs: p.AllowedIPs,
Enabled: true,
}
if peer.Name == "" {
peer.Name = fmt.Sprintf("imported-%d", i+1)
}
if peer.AllowedIPs == "" {
peer.AllowedIPs = "0.0.0.0/0"
}
if p.Keepalive > 0 {
k := p.Keepalive
peer.Keepalive = &k
}
if p.PSK != "" {
pskEnc, err := im.Box.Seal([]byte(p.PSK))
if err != nil {
return fmt.Errorf("seal peer psk: %w", err)
}
peer.PSKEnc = pskEnc
}
if _, err := im.Peers.Create(ctx, peer); err != nil {
res.Skipped = append(res.Skipped, fmt.Sprintf("%s peer %s: %v", ifaceName, peer.Name, err))
continue
}
res.PeersAdded++
}
}
return nil
}
// parsedConf is the intermediate shape of a parsed wg-quick file.
type parsedConf struct {
Address string
ListenPort int
PrivateKey string
MTU int
Peers []parsedPeer
}
type parsedPeer struct {
Name string
PublicKey string
Endpoint string
AllowedIPs string
Keepalive int
PSK string
}
// parseWGConf is intentionally lenient — it accepts anything wg-quick
// would accept, ignores PostUp/PostDown/Table/SaveConfig since
// edgeguard owns runtime, and collapses comment lines starting with
// '#' into the next [Peer]'s Name (mirrors the wg-easy convention).
func parseWGConf(path string) (*parsedConf, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var (
out parsedConf
section string
currentPeer *parsedPeer
nextName string
)
flushPeer := func() {
if currentPeer != nil {
if currentPeer.Name == "" && nextName != "" {
currentPeer.Name = nextName
}
out.Peers = append(out.Peers, *currentPeer)
currentPeer = nil
}
nextName = ""
}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
// Comment line; if the next thing we see is a [Peer],
// use this as the peer's name.
nextName = strings.TrimSpace(strings.TrimPrefix(line, "#"))
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
flushPeer()
section = strings.ToLower(strings.Trim(line, "[]"))
if section == "peer" {
currentPeer = &parsedPeer{}
}
continue
}
idx := strings.Index(line, "=")
if idx < 0 {
continue
}
key := strings.TrimSpace(strings.ToLower(line[:idx]))
val := strings.TrimSpace(line[idx+1:])
switch section {
case "interface":
switch key {
case "address":
out.Address = strings.Split(val, ",")[0] // first addr only
case "listenport":
if n, err := strconv.Atoi(val); err == nil {
out.ListenPort = n
}
case "privatekey":
out.PrivateKey = val
case "mtu":
if n, err := strconv.Atoi(val); err == nil {
out.MTU = n
}
}
case "peer":
if currentPeer == nil {
continue
}
switch key {
case "publickey":
currentPeer.PublicKey = val
case "endpoint":
currentPeer.Endpoint = val
case "allowedips":
currentPeer.AllowedIPs = val
case "persistentkeepalive":
if n, err := strconv.Atoi(val); err == nil {
currentPeer.Keepalive = n
}
case "presharedkey":
currentPeer.PSK = val
}
}
}
flushPeer()
if err := sc.Err(); err != nil {
return nil, err
}
return &out, nil
}
func validIfaceName(s string) bool {
if len(s) < 2 || len(s) > 15 {
return false
}
if !strings.HasPrefix(s, "wg") {
return false
}
for _, r := range s[2:] {
switch {
case r >= 'a' && r <= 'z',
r >= '0' && r <= '9',
r == '-':
default:
return false
}
}
return true
}
func intPtrIfNonzero(n int) *int {
if n == 0 {
return nil
}
return &n
}

View File

@@ -0,0 +1,118 @@
package wireguard
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrIfaceNotFound = errors.New("wireguard interface not found")
type InterfacesRepo struct {
Pool *pgxpool.Pool
}
func NewInterfacesRepo(pool *pgxpool.Pool) *InterfacesRepo { return &InterfacesRepo{Pool: pool} }
const ifaceBaseSelect = `
SELECT id, name, mode, address_cidr, listen_port, public_key, private_key_enc,
peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive,
mtu, role, active, description, created_at, updated_at
FROM wireguard_interfaces
`
func (r *InterfacesRepo) List(ctx context.Context) ([]models.WireguardInterface, error) {
rows, err := r.Pool.Query(ctx, ifaceBaseSelect+" ORDER BY name ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.WireguardInterface, 0, 4)
for rows.Next() {
i, err := scanIface(rows)
if err != nil {
return nil, err
}
out = append(out, *i)
}
return out, rows.Err()
}
func (r *InterfacesRepo) Get(ctx context.Context, id int64) (*models.WireguardInterface, error) {
row := r.Pool.QueryRow(ctx, ifaceBaseSelect+" WHERE id = $1", id)
i, err := scanIface(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrIfaceNotFound
}
return nil, err
}
return i, nil
}
func (r *InterfacesRepo) Create(ctx context.Context, i models.WireguardInterface) (*models.WireguardInterface, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO wireguard_interfaces (
name, mode, address_cidr, listen_port, public_key, private_key_enc,
peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive,
mtu, role, active, description
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING id, name, mode, address_cidr, listen_port, public_key, private_key_enc,
peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive,
mtu, role, active, description, created_at, updated_at`,
i.Name, i.Mode, i.AddressCIDR, i.ListenPort, i.PublicKey, i.PrivateKeyEnc,
i.PeerEndpoint, i.PeerPublicKey, i.PeerPSKEnc, i.AllowedIPs, i.PersistentKeepalive,
i.MTU, i.Role, i.Active, i.Description)
return scanIface(row)
}
func (r *InterfacesRepo) Update(ctx context.Context, id int64, i models.WireguardInterface) (*models.WireguardInterface, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE wireguard_interfaces SET
name = $1, mode = $2, address_cidr = $3, listen_port = $4, public_key = $5,
private_key_enc = $6, peer_endpoint = $7, peer_public_key = $8, peer_psk_enc = $9,
allowed_ips = $10, persistent_keepalive = $11, mtu = $12, role = $13,
active = $14, description = $15, updated_at = NOW()
WHERE id = $16
RETURNING id, name, mode, address_cidr, listen_port, public_key, private_key_enc,
peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive,
mtu, role, active, description, created_at, updated_at`,
i.Name, i.Mode, i.AddressCIDR, i.ListenPort, i.PublicKey, i.PrivateKeyEnc,
i.PeerEndpoint, i.PeerPublicKey, i.PeerPSKEnc, i.AllowedIPs, i.PersistentKeepalive,
i.MTU, i.Role, i.Active, i.Description, id)
out, err := scanIface(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrIfaceNotFound
}
return nil, err
}
return out, nil
}
func (r *InterfacesRepo) Delete(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM wireguard_interfaces WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrIfaceNotFound
}
return nil
}
func scanIface(row interface{ Scan(...any) error }) (*models.WireguardInterface, error) {
var i models.WireguardInterface
if err := row.Scan(
&i.ID, &i.Name, &i.Mode, &i.AddressCIDR, &i.ListenPort, &i.PublicKey, &i.PrivateKeyEnc,
&i.PeerEndpoint, &i.PeerPublicKey, &i.PeerPSKEnc, &i.AllowedIPs, &i.PersistentKeepalive,
&i.MTU, &i.Role, &i.Active, &i.Description, &i.CreatedAt, &i.UpdatedAt,
); err != nil {
return nil, err
}
return &i, nil
}

View File

@@ -0,0 +1,77 @@
// Package wireguard implements the persistence + lifecycle of
// WireGuard tunnels (server + client mode) and their peer roster.
// Sub-files split by concern:
//
// keys.go — Curve25519 keypair generation + parse helpers
// interfaces.go — wireguard_interfaces CRUD
// peers.go — wireguard_peers CRUD
// import.go — read /etc/wireguard/*.conf into the DB
package wireguard
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"golang.org/x/crypto/curve25519"
)
// Keypair holds a base64-encoded WireGuard keypair (the wire format
// wg-quick prints — 32 raw bytes, base64 standard padding included).
type Keypair struct {
Private string
Public string
}
// GenerateKeypair returns a fresh Curve25519 keypair, clamped per
// WireGuard's spec, base64-encoded.
func GenerateKeypair() (*Keypair, error) {
priv := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, priv); err != nil {
return nil, fmt.Errorf("rand: %w", err)
}
// WireGuard / curve25519 clamping: clear the lowest 3 bits of
// priv[0], clear the highest bit and set the second-highest of
// priv[31]. Required to make the scalar a valid Curve25519
// private key.
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
if err != nil {
return nil, fmt.Errorf("derive pub: %w", err)
}
return &Keypair{
Private: base64.StdEncoding.EncodeToString(priv),
Public: base64.StdEncoding.EncodeToString(pub),
}, nil
}
// GeneratePresharedKey returns a fresh 32-byte PSK as base64.
func GeneratePresharedKey() (string, error) {
psk := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, psk); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(psk), nil
}
// PublicFromPrivate derives the matching pubkey for a base64 private
// key — used by the import path where the operator hands us a
// private key from an existing wg-quick config.
func PublicFromPrivate(privB64 string) (string, error) {
priv, err := base64.StdEncoding.DecodeString(privB64)
if err != nil {
return "", fmt.Errorf("decode private key: %w", err)
}
if len(priv) != 32 {
return "", errors.New("private key must be 32 bytes")
}
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
if err != nil {
return "", fmt.Errorf("derive pub: %w", err)
}
return base64.StdEncoding.EncodeToString(pub), nil
}

View File

@@ -0,0 +1,132 @@
package wireguard
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrPeerNotFound = errors.New("wireguard peer not found")
type PeersRepo struct {
Pool *pgxpool.Pool
}
func NewPeersRepo(pool *pgxpool.Pool) *PeersRepo { return &PeersRepo{Pool: pool} }
const peerBaseSelect = `
SELECT id, interface_id, name, public_key, private_key_enc, psk_enc,
allowed_ips, keepalive, last_handshake, transfer_rx, transfer_tx,
enabled, description, created_at, updated_at
FROM wireguard_peers
`
func (r *PeersRepo) ListForInterface(ctx context.Context, ifaceID int64) ([]models.WireguardPeer, error) {
rows, err := r.Pool.Query(ctx, peerBaseSelect+" WHERE interface_id = $1 ORDER BY name ASC", ifaceID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.WireguardPeer, 0, 8)
for rows.Next() {
p, err := scanPeer(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
func (r *PeersRepo) ListAll(ctx context.Context) ([]models.WireguardPeer, error) {
rows, err := r.Pool.Query(ctx, peerBaseSelect+" ORDER BY interface_id, name ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.WireguardPeer, 0, 16)
for rows.Next() {
p, err := scanPeer(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
func (r *PeersRepo) Get(ctx context.Context, id int64) (*models.WireguardPeer, error) {
row := r.Pool.QueryRow(ctx, peerBaseSelect+" WHERE id = $1", id)
p, err := scanPeer(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPeerNotFound
}
return nil, err
}
return p, nil
}
func (r *PeersRepo) Create(ctx context.Context, p models.WireguardPeer) (*models.WireguardPeer, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO wireguard_peers (
interface_id, name, public_key, private_key_enc, psk_enc, allowed_ips,
keepalive, enabled, description
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id, interface_id, name, public_key, private_key_enc, psk_enc,
allowed_ips, keepalive, last_handshake, transfer_rx, transfer_tx,
enabled, description, created_at, updated_at`,
p.InterfaceID, p.Name, p.PublicKey, p.PrivateKeyEnc, p.PSKEnc, p.AllowedIPs,
p.Keepalive, p.Enabled, p.Description)
return scanPeer(row)
}
func (r *PeersRepo) Update(ctx context.Context, id int64, p models.WireguardPeer) (*models.WireguardPeer, error) {
row := r.Pool.QueryRow(ctx, `
UPDATE wireguard_peers SET
name = $1, public_key = $2, private_key_enc = $3, psk_enc = $4,
allowed_ips = $5, keepalive = $6, enabled = $7, description = $8,
updated_at = NOW()
WHERE id = $9
RETURNING id, interface_id, name, public_key, private_key_enc, psk_enc,
allowed_ips, keepalive, last_handshake, transfer_rx, transfer_tx,
enabled, description, created_at, updated_at`,
p.Name, p.PublicKey, p.PrivateKeyEnc, p.PSKEnc,
p.AllowedIPs, p.Keepalive, p.Enabled, p.Description, id)
out, err := scanPeer(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPeerNotFound
}
return nil, err
}
return out, nil
}
func (r *PeersRepo) Delete(ctx context.Context, id int64) error {
tag, err := r.Pool.Exec(ctx, `DELETE FROM wireguard_peers WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrPeerNotFound
}
return nil
}
func scanPeer(row interface{ Scan(...any) error }) (*models.WireguardPeer, error) {
var p models.WireguardPeer
if err := row.Scan(
&p.ID, &p.InterfaceID, &p.Name, &p.PublicKey, &p.PrivateKeyEnc, &p.PSKEnc,
&p.AllowedIPs, &p.Keepalive, &p.LastHandshake, &p.TransferRX, &p.TransferTX,
&p.Enabled, &p.Description, &p.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, err
}
p.HasPrivateKey = len(p.PrivateKeyEnc) > 0
return &p, nil
}

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 // Package wireguard renders /etc/edgeguard/wireguard/<iface>.conf
// Phase 3 (and run `wg syncconf` on reload). v1 ships a stub. // 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 package wireguard
import ( import (
"bytes"
"context" "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) Name() string { return "wireguard" }
func (g *Generator) Render(ctx context.Context) error { 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)
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "edgeguard-management-ui", "name": "edgeguard-management-ui",
"private": true, "private": true,
"version": "1.0.8", "version": "1.0.11",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -20,6 +20,7 @@ const NetworksPage = lazy(() => import('./pages/Networks'))
const IPAddressesPage = lazy(() => import('./pages/IPAddresses')) const IPAddressesPage = lazy(() => import('./pages/IPAddresses'))
const SSLPage = lazy(() => import('./pages/SSL')) const SSLPage = lazy(() => import('./pages/SSL'))
const FirewallPage = lazy(() => import('./pages/Firewall')) const FirewallPage = lazy(() => import('./pages/Firewall'))
const WireguardPage = lazy(() => import('./pages/Wireguard'))
const ClusterPage = lazy(() => import('./pages/Cluster')) const ClusterPage = lazy(() => import('./pages/Cluster'))
const SettingsPage = lazy(() => import('./pages/Settings')) const SettingsPage = lazy(() => import('./pages/Settings'))
@@ -99,6 +100,7 @@ export default function App() {
<Route path="/ip-addresses" element={<IPAddressesPage />} /> <Route path="/ip-addresses" element={<IPAddressesPage />} />
<Route path="/ssl" element={<SSLPage />} /> <Route path="/ssl" element={<SSLPage />} />
<Route path="/firewall" element={<FirewallPage />} /> <Route path="/firewall" element={<FirewallPage />} />
<Route path="/vpn/wireguard" element={<WireguardPage />} />
<Route path="/cluster" element={<ClusterPage />} /> <Route path="/cluster" element={<ClusterPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>

View File

@@ -0,0 +1,64 @@
import { Button, Popconfirm, Space, Tooltip } from 'antd'
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
// ActionButtons is the standard "Edit / Delete" pair used at the
// end of every CRUD table row. Centralising it means we only style
// the action column once across the app.
//
// Either prop may be omitted to suppress that button — useful for
// rows that aren't editable (e.g. builtin services).
interface ActionButtonsProps {
onEdit?: () => void
onDelete?: () => void
deleteConfirm?: string
editTooltip?: string
deleteTooltip?: string
editDisabled?: boolean
deleteDisabled?: boolean
editDisabledReason?: string
deleteDisabledReason?: string
}
export default function ActionButtons({
onEdit, onDelete,
deleteConfirm,
editTooltip, deleteTooltip,
editDisabled, deleteDisabled,
editDisabledReason, deleteDisabledReason,
}: ActionButtonsProps) {
const { t } = useTranslation()
return (
<Space size={4}>
{onEdit && (
<Tooltip title={editDisabled ? editDisabledReason : (editTooltip ?? t('common.edit'))}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
disabled={editDisabled}
onClick={onEdit}
/>
</Tooltip>
)}
{onDelete && (
deleteDisabled ? (
<Tooltip title={deleteDisabledReason ?? t('common.delete')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled />
</Tooltip>
) : (
<Popconfirm
title={deleteConfirm ?? t('common.deleteConfirm')}
okText={t('common.yes')}
cancelText={t('common.no')}
onConfirm={onDelete}
>
<Tooltip title={deleteTooltip ?? t('common.delete')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
)
)}
</Space>
)
}

View File

@@ -1,9 +1,12 @@
import { useMemo, useState } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { Input, Space, Table } from 'antd' import { Grid, Input, List, Pagination, Space, Table, Typography } from 'antd'
import type { ColumnsType, TableProps } from 'antd/es/table' import type { ColumnsType, TableProps } from 'antd/es/table'
import { SearchOutlined } from '@ant-design/icons' import { SearchOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const { useBreakpoint } = Grid
const { Text } = Typography
// DataTable wraps AntD's Table and gives every CRUD page the same // DataTable wraps AntD's Table and gives every CRUD page the same
// baseline UX: // baseline UX:
// //
@@ -23,8 +26,15 @@ interface DataTableProps<T> extends Omit<TableProps<T>, 'pagination'> {
searchPlaceholder?: string searchPlaceholder?: string
searchable?: boolean searchable?: boolean
// toolbar renders to the right of the search input, useful for // toolbar renders to the right of the search input, useful for
// "Add" buttons. // "Add" buttons. `extraActions` is the proxy-lb-waf-style alias —
toolbar?: React.ReactNode // both work to keep migration painless.
toolbar?: ReactNode
extraActions?: ReactNode
// renderMobileCard switches from a dense Table (desktop) to a
// List of cards (≤ md breakpoint). Mirrors the old EdgeGuard
// ProTable mobile mode. Pass undefined to use the default Table
// also on mobile (with horizontal scroll).
renderMobileCard?: (record: T, index: number) => ReactNode
} }
function inferSorter<T>(dataIndex: string | string[] | undefined) { function inferSorter<T>(dataIndex: string | string[] | undefined) {
@@ -59,6 +69,8 @@ export default function DataTable<T extends object>(
props: DataTableProps<T>, props: DataTableProps<T>,
) { ) {
const { t } = useTranslation() const { t } = useTranslation()
const screens = useBreakpoint()
const isMobile = !screens.md
const { const {
dataSource, dataSource,
columns, columns,
@@ -66,10 +78,16 @@ export default function DataTable<T extends object>(
searchPlaceholder, searchPlaceholder,
searchable = true, searchable = true,
toolbar, toolbar,
extraActions,
renderMobileCard,
rowKey,
loading,
...rest ...rest
} = props } = props
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [perPage, setPerPage] = useState(pageSize)
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!search || !dataSource) return dataSource if (!search || !dataSource) return dataSource
@@ -92,33 +110,71 @@ export default function DataTable<T extends object>(
}) })
}, [columns]) }, [columns])
const actions = toolbar ?? extraActions
const records = (filtered ?? []) as T[]
const start = (page - 1) * perPage
const visible = records.slice(start, start + perPage)
return ( return (
<Space direction="vertical" className="w-full mb-12" size="small"> <Space direction="vertical" className="w-full mb-12" size="small">
{(searchable || toolbar) && ( {(searchable || actions) && (
<div className="flex-between mb-12"> <div className="datatable-toolbar">
{searchable && ( {searchable && (
<Input <Input
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
placeholder={searchPlaceholder ?? t('common.search')} placeholder={searchPlaceholder ?? t('common.search')}
allowClear allowClear
onChange={(e) => setSearch(e.target.value)} onChange={(e) => { setSearch(e.target.value); setPage(1) }}
style={{ maxWidth: 320 }} style={{ maxWidth: isMobile ? '100%' : 320 }}
/> />
)} )}
{toolbar && <div>{toolbar}</div>} {actions && <Space wrap>{actions}</Space>}
</div> </div>
)} )}
<Table<T>
size="small" {isMobile && renderMobileCard ? (
{...rest} <>
dataSource={filtered} <List
columns={enhancedCols} loading={loading}
pagination={{ dataSource={visible}
pageSize, renderItem={(record, idx) => (
showSizeChanger: true, <List.Item style={{ padding: 0, marginBottom: 8, border: 'none' }}>
showTotal: (total) => t('common.totalRows', { count: total }), {renderMobileCard(record, start + idx)}
}} </List.Item>
/> )}
locale={{ emptyText: t('common.noData') }}
/>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Pagination
current={page}
pageSize={perPage}
total={records.length}
onChange={(p, ps) => { setPage(p); setPerPage(ps) }}
showSizeChanger
size="small"
pageSizeOptions={['20', '50', '100', '200']}
showTotal={(tot) => <Text type="secondary">{t('common.totalRows', { count: tot })}</Text>}
/>
</div>
</>
) : (
<Table<T>
size="small"
rowKey={rowKey}
loading={loading}
{...rest}
dataSource={filtered}
columns={enhancedCols}
pagination={{
pageSize: perPage,
current: page,
onChange: (p, ps) => { setPage(p); setPerPage(ps) },
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (total) => t('common.totalRows', { count: total }),
}}
/>
)}
</Space> </Space>
) )
} }

View File

@@ -11,6 +11,7 @@ import {
NodeIndexOutlined, NodeIndexOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
SettingOutlined, SettingOutlined,
ThunderboltOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -56,7 +57,8 @@ const NAV: NavSection[] = [
{ {
labelKey: 'nav.section.security', labelKey: 'nav.section.security',
items: [ items: [
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> }, { path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
], ],
}, },
{ {
@@ -68,7 +70,7 @@ const NAV: NavSection[] = [
}, },
] ]
const VERSION = '1.0.8' const VERSION = '1.0.11'
export default function Sidebar({ isOpen, onClose }: SidebarProps) { export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -0,0 +1,30 @@
import { Space, Typography } from 'antd'
import type { ReactNode } from 'react'
const { Text, Title } = Typography
// PageHeader is the standard top-of-page block: icon + title +
// optional subtitle on the left, optional extras (buttons, status
// dots, …) on the right. Lifted from the proxy-lb-waf design system
// so every page across EdgeGuard now has the same visual hierarchy.
interface PageHeaderProps {
icon?: ReactNode
title: ReactNode
subtitle?: ReactNode
extra?: ReactNode
}
export default function PageHeader({ icon, title, subtitle, extra }: PageHeaderProps) {
return (
<div className="page-header">
<div className="page-header-main">
<Title level={4} className="page-header-title">
{icon && <span className="page-header-icon">{icon}</span>}
{title}
</Title>
{subtitle && <Text type="secondary" className="page-header-subtitle">{subtitle}</Text>}
</div>
{extra && <Space wrap>{extra}</Space>}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Badge } from 'antd'
import { useTranslation } from 'react-i18next'
// StatusDot replaces the `Yes/No` text columns with the AntD Badge
// pattern from the old EdgeGuard — a small coloured dot + label,
// scans much faster in long tables.
interface StatusDotProps {
active: boolean
activeLabel?: string
inactiveLabel?: string
}
export default function StatusDot({ active, activeLabel, inactiveLabel }: StatusDotProps) {
const { t } = useTranslation()
return (
<Badge
status={active ? 'success' : 'default'}
text={active ? (activeLabel ?? t('common.active')) : (inactiveLabel ?? t('common.inactive'))}
/>
)
}

View File

@@ -12,7 +12,8 @@
"ipAddresses": "IP-Adressen", "ipAddresses": "IP-Adressen",
"ssl": "SSL-Zertifikate", "ssl": "SSL-Zertifikate",
"vpn": "VPN", "vpn": "VPN",
"firewall": "Firewall (v2)", "wireguard": "WireGuard",
"firewall": "Firewall",
"cluster": "Cluster", "cluster": "Cluster",
"settings": "Einstellungen", "settings": "Einstellungen",
"section": { "section": {
@@ -278,6 +279,72 @@
"applying": "Update läuft …", "applying": "Update läuft …",
"started": "Update wurde gestartet — der Server wird in Kürze neu starten." "started": "Update wurde gestartet — der Server wird in Kürze neu starten."
}, },
"wg": {
"title": "WireGuard",
"intro": "VPN-Tunnel über WireGuard. Server-Modus = wir lauschen für Peers; Client-Modus = wir verbinden zu einem festen Upstream. Privater Schlüssel liegt verschlüsselt in der DB.",
"tabs": { "servers": "Server-Tunnel", "clients": "Client-Tunnel" },
"serverIntro": "Server-Tunnel hosten ein Peer-Roster — z.B. Mitarbeiter-Geräte oder Niederlassungen. Pro Peer bekommt der Operator eine .conf zum Download (oder QR-Code für Mobile).",
"clientIntro": "Client-Tunnel verbinden EdgeGuard zu einem fremden WireGuard-Server (z.B. HQ-Datacenter). Allowed-IPs steuert, welcher Traffic durch den Tunnel geroutet wird.",
"iface": {
"name": "Name",
"namePattern": "wg gefolgt von Kleinbuchstaben/Ziffern/-, max. 15 Zeichen",
"nameExtra": "Empfehlung: wg0, wg1, wg-hq …",
"address": "Adresse (CIDR)",
"addressExtra": "Tunnel-IP der Box, z.B. 10.99.0.1/24 für /24-Pool",
"listenPort": "Listen-Port",
"publicKey": "Public-Key",
"privateKey": "Private-Key (paste)",
"privateKeyExtra": "Nur ausfüllen wenn nicht generieren — base64 32 Byte. Wird verschlüsselt gespeichert.",
"peerEndpoint": "Peer-Endpoint",
"peerPublicKey": "Peer Public-Key",
"peerPSK": "Pre-Shared-Key (PSK)",
"peerPSKExtra": "Optional, zusätzliche Schicht",
"allowedIPs": "Allowed IPs",
"allowedIPsExtra": "Was durch den Tunnel geroutet wird. Default = full-tunnel.",
"keepalive": "Persistent Keepalive (sec)",
"mtu": "MTU",
"zone": "Firewall-Zone",
"description": "Beschreibung",
"addServer": "Server-Tunnel hinzufügen",
"editServer": "Server-Tunnel bearbeiten",
"addClient": "Client-Tunnel hinzufügen",
"editClient": "Client-Tunnel bearbeiten",
"upstream": "Upstream-Peer",
"deleteConfirm": "Tunnel {{name}} wirklich löschen? wg-quick wird gestoppt.",
"keys": "Schlüssel",
"generateExtra": "Wenn an: Server erzeugt ein neues Curve25519-Keypair beim Speichern.",
"generateOn": "Server generiert",
"generateOff": "Manuell paste",
"editKeyWarning": "Achtung: neue Schlüssel = bestehende Peer-Configs ungültig. Nur ändern wenn explizit gewollt."
},
"peers": {
"button": "Peers",
"drawerTitle": "Peer-Roster"
},
"peer": {
"name": "Name",
"publicKey": "Public-Key",
"publicKeyExtra": "Wird vom Peer-Gerät erzeugt; hier nur paste-bar wenn der Peer schon ein Key-Pair hat.",
"allowedIPs": "Allowed IPs",
"allowedIPsExtra": "Welche Tunnel-IPs darf dieser Peer benutzen. Typisch /32 = eine IP.",
"keepalive": "Keepalive (sec)",
"keepaliveExtra": "0 = aus. Empfohlen 25 hinter NAT.",
"lastHandshake": "Letzter Handshake",
"never": "nie",
"description": "Beschreibung",
"add": "Peer hinzufügen",
"edit": "Peer bearbeiten",
"deleteConfirm": "Peer {{name}} wirklich entfernen?",
"keys": "Schlüssel",
"generateExtra": "Wenn an: Server erzeugt für diesen Peer ein Keypair und kann die Config / QR-Code ausliefern. Wenn aus: nur den Public-Key paste-en — keine Config-Download möglich.",
"pskExtra": "Wenn an: Server generiert einen 32-Byte PSK für diesen Peer.",
"pskOn": "PSK generieren",
"pskOff": "kein PSK",
"downloadConf": "wg-quick.conf herunterladen",
"qrTitle": "WireGuard-QR",
"qrHint": "Mit der WireGuard-App (iOS/Android) scannen: \"Tunnel hinzufügen\" → \"QR-Code scannen\". Endpoint im Download-Conf bitte vor Verwendung anpassen."
}
},
"common": { "common": {
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",
@@ -287,7 +354,16 @@
"error": "Fehler", "error": "Fehler",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"delete": "Löschen", "delete": "Löschen",
"deleteConfirm": "Wirklich löschen?",
"search": "Suchen …", "search": "Suchen …",
"totalRows": "{{count}} Einträge" "totalRows": "{{count}} Einträge",
"active": "Aktiv",
"inactive": "Inaktiv",
"noData": "Keine Einträge",
"actions": "Aktionen",
"add": "Hinzufügen",
"download": "Download",
"copy": "Kopieren",
"copied": "Kopiert"
} }
} }

View File

@@ -12,6 +12,7 @@
"ipAddresses": "IP addresses", "ipAddresses": "IP addresses",
"ssl": "SSL certificates", "ssl": "SSL certificates",
"vpn": "VPN", "vpn": "VPN",
"wireguard": "WireGuard",
"firewall": "Firewall", "firewall": "Firewall",
"cluster": "Cluster", "cluster": "Cluster",
"settings": "Settings", "settings": "Settings",
@@ -278,6 +279,72 @@
"applying": "Update in progress …", "applying": "Update in progress …",
"started": "Update has started — the server will restart shortly." "started": "Update has started — the server will restart shortly."
}, },
"wg": {
"title": "WireGuard",
"intro": "WireGuard VPN tunnels. Server mode = we listen for peers; client mode = we dial out to a fixed upstream. Private keys are encrypted at rest.",
"tabs": { "servers": "Server tunnels", "clients": "Client tunnels" },
"serverIntro": "Server tunnels host a peer roster — typically employee devices or branch sites. Each peer can be downloaded as a wg-quick.conf or scanned as a QR code.",
"clientIntro": "Client tunnels connect EdgeGuard to a remote WireGuard server (e.g. HQ datacenter). Allowed IPs control which traffic is routed through the tunnel.",
"iface": {
"name": "Name",
"namePattern": "wg followed by lowercase letters/digits/-, max 15 chars",
"nameExtra": "Suggested: wg0, wg1, wg-hq …",
"address": "Address (CIDR)",
"addressExtra": "Box's tunnel IP, e.g. 10.99.0.1/24 for a /24 pool",
"listenPort": "Listen port",
"publicKey": "Public key",
"privateKey": "Private key (paste)",
"privateKeyExtra": "Fill in only if not auto-generating — base64 32 bytes. Stored encrypted.",
"peerEndpoint": "Peer endpoint",
"peerPublicKey": "Peer public key",
"peerPSK": "Pre-shared key (PSK)",
"peerPSKExtra": "Optional extra layer",
"allowedIPs": "Allowed IPs",
"allowedIPsExtra": "What gets routed through the tunnel. Default = full tunnel.",
"keepalive": "Persistent keepalive (sec)",
"mtu": "MTU",
"zone": "Firewall zone",
"description": "Description",
"addServer": "Add server tunnel",
"editServer": "Edit server tunnel",
"addClient": "Add client tunnel",
"editClient": "Edit client tunnel",
"upstream": "Upstream peer",
"deleteConfirm": "Really delete tunnel {{name}}? wg-quick will be stopped.",
"keys": "Keys",
"generateExtra": "If on: server generates a fresh Curve25519 keypair on save.",
"generateOn": "Server-generated",
"generateOff": "Manual paste",
"editKeyWarning": "Warning: new keys invalidate all existing peer configs. Only change if intentional."
},
"peers": {
"button": "Peers",
"drawerTitle": "Peer roster"
},
"peer": {
"name": "Name",
"publicKey": "Public key",
"publicKeyExtra": "Generated by the peer device; only paste here if the peer already has a keypair.",
"allowedIPs": "Allowed IPs",
"allowedIPsExtra": "Which tunnel IPs this peer is allowed to use. Typically /32 = one IP.",
"keepalive": "Keepalive (sec)",
"keepaliveExtra": "0 = off. Recommended 25 behind NAT.",
"lastHandshake": "Last handshake",
"never": "never",
"description": "Description",
"add": "Add peer",
"edit": "Edit peer",
"deleteConfirm": "Really remove peer {{name}}?",
"keys": "Keys",
"generateExtra": "If on: server generates a keypair for this peer and can hand out the config / QR. If off: paste the peer's public key only — no config download.",
"pskExtra": "If on: server generates a 32-byte PSK for this peer.",
"pskOn": "Generate PSK",
"pskOff": "no PSK",
"downloadConf": "Download wg-quick.conf",
"qrTitle": "WireGuard QR",
"qrHint": "Scan with the WireGuard app (iOS/Android): \"Add tunnel\" → \"Scan QR code\". Replace the Endpoint placeholder in the downloaded conf before use."
}
},
"common": { "common": {
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
@@ -287,7 +354,16 @@
"error": "Error", "error": "Error",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"deleteConfirm": "Really delete?",
"search": "Search …", "search": "Search …",
"totalRows": "{{count}} rows" "totalRows": "{{count}} rows",
"active": "Active",
"inactive": "Inactive",
"noData": "No data",
"actions": "Actions",
"add": "Add",
"download": "Download",
"copy": "Copy",
"copied": "Copied"
} }
} }

View File

@@ -0,0 +1,232 @@
import { useState } from 'react'
import {
Alert, Button, Card, Col, Form, Input, InputNumber, Modal,
Row, Select, Switch, Tag, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { KeyOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
import type { WGInterface } from './types'
const { Text } = Typography
interface ClientForm {
name: string
address_cidr: string
peer_endpoint: string
peer_public_key: string
peer_psk?: string
allowed_ips: string
persistent_keepalive?: number
mtu?: number
role: string
active: boolean
description?: string
generate_keypair: boolean
private_key?: string
}
async function listClients(): Promise<WGInterface[]> {
const r = await apiClient.get('/wireguard/interfaces')
if (!isEnvelope(r.data)) return []
return ((r.data.data as { interfaces?: WGInterface[] }).interfaces ?? [])
.filter(i => i.mode === 'client')
}
interface FwZoneLite { name: string; builtin: boolean }
async function listZones(): Promise<FwZoneLite[]> {
const r = await apiClient.get('/firewall/zones')
if (!isEnvelope(r.data)) return []
return (r.data.data as { zones?: FwZoneLite[] }).zones ?? []
}
export default function ClientsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data: clients, isLoading } = useQuery({ queryKey: ['wg', 'clients'], queryFn: listClients })
const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
const [editing, setEditing] = useState<WGInterface | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<ClientForm>()
const upsert = useMutation({
mutationFn: async (v: ClientForm) => {
const body = { ...v, mode: 'client' }
if (editing) return (await apiClient.put(`/wireguard/interfaces/${editing.id}`, body)).data
return (await apiClient.post('/wireguard/interfaces', body)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['wg', 'clients'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/wireguard/interfaces/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'clients'] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<WGInterface> = [
{ 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.peerEndpoint'), dataIndex: 'peer_endpoint', key: 'peer_endpoint', render: (s?: string | null) => s ?? '—' },
{
title: t('wg.iface.peerPublicKey'), dataIndex: 'peer_public_key', key: 'peer_public_key',
render: (k?: string | null) => k ? <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 16)}</Text> : '—',
},
{ title: t('wg.iface.zone'), dataIndex: 'role', key: 'role', render: (r: string) => <Tag>{r}</Tag> },
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
address_cidr: row.address_cidr,
peer_endpoint: row.peer_endpoint ?? '',
peer_public_key: row.peer_public_key ?? '',
allowed_ips: row.allowed_ips ?? '0.0.0.0/0,::/0',
persistent_keepalive: row.persistent_keepalive ?? 25,
mtu: row.mtu ?? undefined,
role: row.role,
active: row.active,
description: row.description ?? undefined,
generate_keypair: false,
})
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('wg.iface.deleteConfirm', { name: row.name })}
/>
),
},
]
return (
<>
<Alert
type="info"
showIcon
className="mb-12"
message={t('wg.clientIntro')}
/>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={clients ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
allowed_ips: '0.0.0.0/0,::/0', persistent_keepalive: 25,
role: 'wan', active: true, generate_keypair: true,
})
}}>
{t('wg.iface.addClient')}
</Button>
}
/>
<Modal
title={editing ? t('wg.iface.editClient') : t('wg.iface.addClient')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={680}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label={t('wg.iface.name')} name="name"
rules={[{ required: true }, { pattern: /^wg[a-z0-9-]{0,13}$/, message: t('wg.iface.namePattern') }]}
>
<Input placeholder="wg-hq" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('wg.iface.address')} name="address_cidr" rules={[{ required: true }]}>
<Input placeholder="10.99.0.10/24" />
</Form.Item>
</Col>
</Row>
<Card size="small" type="inner" title={t('wg.iface.upstream')} className="mb-12">
<Form.Item label={t('wg.iface.peerEndpoint')} name="peer_endpoint" rules={[{ required: true }]}>
<Input placeholder="vpn.example.com:51820" />
</Form.Item>
<Form.Item label={t('wg.iface.peerPublicKey')} name="peer_public_key" rules={[{ required: true }]}>
<Input.TextArea rows={2} placeholder="base64 public key of the upstream" />
</Form.Item>
<Form.Item
label={t('wg.iface.allowedIPs')} name="allowed_ips"
extra={t('wg.iface.allowedIPsExtra')}
>
<Input placeholder="0.0.0.0/0,::/0 (full tunnel)" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('wg.iface.keepalive')} name="persistent_keepalive">
<InputNumber min={0} max={3600} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('wg.iface.peerPSK')} name="peer_psk" extra={t('wg.iface.peerPSKExtra')}>
<Input.Password placeholder="(optional)" />
</Form.Item>
</Col>
</Row>
</Card>
<Row gutter={16}>
<Col span={8}>
<Form.Item label={t('wg.iface.zone')} name="role" rules={[{ required: true }]}>
<Select
showSearch
options={(zones ?? []).map(z => ({ value: z.name, label: z.name.toUpperCase() }))}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label={t('wg.iface.mtu')} name="mtu">
<InputNumber min={1280} max={9000} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item label={t('wg.iface.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Card size="small" type="inner" title={<><KeyOutlined /> {t('wg.iface.keys')}</>}>
<Form.Item name="generate_keypair" valuePropName="checked" extra={t('wg.iface.generateExtra')}>
<Switch checkedChildren={t('wg.iface.generateOn')} unCheckedChildren={t('wg.iface.generateOff')} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(p, c) => p.generate_keypair !== c.generate_keypair}>
{({ getFieldValue }) => !getFieldValue('generate_keypair') && (
<Form.Item label={t('wg.iface.privateKey')} name="private_key" extra={t('wg.iface.privateKeyExtra')}>
<Input.TextArea rows={2} placeholder="base64-encoded 32-byte private key" />
</Form.Item>
)}
</Form.Item>
</Card>
</Form>
</Modal>
</>
)
}

View File

@@ -0,0 +1,462 @@
import { useState } from 'react'
import {
Alert, Button, Card, Col, Drawer, Form, Input, InputNumber,
Modal, Row, Select, Space, Switch, Tag, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
DownloadOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
TeamOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
import type { WGInterface, WGPeer } from './types'
const { Text } = Typography
interface ServerForm {
name: string
address_cidr: string
listen_port: number
mtu?: number
role: string
active: boolean
description?: string
generate_keypair: boolean
private_key?: string
}
interface PeerForm {
name: string
allowed_ips: string
keepalive?: number
enabled: boolean
description?: string
generate_keypair: boolean
generate_psk: boolean
public_key?: string
psk?: string
}
async function listServers(): Promise<WGInterface[]> {
const r = await apiClient.get('/wireguard/interfaces')
if (!isEnvelope(r.data)) return []
return ((r.data.data as { interfaces?: WGInterface[] }).interfaces ?? [])
.filter(i => i.mode === 'server')
}
async function listPeers(ifaceID: number): Promise<WGPeer[]> {
const r = await apiClient.get(`/wireguard/interfaces/${ifaceID}/peers`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { peers?: WGPeer[] }).peers ?? []
}
interface FwZoneLite { name: string; builtin: boolean }
async function listZones(): Promise<FwZoneLite[]> {
const r = await apiClient.get('/firewall/zones')
if (!isEnvelope(r.data)) return []
return (r.data.data as { zones?: FwZoneLite[] }).zones ?? []
}
export default function ServersTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data: servers, isLoading } = useQuery({ queryKey: ['wg', 'servers'], queryFn: listServers })
const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
// Create/edit modal state.
const [editing, setEditing] = useState<WGInterface | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<ServerForm>()
// Per-server peer-roster drawer.
const [peersDrawer, setPeersDrawer] = useState<WGInterface | null>(null)
const upsert = useMutation({
mutationFn: async (v: ServerForm) => {
const body = { ...v, mode: 'server' }
if (editing) return (await apiClient.put(`/wireguard/interfaces/${editing.id}`, body)).data
return (await apiClient.post('/wireguard/interfaces', body)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['wg', 'servers'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/wireguard/interfaces/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'servers'] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<WGInterface> = [
{ 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.listenPort'), dataIndex: 'listen_port', key: 'listen_port', render: (p?: number | null) => p ?? '—' },
{
title: t('wg.iface.publicKey'), dataIndex: 'public_key', key: 'public_key',
render: (k: string) => <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 16)}</Text>,
},
{ title: t('wg.iface.zone'), dataIndex: 'role', key: 'role', render: (r: string) => <Tag>{r}</Tag> },
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<Space size={4}>
<Button size="small" type="text" icon={<TeamOutlined />} onClick={() => setPeersDrawer(row)}>
{t('wg.peers.button')}
</Button>
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
address_cidr: row.address_cidr,
listen_port: row.listen_port ?? 51820,
mtu: row.mtu ?? undefined,
role: row.role,
active: row.active,
description: row.description ?? undefined,
generate_keypair: false,
})
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('wg.iface.deleteConfirm', { name: row.name })}
/>
</Space>
),
},
]
return (
<>
<Alert
type="info"
showIcon
className="mb-12"
message={t('wg.serverIntro')}
/>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={servers ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
listen_port: 51820, role: 'wan', active: true, generate_keypair: true,
})
}}>
{t('wg.iface.addServer')}
</Button>
}
/>
<Modal
title={editing ? t('wg.iface.editServer') : t('wg.iface.addServer')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={620}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label={t('wg.iface.name')} name="name"
rules={[{ required: true }, { pattern: /^wg[a-z0-9-]{0,13}$/, message: t('wg.iface.namePattern') }]}
extra={t('wg.iface.nameExtra')}
>
<Input placeholder="wg0" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('wg.iface.listenPort')} name="listen_port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={16}>
<Form.Item
label={t('wg.iface.address')} name="address_cidr"
rules={[{ required: true }]}
extra={t('wg.iface.addressExtra')}
>
<Input placeholder="10.99.0.1/24" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label={t('wg.iface.mtu')} name="mtu" extra="default 1420">
<InputNumber min={1280} max={9000} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('wg.iface.zone')} name="role" rules={[{ required: true }]}>
<Select
showSearch
options={(zones ?? []).map(z => ({ value: z.name, label: z.name.toUpperCase() }))}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item label={t('wg.iface.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Card size="small" type="inner" title={<><KeyOutlined /> {t('wg.iface.keys')}</>}>
<Form.Item name="generate_keypair" valuePropName="checked" extra={t('wg.iface.generateExtra')}>
<Switch checkedChildren={t('wg.iface.generateOn')} unCheckedChildren={t('wg.iface.generateOff')} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(p, c) => p.generate_keypair !== c.generate_keypair}>
{({ getFieldValue }) => !getFieldValue('generate_keypair') && (
<Form.Item label={t('wg.iface.privateKey')} name="private_key" extra={t('wg.iface.privateKeyExtra')}>
<Input.TextArea rows={2} placeholder="base64-encoded 32-byte private key" />
</Form.Item>
)}
</Form.Item>
{editing && (
<Alert
type="warning"
showIcon
style={{ marginTop: 8 }}
message={t('wg.iface.editKeyWarning')}
/>
)}
</Card>
</Form>
</Modal>
<PeerDrawer
iface={peersDrawer}
onClose={() => setPeersDrawer(null)}
/>
</>
)
}
// ── Peer roster drawer ───────────────────────────────────────────────
interface PeerDrawerProps {
iface: WGInterface | null
onClose: () => void
}
function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
const { t } = useTranslation()
const qc = useQueryClient()
const open = iface !== null
const ifaceID = iface?.id ?? 0
const { data: peers, isLoading } = useQuery({
queryKey: ['wg', 'peers', ifaceID],
queryFn: () => listPeers(ifaceID),
enabled: open,
})
const [editing, setEditing] = useState<WGPeer | null>(null)
const [creating, setCreating] = useState(false)
const [qrPeer, setQrPeer] = useState<WGPeer | null>(null)
const [form] = Form.useForm<PeerForm>()
const upsert = useMutation({
mutationFn: async (v: PeerForm) => {
if (editing) return (await apiClient.put(`/wireguard/peers/${editing.id}`, v)).data
return (await apiClient.post(`/wireguard/interfaces/${ifaceID}/peers`, v)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['wg', 'peers', ifaceID] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/wireguard/peers/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'peers', ifaceID] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<WGPeer> = [
{ title: t('wg.peer.name'), dataIndex: 'name', key: 'name' },
{ title: t('wg.peer.allowedIPs'), dataIndex: 'allowed_ips', key: 'allowed_ips', render: (s: string) => <code>{s}</code> },
{
title: t('wg.peer.publicKey'), dataIndex: 'public_key', key: 'public_key',
render: (k: string) => <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 12)}</Text>,
},
{
title: t('wg.peer.lastHandshake'), dataIndex: 'last_handshake', key: 'last_handshake',
render: (s?: string | null) => s ? new Date(s).toLocaleString() : <Tag>{t('wg.peer.never')}</Tag>,
},
{ title: t('common.active'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<Space size={4}>
{row.has_private_key && (
<>
<Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => setQrPeer(row)}>QR</Button>
<Button
size="small"
type="text"
icon={<DownloadOutlined />}
href={`/api/v1/wireguard/peers/${row.id}/config`}
target="_blank"
title={t('wg.peer.downloadConf')}
>.conf</Button>
</>
)}
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
allowed_ips: row.allowed_ips,
keepalive: row.keepalive ?? undefined,
enabled: row.enabled,
description: row.description ?? undefined,
generate_keypair: false,
generate_psk: false,
public_key: row.public_key,
})
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('wg.peer.deleteConfirm', { name: row.name })}
/>
</Space>
),
},
]
return (
<Drawer
open={open}
onClose={onClose}
width={920}
title={iface && (
<Space>
<span>{t('wg.peers.drawerTitle')}</span>
<Tag color="blue"><code>{iface.name}</code></Tag>
<Text type="secondary">{iface.address_cidr}</Text>
</Space>
)}
destroyOnClose
>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={peers ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
allowed_ips: '', enabled: true,
generate_keypair: true, generate_psk: false,
})
}}>
{t('wg.peer.add')}
</Button>
}
/>
<Modal
title={editing ? t('wg.peer.edit') : t('wg.peer.add')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={620}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Row gutter={16}>
<Col span={14}>
<Form.Item label={t('wg.peer.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="alice-laptop" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item label={t('common.active')} name="enabled" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item
label={t('wg.peer.allowedIPs')} name="allowed_ips" rules={[{ required: true }]}
extra={t('wg.peer.allowedIPsExtra')}
>
<Input placeholder="10.99.0.10/32" />
</Form.Item>
<Form.Item label={t('wg.peer.keepalive')} name="keepalive" extra={t('wg.peer.keepaliveExtra')}>
<InputNumber min={0} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('wg.peer.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Card size="small" type="inner" title={<><KeyOutlined /> {t('wg.peer.keys')}</>}>
<Form.Item name="generate_keypair" valuePropName="checked" extra={t('wg.peer.generateExtra')}>
<Switch checkedChildren={t('wg.iface.generateOn')} unCheckedChildren={t('wg.iface.generateOff')} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(p, c) => p.generate_keypair !== c.generate_keypair}>
{({ getFieldValue }) => !getFieldValue('generate_keypair') && (
<Form.Item label={t('wg.peer.publicKey')} name="public_key" extra={t('wg.peer.publicKeyExtra')}>
<Input.TextArea rows={2} placeholder="base64 public key (operator-paste)" />
</Form.Item>
)}
</Form.Item>
<Form.Item name="generate_psk" valuePropName="checked" extra={t('wg.peer.pskExtra')}>
<Switch checkedChildren={t('wg.peer.pskOn')} unCheckedChildren={t('wg.peer.pskOff')} />
</Form.Item>
</Card>
</Form>
</Modal>
<Modal
title={qrPeer && `${t('wg.peer.qrTitle')}${qrPeer.name}`}
open={qrPeer !== null}
onCancel={() => setQrPeer(null)}
footer={null}
width={420}
>
{qrPeer && (
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<img
src={`/api/v1/wireguard/peers/${qrPeer.id}/qr`}
alt="WireGuard QR"
style={{ width: '100%', maxWidth: 360, height: 'auto', display: 'block' }}
/>
<Text type="secondary" style={{ textAlign: 'center', fontSize: 12 }}>
{t('wg.peer.qrHint')}
</Text>
<Button
type="primary"
icon={<DownloadOutlined />}
href={`/api/v1/wireguard/peers/${qrPeer.id}/config`}
target="_blank"
>
{t('wg.peer.downloadConf')}
</Button>
</Space>
)}
</Modal>
</Drawer>
)
}

View File

@@ -0,0 +1,39 @@
import { Tabs } from 'antd'
import { ApiOutlined, GlobalOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import PageHeader from '../../components/PageHeader'
import ServersTab from './Servers'
import ClientsTab from './Clients'
// /vpn/wireguard — two tabs (Server, Client). Each is independent;
// they share types but not state. Server-tab opens a peer-roster
// drawer per server, Client-tab manages outbound tunnels with a
// fixed upstream peer.
export default function WireguardPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader
icon={<ThunderboltOutlined />}
title={t('wg.title')}
subtitle={t('wg.intro')}
/>
<Tabs
defaultActiveKey="servers"
items={[
{
key: 'servers',
label: <span><GlobalOutlined /> {t('wg.tabs.servers')}</span>,
children: <ServersTab />,
},
{
key: 'clients',
label: <span><ApiOutlined /> {t('wg.tabs.clients')}</span>,
children: <ClientsTab />,
},
]}
/>
</div>
)
}

View File

@@ -0,0 +1,37 @@
// Shared types for /vpn/wireguard tabs.
export interface WGInterface {
id: number
name: string
mode: 'server' | 'client'
address_cidr: string
listen_port?: number | null
public_key: string
peer_endpoint?: string | null
peer_public_key?: string | null
allowed_ips?: string | null
persistent_keepalive?: number | null
mtu?: number | null
role: string
active: boolean
description?: string | null
created_at: string
updated_at: string
}
export interface WGPeer {
id: number
interface_id: number
name: string
public_key: string
allowed_ips: string
keepalive?: number | null
last_handshake?: string | null
transfer_rx: number
transfer_tx: number
enabled: boolean
description?: string | null
has_private_key: boolean
created_at: string
updated_at: string
}

View File

@@ -2708,3 +2708,41 @@ h1, h2, h3, h4, h5, h6 {
gap: 8px; gap: 8px;
} }
.learning-neural-name { font-family: 'JetBrains Mono', monospace; } .learning-neural-name { font-family: 'JetBrains Mono', monospace; }
/* ── PageHeader (proxy-lb-waf-style) ───────────────────────────── */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.page-header-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.page-header-title {
margin: 0 !important;
display: flex;
align-items: center;
gap: 8px;
font-size: 18px !important;
font-weight: 600 !important;
}
.page-header-title .page-header-icon {
color: #0EA5E9;
display: inline-flex;
font-size: 18px;
}
.page-header-subtitle {
font-size: 13px;
color: #64748B;
}
/* ── DataTable toolbar row ─────────────────────────────────────── */
.datatable-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
align-items: center;
justify-content: space-between;
}

View File

@@ -39,6 +39,12 @@ case "$1" in
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy.service edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload haproxy.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload haproxy.service
edgeguard ALL=(root) NOPASSWD: /usr/sbin/nft -f /etc/edgeguard/nftables.d/ruleset.nft edgeguard ALL=(root) NOPASSWD: /usr/sbin/nft -f /etc/edgeguard/nftables.d/ruleset.nft
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start wg-quick@*.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart wg-quick@*.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop wg-quick@*.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl start wg-quick@*.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service
SUDOERS SUDOERS
chmod 0440 /etc/sudoers.d/edgeguard chmod 0440 /etc/sudoers.d/edgeguard