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>
485 lines
14 KiB
Go
485 lines
14 KiB
Go
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
|
|
}
|