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:
co-authored by
Claude Sonnet 5
parent
3d6608ef80
commit
3b3ffd8ebf
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user