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")
}