Add wireguard-ui-multi core: multi-server DB, WireGuard manager, REST API, UI, installers

Implements the from-scratch multi-server WireGuard management fork per
CLAUDE.md spec: sqlite schema (servers/peers/audit_log/users), Curve25519
key generation, per-interface config rendering + wg-quick/systemd control,
nftables hook scaffolding, session+CSRF-protected REST API with QR code
and config download endpoints, a minimal vanilla-JS web UI, legacy
wg0.conf migration, and both a native installer and a Proxmox LXC
provisioning script (with auto-detected latest Debian template).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-10 02:53:14 +02:00
co-authored by Claude Sonnet 5
parent 3d6608ef80
commit 3b3ffd8ebf
26 changed files with 3236 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
package wireguard
import (
"fmt"
"os"
"path/filepath"
"strings"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// ConfigDir is where per-interface wgX.conf files are written, e.g. /etc/wireguard.
var ConfigDir = "/etc/wireguard"
// RenderConfig builds the wg-quick compatible config text for a server and its peers.
func RenderConfig(srv *server.Server, peers []*server.Peer) string {
var b strings.Builder
fmt.Fprintf(&b, "[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", srv.PrivateKey)
fmt.Fprintf(&b, "Address = %s\n", srv.AddressRange)
fmt.Fprintf(&b, "ListenPort = %d\n", srv.ListenPort)
if srv.MTU > 0 {
fmt.Fprintf(&b, "MTU = %d\n", srv.MTU)
}
if srv.DNS != "" {
fmt.Fprintf(&b, "DNS = %s\n", srv.DNS)
}
for _, p := range peers {
if !p.Enabled {
continue
}
b.WriteString("\n[Peer]\n")
fmt.Fprintf(&b, "# %s\n", p.Name)
fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey)
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.AllowedIPs)
if p.PersistentKeepalive > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive)
}
}
return b.String()
}
// RenderClientConfig builds the config a peer/client would use to connect to srv.
func RenderClientConfig(srv *server.Server, p *server.Peer, endpointHost string) string {
var b strings.Builder
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey)
fmt.Fprintf(&b, "Address = %s\n", p.AllowedIPs)
if srv.DNS != "" {
fmt.Fprintf(&b, "DNS = %s\n", srv.DNS)
}
b.WriteString("\n[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", srv.PublicKey)
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "Endpoint = %s:%d\n", endpointHost, srv.ListenPort)
fmt.Fprintf(&b, "AllowedIPs = 0.0.0.0/0, ::/0\n")
if p.PersistentKeepalive > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive)
}
return b.String()
}
// WriteConfig writes the rendered server config to ConfigDir/<interface>.conf with 0600 perms.
func WriteConfig(srv *server.Server, peers []*server.Peer) error {
if err := os.MkdirAll(ConfigDir, 0700); err != nil {
return err
}
path := filepath.Join(ConfigDir, srv.InterfaceName+".conf")
return os.WriteFile(path, []byte(RenderConfig(srv, peers)), 0600)
}
// ConfigPath returns the on-disk path for a server's config file.
func ConfigPath(srv *server.Server) string {
return filepath.Join(ConfigDir, srv.InterfaceName+".conf")
}
+47
View File
@@ -0,0 +1,47 @@
package wireguard
import (
"crypto/rand"
"encoding/base64"
"golang.org/x/crypto/curve25519"
)
// GenerateKeyPair creates a new WireGuard-compatible Curve25519 key pair,
// base64-encoded like `wg genkey` / `wg pubkey`.
func GenerateKeyPair() (privateKey, publicKey string, err error) {
var priv [32]byte
if _, err := rand.Read(priv[:]); err != nil {
return "", "", err
}
// Clamp per RFC 7748 / WireGuard convention.
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
}
// PublicFromPrivate derives the public key for an existing base64 private key.
func PublicFromPrivate(privateKeyB64 string) (string, error) {
privBytes, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return "", err
}
var priv, pub [32]byte
copy(priv[:], privBytes)
curve25519.ScalarBaseMult(&pub, &priv)
return base64.StdEncoding.EncodeToString(pub[:]), nil
}
// GeneratePresharedKey creates a random base64 preshared key.
func GeneratePresharedKey() (string, error) {
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key[:]), nil
}
+78
View File
@@ -0,0 +1,78 @@
package wireguard
import (
"fmt"
"os/exec"
"strings"
)
// Status of a WireGuard interface.
type Status string
const (
StatusUp Status = "UP"
StatusDown Status = "DOWN"
)
// Up brings up the given interface via wg-quick.
func Up(iface string) error {
return run("wg-quick", "up", iface)
}
// Down brings down the given interface via wg-quick.
func Down(iface string) error {
return run("wg-quick", "down", iface)
}
// Reload applies config changes to a running interface without a full restart,
// using `wg syncconf` against a stripped config (wg-quick strip).
func Reload(iface, confPath string) error {
strip := exec.Command("wg-quick", "strip", confPath)
stripped, err := strip.Output()
if err != nil {
return fmt.Errorf("wg-quick strip: %w", err)
}
sync := exec.Command("wg", "syncconf", iface, "/dev/stdin")
sync.Stdin = strings.NewReader(string(stripped))
if out, err := sync.CombinedOutput(); err != nil {
return fmt.Errorf("wg syncconf: %w: %s", err, out)
}
return nil
}
// IsUp checks whether the interface currently exists / is up.
func IsUp(iface string) bool {
cmd := exec.Command("wg", "show", iface)
return cmd.Run() == nil
}
func GetStatus(iface string) Status {
if IsUp(iface) {
return StatusUp
}
return StatusDown
}
// EnableService enables and starts the systemd wg-quick@<iface>.service unit.
func EnableService(iface string) error {
if err := run("systemctl", "enable", "wg-quick@"+iface); err != nil {
return err
}
return run("systemctl", "start", "wg-quick@"+iface)
}
// DisableService stops and disables the systemd wg-quick@<iface>.service unit.
func DisableService(iface string) error {
if err := run("systemctl", "stop", "wg-quick@"+iface); err != nil {
return err
}
return run("systemctl", "disable", "wg-quick@"+iface)
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, out)
}
return nil
}
+203
View File
@@ -0,0 +1,203 @@
package wireguard
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// ParsedLegacyConfig is the parsed result of a legacy wg-quick style config file.
type ParsedLegacyConfig struct {
PrivateKey string
Address string // e.g. "10.10.0.1/24" (used as AddressRange for the new Server)
ListenPort int
DNS string
MTU int
Peers []ParsedLegacyPeer
}
// ParsedLegacyPeer is a single [Peer] section from a legacy config.
type ParsedLegacyPeer struct {
Name string
PublicKey string
PresharedKey string
AllowedIPs string
Endpoint string
PersistentKeepalive int
}
// ParseLegacyConfig reads and parses a wg-quick INI-style config file (e.g. /etc/wireguard/wg0.conf).
func ParseLegacyConfig(path string) (*ParsedLegacyConfig, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
cfg := &ParsedLegacyConfig{}
var curSection string
var curPeer *ParsedLegacyPeer
// pendingName holds a comment found on the line(s) immediately before a
// "[Peer]" header, e.g. "# client-laptop". wg-quick has no native peer
// name field, so this is the only place a human-readable name can come
// from; it's consumed (and reset) as soon as the next [Peer] section starts.
var pendingName string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
pendingName = strings.TrimSpace(strings.TrimLeft(line, "#;"))
continue
}
// Strip inline comments.
if idx := strings.IndexAny(line, "#;"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
if line == "" {
continue
}
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section := strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
switch section {
case "interface":
curSection = "interface"
curPeer = nil
case "peer":
curSection = "peer"
cfg.Peers = append(cfg.Peers, ParsedLegacyPeer{Name: pendingName})
curPeer = &cfg.Peers[len(cfg.Peers)-1]
default:
curSection = ""
curPeer = nil
}
pendingName = ""
continue
}
key, value, ok := splitKV(line)
if !ok {
continue
}
switch curSection {
case "interface":
switch {
case strings.EqualFold(key, "PrivateKey"):
cfg.PrivateKey = value
case strings.EqualFold(key, "Address"):
cfg.Address = value
case strings.EqualFold(key, "ListenPort"):
cfg.ListenPort, _ = strconv.Atoi(value)
case strings.EqualFold(key, "DNS"):
cfg.DNS = value
case strings.EqualFold(key, "MTU"):
cfg.MTU, _ = strconv.Atoi(value)
}
case "peer":
if curPeer == nil {
continue
}
switch {
case strings.EqualFold(key, "PublicKey"):
curPeer.PublicKey = value
case strings.EqualFold(key, "PresharedKey"):
curPeer.PresharedKey = value
case strings.EqualFold(key, "AllowedIPs"):
curPeer.AllowedIPs = value
case strings.EqualFold(key, "Endpoint"):
curPeer.Endpoint = value
case strings.EqualFold(key, "PersistentKeepalive"):
curPeer.PersistentKeepalive, _ = strconv.Atoi(value)
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return cfg, nil
}
func splitKV(line string) (key, value string, ok bool) {
idx := strings.Index(line, "=")
if idx < 0 {
return "", "", false
}
key = strings.TrimSpace(line[:idx])
value = strings.TrimSpace(line[idx+1:])
if key == "" {
return "", "", false
}
return key, value, true
}
// ImportLegacyServer parses legacyConfPath and creates a corresponding Server + its Peers
// in the given store, using serverName and interfaceName for the new Server record.
// Returns the new server's ID.
func ImportLegacyServer(store *server.Store, legacyConfPath, serverName, interfaceName string) (int64, error) {
parsed, err := ParseLegacyConfig(legacyConfPath)
if err != nil {
return 0, fmt.Errorf("parse legacy config %q: %w", legacyConfPath, err)
}
pubKey, err := PublicFromPrivate(parsed.PrivateKey)
if err != nil {
return 0, fmt.Errorf("derive public key: %w", err)
}
mtu := parsed.MTU
if mtu == 0 {
mtu = 1420
}
srv := &server.Server{
Name: serverName,
InterfaceName: interfaceName,
ListenPort: parsed.ListenPort,
PrivateKey: parsed.PrivateKey,
PublicKey: pubKey,
AddressRange: parsed.Address,
DNS: parsed.DNS,
MTU: mtu,
Enabled: true,
}
serverID, err := store.CreateServer(srv)
if err != nil {
return 0, fmt.Errorf("create server: %w", err)
}
for i, pp := range parsed.Peers {
name := pp.Name
if name == "" {
name = fmt.Sprintf("peer-%d", i+1)
}
peer := &server.Peer{
ServerID: serverID,
Name: name,
PublicKey: pp.PublicKey,
PresharedKey: pp.PresharedKey,
AllowedIPs: pp.AllowedIPs,
Endpoint: pp.Endpoint,
PersistentKeepalive: pp.PersistentKeepalive,
Enabled: true,
}
if _, err := store.CreatePeer(peer); err != nil {
return serverID, fmt.Errorf("create peer %q (index %d): %w", name, i, err)
}
}
return serverID, nil
}