Files
zfs/internal/server/admin_handlers.go
2026-02-16 02:04:57 +01:00

836 lines
22 KiB
Go

package server
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"time"
"git.ma-al.com/goc_marek/zfs/internal/server/templates/pages"
)
// Admin authentication and management handlers
// generateToken generates a secure random token
func generateToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// AdminLoginRequest represents a login request
type AdminLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// AdminLoginResponse represents a login response
type AdminLoginResponse struct {
Success bool `json:"success"`
Token string `json:"token,omitempty"`
Message string `json:"message,omitempty"`
}
// handleAdminLogin handles admin login
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req AdminLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
admin, err := s.db.GetAdminByUsername(req.Username)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if admin == nil {
json.NewEncoder(w).Encode(AdminLoginResponse{
Success: false,
Message: "Invalid credentials",
})
return
}
// Verify password
if admin.PasswordHash != hashAPIKey(req.Password) {
json.NewEncoder(w).Encode(AdminLoginResponse{
Success: false,
Message: "Invalid credentials",
})
return
}
// Generate session token
token, err := generateToken()
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Create session (valid for 24 hours)
expiresAt := time.Now().Add(24 * time.Hour)
if err := s.db.CreateSession(admin.ID, token, expiresAt); err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: "admin_token",
Value: token,
Path: "/",
Expires: expiresAt,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminLoginResponse{
Success: true,
Token: token,
Message: "Login successful",
})
}
// handleAdminLogout handles admin logout
func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("admin_token")
if err == nil {
s.db.DeleteSession(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "admin_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Logged out successfully",
})
}
// authenticateAdmin checks if the request has a valid admin session
func (s *Server) authenticateAdmin(r *http.Request) (*Admin, error) {
cookie, err := r.Cookie("admin_token")
if err != nil {
return nil, err
}
session, err := s.db.GetSessionByToken(cookie.Value)
if err != nil {
return nil, err
}
if session == nil {
return nil, nil
}
if session.ExpiresAt.Before(time.Now()) {
s.db.DeleteSession(cookie.Value)
return nil, nil
}
admin, err := s.db.GetAdminByID(session.AdminID)
if err != nil {
return nil, err
}
return admin, nil
}
// handleAdminCheck checks if admin is authenticated
func (s *Server) handleAdminCheck(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"authenticated": false,
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"authenticated": true,
"username": admin.Username,
"role": admin.Role,
})
}
// Client management handlers
// handleAdminGetClients returns all clients
func (s *Server) handleAdminGetClients(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
clients, err := s.db.GetAllClients()
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Add usage info for each client
type ClientWithUsage struct {
*ClientConfig
CurrentUsage int64 `json:"current_usage"`
SnapshotCount int `json:"snapshot_count"`
}
var result []ClientWithUsage
for _, c := range clients {
usage, _ := s.db.GetClientUsage(c.ClientID)
snapshots, _ := s.db.GetSnapshotsByClient(c.ClientID)
result = append(result, ClientWithUsage{
ClientConfig: c,
CurrentUsage: usage,
SnapshotCount: len(snapshots),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// handleAdminGetClient returns a specific client
func (s *Server) handleAdminGetClient(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
http.Error(w, "client_id required", http.StatusBadRequest)
return
}
client, err := s.db.GetClient(clientID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if client == nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
usage, _ := s.db.GetClientUsage(clientID)
snapshots, _ := s.db.GetSnapshotsByClient(clientID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"client": client,
"current_usage": usage,
"snapshot_count": len(snapshots),
})
}
// handleAdminCreateClient creates a new client
func (s *Server) handleAdminCreateClient(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ClientID string `json:"client_id"`
APIKey string `json:"api_key"`
MaxSizeBytes int64 `json:"max_size_bytes"`
Dataset string `json:"dataset"`
StorageType string `json:"storage_type"`
Enabled bool `json:"enabled"`
RotationPolicy *RotationPolicy `json:"rotation_policy"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.ClientID == "" || req.APIKey == "" {
http.Error(w, "client_id and api_key required", http.StatusBadRequest)
return
}
if req.StorageType == "" {
req.StorageType = "s3"
}
client := &ClientConfig{
ClientID: req.ClientID,
APIKey: hashAPIKey(req.APIKey),
MaxSizeBytes: req.MaxSizeBytes,
Dataset: req.Dataset,
StorageType: req.StorageType,
Enabled: req.Enabled,
RotationPolicy: req.RotationPolicy,
}
if err := s.db.SaveClient(client); err != nil {
http.Error(w, "Failed to create client", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Client created successfully",
})
}
// handleAdminUpdateClient updates an existing client
func (s *Server) handleAdminUpdateClient(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPut && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ClientID string `json:"client_id"`
APIKey string `json:"api_key"`
MaxSizeBytes int64 `json:"max_size_bytes"`
Dataset string `json:"dataset"`
StorageType string `json:"storage_type"`
Enabled bool `json:"enabled"`
RotationPolicy *RotationPolicy `json:"rotation_policy"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.ClientID == "" {
http.Error(w, "client_id required", http.StatusBadRequest)
return
}
// Get existing client
existing, err := s.db.GetClient(req.ClientID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if existing == nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
// Update fields
if req.APIKey != "" {
existing.APIKey = hashAPIKey(req.APIKey)
}
if req.MaxSizeBytes > 0 {
existing.MaxSizeBytes = req.MaxSizeBytes
}
if req.Dataset != "" {
existing.Dataset = req.Dataset
}
if req.StorageType != "" {
existing.StorageType = req.StorageType
}
existing.Enabled = req.Enabled
if req.RotationPolicy != nil {
existing.RotationPolicy = req.RotationPolicy
}
if err := s.db.SaveClient(existing); err != nil {
http.Error(w, "Failed to update client", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Client updated successfully",
})
}
// handleAdminDeleteClient deletes a client
func (s *Server) handleAdminDeleteClient(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
http.Error(w, "client_id required", http.StatusBadRequest)
return
}
// Delete snapshots first (handled by foreign key cascade)
// Get snapshots to delete from storage
snapshots, _ := s.db.GetSnapshotsByClient(clientID)
for _, snap := range snapshots {
if snap.StorageType == "s3" && s.s3Backend != nil {
s.s3Backend.Delete(context.Background(), snap.StorageKey)
}
}
if err := s.db.DeleteClient(clientID); err != nil {
http.Error(w, "Failed to delete client", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Client deleted successfully",
})
}
// handleAdminGetSnapshots returns all snapshots for a client
func (s *Server) handleAdminGetSnapshots(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
clientID := r.URL.Query().Get("client_id")
var snapshots []*SnapshotMetadata
if clientID != "" {
snapshots, err = s.db.GetSnapshotsByClient(clientID)
} else {
snapshots, err = s.db.GetAllSnapshots()
}
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(snapshots)
}
// handleAdminDeleteSnapshot deletes a specific snapshot
func (s *Server) handleAdminDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clientID := r.URL.Query().Get("client_id")
snapshotID := r.URL.Query().Get("snapshot_id")
if clientID == "" || snapshotID == "" {
http.Error(w, "client_id and snapshot_id required", http.StatusBadRequest)
return
}
// Get snapshot to delete from storage
snap, err := s.db.GetSnapshotByID(clientID, snapshotID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if snap != nil {
if snap.StorageType == "s3" && s.s3Backend != nil {
s.s3Backend.Delete(context.Background(), snap.StorageKey)
} else if snap.StorageType == "local" && s.localBackend != nil {
s.localBackend.Delete(context.Background(), snap.StorageKey)
}
}
if err := s.db.DeleteSnapshot(clientID, snapshotID); err != nil {
http.Error(w, "Failed to delete snapshot", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Snapshot deleted successfully",
})
}
// handleAdminGetStats returns server statistics
func (s *Server) handleAdminGetStats(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
clients, _ := s.db.GetAllClients()
totalSnapshots, _ := s.db.GetTotalSnapshotCount()
totalStorage, _ := s.db.GetTotalStorageUsed()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"client_count": len(clients),
"total_snapshots": totalSnapshots,
"total_storage": totalStorage,
"total_storage_gb": float64(totalStorage) / (1024 * 1024 * 1024),
})
}
// Admin management handlers
// handleAdminGetAdmins returns all admins
func (s *Server) handleAdminGetAdmins(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
admins, err := s.db.GetAllAdmins()
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
// Remove password hashes from response
type AdminResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
var result []AdminResponse
for _, a := range admins {
result = append(result, AdminResponse{
ID: a.ID,
Username: a.Username,
Role: a.Role,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// handleAdminCreateAdmin creates a new admin
func (s *Server) handleAdminCreateAdmin(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
http.Error(w, "username and password required", http.StatusBadRequest)
return
}
if req.Role == "" {
req.Role = "admin"
}
if err := s.db.CreateAdmin(req.Username, hashAPIKey(req.Password), req.Role); err != nil {
http.Error(w, "Failed to create admin (username may already exist)", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Admin created successfully",
})
}
// handleAdminDeleteAdmin deletes an admin
func (s *Server) handleAdminDeleteAdmin(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "id required", http.StatusBadRequest)
return
}
// Prevent deleting yourself
var targetAdminID int
fmt.Sscanf(id, "%d", &targetAdminID)
if admin != nil && targetAdminID == admin.ID {
http.Error(w, "Cannot delete yourself", http.StatusBadRequest)
return
}
if err := s.db.DeleteAdmin(targetAdminID); err != nil {
http.Error(w, "Failed to delete admin", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Admin deleted successfully",
})
}
// handleAdminChangePassword handles admin password change
func (s *Server) handleAdminChangePassword(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID int `json:"id"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Password == "" {
http.Error(w, "Password required", http.StatusBadRequest)
return
}
if err := s.db.UpdateAdminPassword(req.ID, hashAPIKey(req.Password)); err != nil {
http.Error(w, "Failed to update password", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Password changed successfully",
})
}
// handleAdminUI serves the admin panel UI
func (s *Server) handleAdminUI(w http.ResponseWriter, r *http.Request) {
// Check if admin is authenticated
admin, _ := s.authenticateAdmin(r)
if admin != nil {
// Serve the admin panel using templ
w.Header().Set("Content-Type", "text/html; charset=utf-8")
pages.AdminPage(admin.Username).Render(r.Context(), w)
} else {
// Serve the login page using templ
w.Header().Set("Content-Type", "text/html; charset=utf-8")
pages.LoginPage().Render(r.Context(), w)
}
}
// handleAdminStatic serves static files for the admin panel
func (s *Server) handleAdminStatic(w http.ResponseWriter, r *http.Request) {
// Extract the file path from the URL
// URL format: /admin/static/<filename>
path := r.URL.Path[len("/admin/static/"):]
// Get the embedded filesystem
staticFS, err := GetStaticFS()
if err != nil {
http.Error(w, "Failed to load static files", http.StatusInternalServerError)
return
}
// Serve the file
content, err := fs.ReadFile(staticFS, path)
if err != nil {
http.NotFound(w, r)
return
}
// Set content type based on file extension
switch {
case len(path) >= 3 && path[len(path)-3:] == ".js":
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
case len(path) >= 4 && path[len(path)-4:] == ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Write(content)
}
// handleAdminResetClientPassword resets a client's API key to a new random value
func (s *Server) handleAdminResetClientPassword(w http.ResponseWriter, r *http.Request) {
admin, err := s.authenticateAdmin(r)
if err != nil || admin == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clientID := r.URL.Query().Get("client_id")
if clientID == "" {
http.Error(w, "client_id required", http.StatusBadRequest)
return
}
// Get existing client
client, err := s.db.GetClient(clientID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if client == nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
// Generate new random API key
newAPIKey, err := generateToken()
if err != nil {
http.Error(w, "Failed to generate new API key", http.StatusInternalServerError)
return
}
// Update client with new API key
client.APIKey = hashAPIKey(newAPIKey)
if err := s.db.SaveClient(client); err != nil {
http.Error(w, "Failed to update client", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Client password reset successfully",
"api_key": newAPIKey, // Return the new key (only shown once!)
})
}
// handleClientChangePassword allows a client to change its own API key
func (s *Server) handleClientChangePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ClientID string `json:"client_id"`
CurrentKey string `json:"current_key"`
NewKey string `json:"new_key"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.ClientID == "" || req.CurrentKey == "" || req.NewKey == "" {
http.Error(w, "client_id, current_key, and new_key required", http.StatusBadRequest)
return
}
// Authenticate with current key
if !s.authenticate(req.ClientID, req.CurrentKey) {
http.Error(w, "Invalid current API key", http.StatusUnauthorized)
return
}
// Get client
client, err := s.db.GetClient(req.ClientID)
if err != nil || client == nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
// Update with new key
client.APIKey = hashAPIKey(req.NewKey)
if err := s.db.SaveClient(client); err != nil {
http.Error(w, "Failed to update password", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Password changed successfully",
})
}