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
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const sessionCookieName = "wgm_session"
const csrfCookieName = "wgm_csrf"
const sessionTTL = 12 * time.Hour
type session struct {
username string
csrf string
expiresAt time.Time
}
// SessionStore is a simple in-memory session store (single-process deployment).
type SessionStore struct {
mu sync.Mutex
sessions map[string]*session
}
func NewSessionStore() *SessionStore {
return &SessionStore{sessions: make(map[string]*session)}
}
func randomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (s *SessionStore) Create(username string) (sessionToken, csrfToken string, err error) {
sessionToken, err = randomToken()
if err != nil {
return "", "", err
}
csrfToken, err = randomToken()
if err != nil {
return "", "", err
}
s.mu.Lock()
s.sessions[sessionToken] = &session{
username: username,
csrf: csrfToken,
expiresAt: time.Now().Add(sessionTTL),
}
s.mu.Unlock()
return sessionToken, csrfToken, nil
}
func (s *SessionStore) Get(token string) (*session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[token]
if !ok || time.Now().After(sess.expiresAt) {
delete(s.sessions, token)
return nil, false
}
return sess, true
}
func (s *SessionStore) Delete(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
}
// HashPassword bcrypt-hashes a plaintext password for storage.
func HashPassword(pw string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return string(b), err
}
// CheckPassword compares a plaintext password against a stored bcrypt hash.
func CheckPassword(hash, pw string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
var ErrUnauthenticated = errors.New("unauthenticated")
// requireAuth resolves the session from the request cookie, or fails.
func (a *API) requireAuth(r *http.Request) (*session, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, ErrUnauthenticated
}
sess, ok := a.sessions.Get(c.Value)
if !ok {
return nil, ErrUnauthenticated
}
return sess, nil
}
// requireCSRF checks the X-CSRF-Token header against the session's csrf token,
// mandatory for all state-changing (non-GET) requests.
func requireCSRF(sess *session, r *http.Request) bool {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return true
}
token := r.Header.Get("X-CSRF-Token")
return subtle.ConstantTimeCompare([]byte(token), []byte(sess.csrf)) == 1
}
func setSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionTTL.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: csrfToken,
Path: "/",
HttpOnly: false, // readable by frontend JS to echo back in X-CSRF-Token header
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionTTL.Seconds()),
})
}
func clearSessionCookies(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "", Path: "/", MaxAge: -1})
}
+484
View File
@@ -0,0 +1,484 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
qrcode "github.com/skip2/go-qrcode"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/firewall"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
wg "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/wireguard"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func idParam(r *http.Request, name string) (int64, error) {
return strconv.ParseInt(r.PathValue(name), 10, 64)
}
// --- Auth ---
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
var hash string
err := a.db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, req.Username).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !CheckPassword(hash, req.Password)) {
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "login failed")
return
}
sessionToken, csrfToken, err := a.sessions.Create(req.Username)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not create session")
return
}
setSessionCookies(w, sessionToken, csrfToken)
_ = a.db.LogAudit(req.Username, "login", "session", "")
writeJSON(w, http.StatusOK, map[string]string{"csrf_token": csrfToken})
}
func (a *API) handleLogout(w http.ResponseWriter, r *http.Request, sess *session) {
if c, err := r.Cookie(sessionCookieName); err == nil {
a.sessions.Delete(c.Value)
}
clearSessionCookies(w)
_ = a.db.LogAudit(sess.username, "logout", "session", "")
w.WriteHeader(http.StatusNoContent)
}
// --- Servers ---
func (a *API) handleListServers(w http.ResponseWriter, r *http.Request, _ *session) {
servers, err := a.store.ListServers()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
type serverStatus struct {
*server.Server
Status wg.Status `json:"status"`
}
out := make([]serverStatus, 0, len(servers))
for _, s := range servers {
out = append(out, serverStatus{Server: s, Status: wg.GetStatus(s.InterfaceName)})
}
writeJSON(w, http.StatusOK, out)
}
type createServerRequest struct {
Name string `json:"name"`
InterfaceName string `json:"interface_name"`
ListenPort int `json:"listen_port"`
AddressRange string `json:"address_range"`
DNS string `json:"dns"`
MTU int `json:"mtu"`
}
func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request, sess *session) {
var req createServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.InterfaceName == "" || req.ListenPort == 0 || req.AddressRange == "" {
writeErr(w, http.StatusBadRequest, "name, interface_name, listen_port, address_range required")
return
}
if req.MTU == 0 {
req.MTU = 1420
}
priv, pub, err := wg.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, "key generation failed")
return
}
srv := &server.Server{
Name: req.Name, InterfaceName: req.InterfaceName, ListenPort: req.ListenPort,
PrivateKey: priv, PublicKey: pub, AddressRange: req.AddressRange,
DNS: req.DNS, MTU: req.MTU, Enabled: true,
}
id, err := a.store.CreateServer(srv)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
srv.ID = id
if err := wg.WriteConfig(srv, nil); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = a.db.LogAudit(sess.username, "server.create", req.Name, "")
writeJSON(w, http.StatusCreated, srv)
}
func (a *API) handleGetServer(w http.ResponseWriter, r *http.Request, _ *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, srv)
}
func (a *API) handleUpdateServer(w http.ResponseWriter, r *http.Request, sess *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
} else if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var req createServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
srv.Name, srv.AddressRange, srv.DNS = req.Name, req.AddressRange, req.DNS
if req.MTU > 0 {
srv.MTU = req.MTU
}
if req.ListenPort > 0 {
srv.ListenPort = req.ListenPort
}
if err := a.store.UpdateServer(srv); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
peers, _ := a.store.ListPeersByServer(srv.ID)
if err := wg.WriteConfig(srv, peers); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = a.db.LogAudit(sess.username, "server.update", srv.Name, "")
writeJSON(w, http.StatusOK, srv)
}
func (a *API) handleDeleteServer(w http.ResponseWriter, r *http.Request, sess *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if err != nil {
writeErr(w, http.StatusNotFound, "server not found")
return
}
_ = wg.Down(srv.InterfaceName)
if err := a.store.DeleteServer(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
_ = a.db.LogAudit(sess.username, "server.delete", srv.Name, "")
w.WriteHeader(http.StatusNoContent)
}
func (a *API) handleStartServer(w http.ResponseWriter, r *http.Request, sess *session) {
a.serverAction(w, r, sess, "server.start", func(srv *server.Server) error {
if err := wg.Up(srv.InterfaceName); err != nil {
return err
}
return firewall.RunHook(firewall.HookServerStart, srv.InterfaceName)
})
}
func (a *API) handleStopServer(w http.ResponseWriter, r *http.Request, sess *session) {
a.serverAction(w, r, sess, "server.stop", func(srv *server.Server) error {
if err := wg.Down(srv.InterfaceName); err != nil {
return err
}
return firewall.RunHook(firewall.HookServerStop, srv.InterfaceName)
})
}
func (a *API) handleReloadServer(w http.ResponseWriter, r *http.Request, sess *session) {
a.serverAction(w, r, sess, "server.reload", func(srv *server.Server) error {
peers, err := a.store.ListPeersByServer(srv.ID)
if err != nil {
return err
}
if err := wg.WriteConfig(srv, peers); err != nil {
return err
}
return wg.Reload(srv.InterfaceName, wg.ConfigPath(srv))
})
}
func (a *API) serverAction(w http.ResponseWriter, r *http.Request, sess *session, action string, fn func(*server.Server) error) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
} else if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := fn(srv); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
_ = a.db.LogAudit(sess.username, action, srv.Name, "")
writeJSON(w, http.StatusOK, map[string]string{"status": string(wg.GetStatus(srv.InterfaceName))})
}
func (a *API) handleDownloadServerConfig(w http.ResponseWriter, r *http.Request, _ *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if err != nil {
writeErr(w, http.StatusNotFound, "server not found")
return
}
peers, err := a.store.ListPeersByServer(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", "attachment; filename="+srv.InterfaceName+".conf")
_, _ = w.Write([]byte(wg.RenderConfig(srv, peers)))
}
// --- Peers ---
func (a *API) handleListPeers(w http.ResponseWriter, r *http.Request, _ *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
peers, err := a.store.ListPeersByServer(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
// never expose private keys in listing responses
type safePeer struct {
*server.Peer
}
out := make([]map[string]any, 0, len(peers))
for _, p := range peers {
out = append(out, map[string]any{
"id": p.ID, "server_id": p.ServerID, "name": p.Name, "email": p.Email,
"public_key": p.PublicKey, "allowed_ips": p.AllowedIPs, "endpoint": p.Endpoint,
"persistent_keepalive": p.PersistentKeepalive, "enabled": p.Enabled,
"expires_at": p.ExpiresAt,
})
}
writeJSON(w, http.StatusOK, out)
}
type createPeerRequest struct {
Name string `json:"name"`
Email string `json:"email"`
AllowedIPs string `json:"allowed_ips"`
PersistentKeepalive int `json:"persistent_keepalive"`
UsePresharedKey bool `json:"use_preshared_key"`
}
func (a *API) handleCreatePeer(w http.ResponseWriter, r *http.Request, sess *session) {
serverID, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(serverID)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
} else if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var req createPeerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.AllowedIPs == "" {
writeErr(w, http.StatusBadRequest, "name and allowed_ips required")
return
}
if req.PersistentKeepalive == 0 {
req.PersistentKeepalive = 25
}
priv, pub, err := wg.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, "key generation failed")
return
}
var psk string
if req.UsePresharedKey {
psk, err = wg.GeneratePresharedKey()
if err != nil {
writeErr(w, http.StatusInternalServerError, "psk generation failed")
return
}
}
p := &server.Peer{
ServerID: serverID, Name: req.Name, Email: req.Email, PublicKey: pub, PrivateKey: priv,
PresharedKey: psk, AllowedIPs: req.AllowedIPs, PersistentKeepalive: req.PersistentKeepalive,
Enabled: true,
}
id, err := a.store.CreatePeer(p)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
p.ID = id
peers, _ := a.store.ListPeersByServer(serverID)
if err := wg.WriteConfig(srv, peers); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = firewall.RunHook(firewall.HookPeerAdd, srv.InterfaceName, p.PublicKey)
_ = a.db.LogAudit(sess.username, "peer.create", p.Name, "server="+srv.Name)
writeJSON(w, http.StatusCreated, p)
}
func (a *API) handleDeletePeer(w http.ResponseWriter, r *http.Request, sess *session) {
serverID, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
peerID, err := idParam(r, "peerid")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid peer id")
return
}
srv, err := a.store.GetServer(serverID)
if err != nil {
writeErr(w, http.StatusNotFound, "server not found")
return
}
p, err := a.store.GetPeer(peerID)
if err != nil {
writeErr(w, http.StatusNotFound, "peer not found")
return
}
if err := a.store.DeletePeer(peerID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
peers, _ := a.store.ListPeersByServer(serverID)
if err := wg.WriteConfig(srv, peers); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = firewall.RunHook(firewall.HookPeerRemove, srv.InterfaceName, p.PublicKey)
_ = a.db.LogAudit(sess.username, "peer.delete", p.Name, "server="+srv.Name)
w.WriteHeader(http.StatusNoContent)
}
func (a *API) handleDownloadPeerConfig(w http.ResponseWriter, r *http.Request, _ *session) {
srv, p, err := a.loadServerAndPeer(r)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
host := r.URL.Query().Get("host")
if host == "" {
host = r.Host
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", "attachment; filename="+p.Name+".conf")
_, _ = w.Write([]byte(wg.RenderClientConfig(srv, p, host)))
}
func (a *API) handlePeerQRCode(w http.ResponseWriter, r *http.Request, _ *session) {
srv, p, err := a.loadServerAndPeer(r)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
host := r.URL.Query().Get("host")
if host == "" {
host = r.Host
}
png, err := qrcode.Encode(wg.RenderClientConfig(srv, p, host), qrcode.Medium, 256)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(bytes.NewBuffer(png).Bytes())
}
func (a *API) loadServerAndPeer(r *http.Request) (*server.Server, *server.Peer, error) {
serverID, err := idParam(r, "id")
if err != nil {
return nil, nil, errors.New("invalid id")
}
peerID, err := idParam(r, "peerid")
if err != nil {
return nil, nil, errors.New("invalid peer id")
}
srv, err := a.store.GetServer(serverID)
if err != nil {
return nil, nil, errors.New("server not found")
}
p, err := a.store.GetPeer(peerID)
if err != nil {
return nil, nil, errors.New("peer not found")
}
return srv, p, nil
}
+86
View File
@@ -0,0 +1,86 @@
package api
import (
"log/slog"
"net/http"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// API holds shared dependencies for HTTP handlers.
type API struct {
db *database.DB
store *server.Store
sessions *SessionStore
log *slog.Logger
lanIface string
}
func New(db *database.DB, log *slog.Logger, lanIface string) *API {
return &API{
db: db,
store: server.NewStore(db),
sessions: NewSessionStore(),
log: log,
lanIface: lanIface,
}
}
// Routes builds the full HTTP handler tree (API + UI), using Go 1.22 mux patterns.
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
// Auth
mux.HandleFunc("POST /api/login", a.handleLogin)
mux.HandleFunc("POST /api/logout", a.withAuth(a.handleLogout))
// Servers
mux.HandleFunc("GET /api/servers", a.withAuth(a.handleListServers))
mux.HandleFunc("POST /api/servers", a.withAuth(a.handleCreateServer))
mux.HandleFunc("GET /api/servers/{id}", a.withAuth(a.handleGetServer))
mux.HandleFunc("PUT /api/servers/{id}", a.withAuth(a.handleUpdateServer))
mux.HandleFunc("DELETE /api/servers/{id}", a.withAuth(a.handleDeleteServer))
mux.HandleFunc("POST /api/servers/{id}/start", a.withAuth(a.handleStartServer))
mux.HandleFunc("POST /api/servers/{id}/stop", a.withAuth(a.handleStopServer))
mux.HandleFunc("POST /api/servers/{id}/reload", a.withAuth(a.handleReloadServer))
mux.HandleFunc("GET /api/servers/{id}/config", a.withAuth(a.handleDownloadServerConfig))
// Peers
mux.HandleFunc("GET /api/server/{id}/peers", a.withAuth(a.handleListPeers))
mux.HandleFunc("POST /api/server/{id}/peer", a.withAuth(a.handleCreatePeer))
mux.HandleFunc("DELETE /api/server/{id}/peer/{peerid}", a.withAuth(a.handleDeletePeer))
mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/config", a.withAuth(a.handleDownloadPeerConfig))
mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/qrcode", a.withAuth(a.handlePeerQRCode))
// UI
mux.HandleFunc("GET /", a.handleDashboard)
mux.HandleFunc("GET /login", a.handleLoginPage)
mux.HandleFunc("GET /servers/{id}", a.handleServerPage)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("internal/ui/static"))))
return a.logMiddleware(mux)
}
func (a *API) logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.log.Info("request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
// withAuth enforces a valid session and, for mutating requests, a matching CSRF token.
func (a *API) withAuth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := a.requireAuth(r)
if err != nil {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if !requireCSRF(sess, r) {
http.Error(w, "invalid csrf token", http.StatusForbidden)
return
}
next(w, r, sess)
}
}
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"net/http"
)
const templatesDir = "internal/ui/templates"
// hasSession reports whether the request carries a valid, non-expired session cookie.
func (a *API) hasSession(r *http.Request) bool {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return false
}
_, ok := a.sessions.Get(c.Value)
return ok
}
func (a *API) handleDashboard(w http.ResponseWriter, r *http.Request) {
if !a.hasSession(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
http.ServeFile(w, r, templatesDir+"/dashboard.html")
}
func (a *API) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if a.hasSession(r) {
http.Redirect(w, r, "/", http.StatusFound)
return
}
http.ServeFile(w, r, templatesDir+"/login.html")
}
func (a *API) handleServerPage(w http.ResponseWriter, r *http.Request) {
if !a.hasSession(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
http.ServeFile(w, r, templatesDir+"/server.html")
}
+85
View File
@@ -0,0 +1,85 @@
package database
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// DB wraps the sqlite connection used by the whole application.
type DB struct {
*sql.DB
}
const schema = `
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
interface_name TEXT NOT NULL UNIQUE,
listen_port INTEGER NOT NULL,
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
address_range TEXT NOT NULL,
dns TEXT DEFAULT '',
mtu INTEGER DEFAULT 1420,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT DEFAULT '',
public_key TEXT NOT NULL,
private_key TEXT DEFAULT '',
preshared_key TEXT DEFAULT '',
allowed_ips TEXT NOT NULL,
endpoint TEXT DEFAULT '',
persistent_keepalive INTEGER DEFAULT 25,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
detail TEXT DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_peers_server_id ON peers(server_id);
`
// Open opens (creating if needed) the sqlite database at path and applies schema.
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := sqlDB.Exec(schema); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
return &DB{sqlDB}, nil
}
// LogAudit records an entry in the audit log.
func (db *DB) LogAudit(actor, action, target, detail string) error {
_, err := db.Exec(`INSERT INTO audit_log (actor, action, target, detail) VALUES (?, ?, ?, ?)`,
actor, action, target, detail)
return err
}
+75
View File
@@ -0,0 +1,75 @@
package firewall
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// HooksDir holds optional user-defined shell scripts run around lifecycle events.
var HooksDir = "/etc/wireguard-manager/hooks"
// HookEvent names the lifecycle points a hook script may exist for.
type HookEvent string
const (
HookServerStart HookEvent = "server-start"
HookServerStop HookEvent = "server-stop"
HookPeerAdd HookEvent = "peer-add"
HookPeerRemove HookEvent = "peer-remove"
)
// RunHook executes /etc/wireguard-manager/hooks/<event> if present and executable,
// passing iface (and optionally peer pubkey) as arguments. Missing hook is not an error.
func RunHook(event HookEvent, args ...string) error {
path := filepath.Join(HooksDir, string(event))
if _, err := os.Stat(path); err != nil {
return nil // hook not installed, skip silently
}
cmd := exec.Command(path, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("hook %s: %w: %s", event, err, out)
}
return nil
}
// NFTRuleset renders a suggested nftables ruleset snippet for a server, allowing
// its UDP listen port in and forwarding traffic between the tunnel and lanIface.
func NFTRuleset(srv *server.Server, lanIface string) string {
return fmt.Sprintf(`table inet wireguard_%s {
chain input {
type filter hook input priority 0; policy accept;
udp dport %d accept
}
chain forward {
type filter hook forward priority 0; policy accept;
iifname "%s" oifname "%s" accept
iifname "%s" oifname "%s" accept
}
}
`, srv.InterfaceName, srv.ListenPort, srv.InterfaceName, lanIface, lanIface, srv.InterfaceName)
}
// ApplyRuleset writes the ruleset to a temp file and loads it with `nft -f`.
func ApplyRuleset(srv *server.Server, lanIface string) error {
tmp, err := os.CreateTemp("", "wgm-nft-*.conf")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(NFTRuleset(srv, lanIface)); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.Command("nft", "-f", tmp.Name())
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("nft -f: %w: %s", err, out)
}
return nil
}
+205
View File
@@ -0,0 +1,205 @@
package server
import (
"database/sql"
"errors"
"time"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
)
// Server represents a single, independent WireGuard interface.
type Server struct {
ID int64
Name string
InterfaceName string
ListenPort int
PrivateKey string
PublicKey string
AddressRange string
DNS string
MTU int
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Peer represents a WireGuard client belonging to a Server.
type Peer struct {
ID int64
ServerID int64
Name string
Email string
PublicKey string
PrivateKey string
PresharedKey string
AllowedIPs string
Endpoint string
PersistentKeepalive int
Enabled bool
ExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
var ErrNotFound = errors.New("not found")
// Store provides CRUD access to servers and peers.
type Store struct {
db *database.DB
}
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
func (s *Store) CreateServer(srv *Server) (int64, error) {
res, err := s.db.Exec(`INSERT INTO servers
(name, interface_name, listen_port, private_key, public_key, address_range, dns, mtu, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey,
srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled))
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateServer(srv *Server) error {
_, err := s.db.Exec(`UPDATE servers SET
name = ?, interface_name = ?, listen_port = ?, private_key = ?, public_key = ?,
address_range = ?, dns = ?, mtu = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey,
srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled), srv.ID)
return err
}
func (s *Store) DeleteServer(id int64) error {
_, err := s.db.Exec(`DELETE FROM servers WHERE id = ?`, id)
return err
}
func (s *Store) GetServer(id int64) (*Server, error) {
row := s.db.QueryRow(`SELECT id, name, interface_name, listen_port, private_key, public_key,
address_range, dns, mtu, enabled, created_at, updated_at FROM servers WHERE id = ?`, id)
return scanServer(row)
}
func (s *Store) ListServers() ([]*Server, error) {
rows, err := s.db.Query(`SELECT id, name, interface_name, listen_port, private_key, public_key,
address_range, dns, mtu, enabled, created_at, updated_at FROM servers ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Server
for rows.Next() {
srv, err := scanServerRows(rows)
if err != nil {
return nil, err
}
out = append(out, srv)
}
return out, rows.Err()
}
func (s *Store) CreatePeer(p *Peer) (int64, error) {
res, err := s.db.Exec(`INSERT INTO peers
(server_id, name, email, public_key, private_key, preshared_key, allowed_ips, endpoint,
persistent_keepalive, enabled, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ServerID, p.Name, p.Email, p.PublicKey, p.PrivateKey, p.PresharedKey, p.AllowedIPs,
p.Endpoint, p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdatePeer(p *Peer) error {
_, err := s.db.Exec(`UPDATE peers SET
name = ?, email = ?, public_key = ?, preshared_key = ?, allowed_ips = ?, endpoint = ?,
persistent_keepalive = ?, enabled = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
p.Name, p.Email, p.PublicKey, p.PresharedKey, p.AllowedIPs, p.Endpoint,
p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt, p.ID)
return err
}
func (s *Store) DeletePeer(id int64) error {
_, err := s.db.Exec(`DELETE FROM peers WHERE id = ?`, id)
return err
}
func (s *Store) GetPeer(id int64) (*Peer, error) {
row := s.db.QueryRow(`SELECT id, server_id, name, email, public_key, private_key, preshared_key,
allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at
FROM peers WHERE id = ?`, id)
return scanPeer(row)
}
func (s *Store) ListPeersByServer(serverID int64) ([]*Peer, error) {
rows, err := s.db.Query(`SELECT id, server_id, name, email, public_key, private_key, preshared_key,
allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at
FROM peers WHERE server_id = ? ORDER BY name`, serverID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Peer
for rows.Next() {
p, err := scanPeerRows(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
type scanner interface {
Scan(dest ...any) error
}
func scanServer(row scanner) (*Server, error) {
var srv Server
var enabled int
if err := row.Scan(&srv.ID, &srv.Name, &srv.InterfaceName, &srv.ListenPort, &srv.PrivateKey,
&srv.PublicKey, &srv.AddressRange, &srv.DNS, &srv.MTU, &enabled, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
srv.Enabled = enabled != 0
return &srv, nil
}
func scanServerRows(rows *sql.Rows) (*Server, error) { return scanServer(rows) }
func scanPeer(row scanner) (*Peer, error) {
var p Peer
var enabled int
if err := row.Scan(&p.ID, &p.ServerID, &p.Name, &p.Email, &p.PublicKey, &p.PrivateKey,
&p.PresharedKey, &p.AllowedIPs, &p.Endpoint, &p.PersistentKeepalive, &enabled,
&p.ExpiresAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Enabled = enabled != 0
return &p, nil
}
func scanPeerRows(rows *sql.Rows) (*Peer, error) { return scanPeer(rows) }
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+106
View File
@@ -0,0 +1,106 @@
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return match ? decodeURIComponent(match[1]) : "";
}
async function apiFetch(url, options) {
options = options || {};
options.headers = options.headers || {};
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
}
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("unauthenticated");
}
return res;
}
async function loadServers() {
const tbody = document.querySelector("#servers tbody");
tbody.innerHTML = "";
const res = await apiFetch("/api/servers");
if (!res.ok) return;
const servers = await res.json();
for (const s of servers) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
const link = document.createElement("a");
link.href = "/servers/" + s.ID;
link.textContent = s.Name;
nameTd.appendChild(link);
const ifaceTd = document.createElement("td");
ifaceTd.textContent = s.InterfaceName;
const portTd = document.createElement("td");
portTd.textContent = s.ListenPort;
const statusTd = document.createElement("td");
const badge = document.createElement("span");
badge.className = "badge " + (s.status === "UP" ? "up" : "down");
badge.textContent = s.status;
statusTd.appendChild(badge);
const actionsTd = document.createElement("td");
actionsTd.appendChild(makeActionButton("Start", () => serverAction(s.ID, "start")));
actionsTd.appendChild(makeActionButton("Stop", () => serverAction(s.ID, "stop")));
actionsTd.appendChild(makeActionButton("Reload", () => serverAction(s.ID, "reload")));
tr.appendChild(nameTd);
tr.appendChild(ifaceTd);
tr.appendChild(portTd);
tr.appendChild(statusTd);
tr.appendChild(actionsTd);
tbody.appendChild(tr);
}
}
function makeActionButton(label, onClick) {
const btn = document.createElement("button");
btn.textContent = label;
btn.className = "secondary";
btn.addEventListener("click", onClick);
return btn;
}
async function serverAction(id, action) {
await apiFetch("/api/servers/" + id + "/" + action, { method: "POST" });
loadServers();
}
document.getElementById("new-server").addEventListener("click", async () => {
const name = prompt("Name des Servers (z.B. WGhome):");
if (!name) return;
const interfaceName = prompt("Interface (z.B. wg-home):");
if (!interfaceName) return;
const listenPort = parseInt(prompt("Listen Port (z.B. 51822):"), 10);
if (!listenPort) return;
const addressRange = prompt("Address Range (z.B. 10.20.22.0/24):");
if (!addressRange) return;
const dns = prompt("DNS (optional):") || "";
const res = await apiFetch("/api/servers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name,
interface_name: interfaceName,
listen_port: listenPort,
address_range: addressRange,
dns: dns,
mtu: 1420,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error || "Server konnte nicht erstellt werden.");
return;
}
loadServers();
});
loadServers();
+25
View File
@@ -0,0 +1,25 @@
document.getElementById("login-form").addEventListener("submit", async function (e) {
e.preventDefault();
const errEl = document.getElementById("login-error");
errEl.textContent = "";
const form = e.target;
const username = form.username.value;
const password = form.password.value;
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errEl.textContent = data.error || "Anmeldung fehlgeschlagen.";
return;
}
window.location.href = "/";
} catch (err) {
errEl.textContent = "Verbindung fehlgeschlagen.";
}
});
+173
View File
@@ -0,0 +1,173 @@
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return match ? decodeURIComponent(match[1]) : "";
}
async function apiFetch(url, options) {
options = options || {};
options.headers = options.headers || {};
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
}
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("unauthenticated");
}
return res;
}
function serverIDFromPath() {
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[1];
}
const serverID = serverIDFromPath();
const errEl = document.getElementById("server-error");
async function loadServer() {
errEl.textContent = "";
const res = await apiFetch("/api/servers/" + serverID);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errEl.textContent = data.error || "Server konnte nicht geladen werden.";
return;
}
const s = await res.json();
document.getElementById("server-name").textContent = s.Name;
document.getElementById("d-interface").textContent = s.InterfaceName;
document.getElementById("d-port").textContent = s.ListenPort;
document.getElementById("d-address").textContent = s.AddressRange;
document.getElementById("d-dns").textContent = s.DNS || "-";
document.getElementById("d-mtu").textContent = s.MTU;
const statusRes = await apiFetch("/api/servers");
if (statusRes.ok) {
const servers = await statusRes.json();
const match = servers.find((x) => String(x.ID) === String(serverID));
const statusTd = document.getElementById("d-status");
statusTd.innerHTML = "";
const badge = document.createElement("span");
const status = match ? match.status : "DOWN";
badge.className = "badge " + (status === "UP" ? "up" : "down");
badge.textContent = status;
statusTd.appendChild(badge);
}
}
async function loadPeers() {
const tbody = document.querySelector("#peers tbody");
tbody.innerHTML = "";
const res = await apiFetch("/api/server/" + serverID + "/peers");
if (!res.ok) return;
const peers = await res.json();
for (const p of peers) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
nameTd.textContent = p.name;
const emailTd = document.createElement("td");
emailTd.textContent = p.email || "-";
const allowedTd = document.createElement("td");
allowedTd.textContent = p.allowed_ips;
const enabledTd = document.createElement("td");
enabledTd.textContent = p.enabled ? "Ja" : "Nein";
const actionsTd = document.createElement("td");
const qrBtn = document.createElement("button");
qrBtn.textContent = "QR-Code";
qrBtn.className = "secondary";
qrBtn.addEventListener("click", () => showQRCode(p.id));
actionsTd.appendChild(qrBtn);
const dlLink = document.createElement("a");
dlLink.href = "/api/server/" + serverID + "/peer/" + p.id + "/config?host=" + encodeURIComponent(window.location.hostname);
dlLink.textContent = "Config";
dlLink.style.marginLeft = "0.5rem";
actionsTd.appendChild(dlLink);
const delBtn = document.createElement("button");
delBtn.textContent = "Löschen";
delBtn.className = "danger";
delBtn.addEventListener("click", () => deletePeer(p.id));
actionsTd.appendChild(delBtn);
tr.appendChild(nameTd);
tr.appendChild(emailTd);
tr.appendChild(allowedTd);
tr.appendChild(enabledTd);
tr.appendChild(actionsTd);
tbody.appendChild(tr);
}
}
function showQRCode(peerID) {
const modal = document.getElementById("qrcode-modal");
const img = document.getElementById("qrcode-img");
img.src = "/api/server/" + serverID + "/peer/" + peerID + "/qrcode?host=" + encodeURIComponent(window.location.hostname) + "&t=" + Date.now();
modal.classList.remove("hidden");
}
document.getElementById("qrcode-close").addEventListener("click", () => {
document.getElementById("qrcode-modal").classList.add("hidden");
});
async function deletePeer(peerID) {
if (!confirm("Peer wirklich löschen?")) return;
await apiFetch("/api/server/" + serverID + "/peer/" + peerID, { method: "DELETE" });
loadPeers();
}
document.getElementById("btn-start").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/start", { method: "POST" });
loadServer();
});
document.getElementById("btn-stop").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/stop", { method: "POST" });
loadServer();
});
document.getElementById("btn-reload").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/reload", { method: "POST" });
loadServer();
});
document.getElementById("btn-download").addEventListener("click", () => {
window.location.href = "/api/servers/" + serverID + "/config";
});
document.getElementById("peer-form").addEventListener("submit", async (e) => {
e.preventDefault();
const errP = document.getElementById("peer-error");
errP.textContent = "";
const form = e.target;
const body = {
name: form.name.value,
email: form.email.value,
allowed_ips: form.allowed_ips.value,
persistent_keepalive: parseInt(form.persistent_keepalive.value, 10) || 25,
use_preshared_key: form.use_preshared_key.checked,
};
const res = await apiFetch("/api/server/" + serverID + "/peer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errP.textContent = data.error || "Peer konnte nicht erstellt werden.";
return;
}
form.reset();
form.persistent_keepalive.value = 25;
loadPeers();
});
loadServer();
loadPeers();
+209
View File
@@ -0,0 +1,209 @@
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
color: #1c1c1c;
background: #fafafa;
}
h1, h2 {
color: #222;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
background: #fff;
}
table.details {
width: auto;
min-width: 320px;
}
th, td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e2e2e2;
}
thead th {
background: #f0f0f0;
font-weight: 600;
}
tbody tr:hover {
background: #f7f7f7;
}
button {
cursor: pointer;
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.4rem 0.8rem;
margin: 0.15rem;
font-size: 0.9rem;
}
button:hover {
background: #1d4ed8;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
button.secondary {
background: #6b7280;
}
button.secondary:hover {
background: #4b5563;
}
.actions {
margin: 1rem 0;
}
form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
background: #fff;
padding: 1rem;
border: 1px solid #e2e2e2;
border-radius: 6px;
max-width: 480px;
}
form#login-form {
flex-direction: column;
align-items: stretch;
max-width: 320px;
margin: 3rem auto;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"] {
padding: 0.4rem 0.6rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
}
label {
font-size: 0.9rem;
}
.error {
color: #dc2626;
font-size: 0.9rem;
min-height: 1.2em;
}
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.badge.up {
background: #16a34a;
}
.badge.down {
background: #dc2626;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden {
display: none;
}
.modal-content {
background: #fff;
padding: 1rem;
border-radius: 6px;
text-align: center;
}
.modal-content img {
display: block;
margin-top: 0.5rem;
max-width: 320px;
}
@media (prefers-color-scheme: dark) {
body {
background: #17181a;
color: #e6e6e6;
}
h1, h2 {
color: #f2f2f2;
}
table, form {
background: #212226;
}
thead th {
background: #2a2b30;
}
th, td {
border-bottom: 1px solid #33343a;
}
tbody tr:hover {
background: #26272c;
}
input {
background: #1b1c1f;
color: #e6e6e6;
border: 1px solid #3a3b41;
}
.modal-content {
background: #212226;
}
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>WireGuard Server</h1>
<table id="servers">
<thead>
<tr><th>Name</th><th>Interface</th><th>Port</th><th>Status</th><th>Aktionen</th></tr>
</thead>
<tbody></tbody>
</table>
<button id="new-server">Neuer Server</button>
<script src="/static/app.js"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Login</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<form id="login-form">
<h1>Anmelden</h1>
<input type="text" name="username" placeholder="Benutzername" required>
<input type="password" name="password" placeholder="Passwort" required>
<button type="submit">Login</button>
<p id="login-error" class="error"></p>
</form>
<script src="/static/login.js"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Server</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<p><a href="/">&larr; Zurück zum Dashboard</a></p>
<h1 id="server-name">Server</h1>
<p id="server-error" class="error"></p>
<table class="details">
<tbody>
<tr><th>Interface</th><td id="d-interface"></td></tr>
<tr><th>Port</th><td id="d-port"></td></tr>
<tr><th>Address Range</th><td id="d-address"></td></tr>
<tr><th>DNS</th><td id="d-dns"></td></tr>
<tr><th>MTU</th><td id="d-mtu"></td></tr>
<tr><th>Status</th><td id="d-status"></td></tr>
</tbody>
</table>
<div class="actions">
<button id="btn-start">Start</button>
<button id="btn-stop">Stop</button>
<button id="btn-reload">Neu laden</button>
<button id="btn-download">Config herunterladen</button>
</div>
<h2>Peers</h2>
<table id="peers">
<thead>
<tr><th>Name</th><th>Email</th><th>Allowed IPs</th><th>Aktiv</th><th>Aktionen</th></tr>
</thead>
<tbody></tbody>
</table>
<h2>Neuen Peer hinzufügen</h2>
<form id="peer-form">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email">
<input type="text" name="allowed_ips" placeholder="Allowed IPs, z.B. 10.20.22.5/32" required>
<input type="number" name="persistent_keepalive" placeholder="Persistent Keepalive (s)" value="25">
<label><input type="checkbox" name="use_preshared_key"> Preshared Key verwenden</label>
<button type="submit">Peer hinzufügen</button>
<p id="peer-error" class="error"></p>
</form>
<div id="qrcode-modal" class="modal hidden">
<div class="modal-content">
<button id="qrcode-close">Schließen</button>
<img id="qrcode-img" alt="QR Code">
</div>
</div>
<script src="/static/server.js"></script>
</body>
</html>
+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
}