Files
pulse/internal/api/router.go
T
Pulse Monitor 37e86a7cbc feat: add ability to remove password authentication
New Feature:
- Add "Remove Password" button in Settings → Security tab
- Allows users to disable password authentication completely
- Returns Pulse to open access mode (no auth required)
- Requires current password confirmation for security

Implementation:
- New API endpoint: POST /api/security/remove-password
- New modal component: RemovePasswordModal.tsx
- Removes password from systemd override files
- Clears auth configuration from running instance
- Invalidates all sessions after removal

This addresses the issue where users couldn't disable authentication
once it was enabled. Now they can easily toggle between secured and
open modes as needed for their use case.
2025-08-13 20:39:26 +00:00

1815 lines
59 KiB
Go

package api
import (
base64Pkg "encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
internalauth "github.com/rcourtman/pulse-go-rewrite/internal/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rs/zerolog/log"
)
// Router handles HTTP routing
type Router struct {
mux *http.ServeMux
config *config.Config
monitor *monitoring.Monitor
wsHub *websocket.Hub
reloadFunc func() error
updateManager *updates.Manager
exportLimiter *RateLimiter
persistence *config.ConfigPersistence
}
// NewRouter creates a new router instance
func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket.Hub, reloadFunc func() error) http.Handler {
r := &Router{
mux: http.NewServeMux(),
config: cfg,
monitor: monitor,
wsHub: wsHub,
reloadFunc: reloadFunc,
updateManager: updates.NewManager(cfg),
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
persistence: config.NewConfigPersistence(cfg.DataPath),
}
r.setupRoutes()
// Start forwarding update progress to WebSocket
go r.forwardUpdateProgress()
// Wrap with error handler middleware only
// Note: TimeoutHandler breaks WebSocket upgrades
return ErrorHandler(r)
}
// handleDiscovery returns cached discovery results
func (r *Router) handleDiscovery(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get cached discovery results from the monitor's discovery service
if r.monitor != nil && r.monitor.GetDiscoveryService() != nil {
result, _ := r.monitor.GetDiscoveryService().GetCachedResult()
if result != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
return
}
}
// Return empty result if no discovery service or no cached results
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"servers": []interface{}{},
"errors": []string{},
})
}
// setupRoutes configures all routes
func (r *Router) setupRoutes() {
// Create handlers
alertHandlers := NewAlertHandlers(r.monitor)
notificationHandlers := NewNotificationHandlers(r.monitor)
configHandlers := NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub)
updateHandlers := NewUpdateHandlers(r.updateManager)
guestMetadataHandler := NewGuestMetadataHandler(r.config.DataPath)
// API routes
r.mux.HandleFunc("/api/health", r.handleHealth)
r.mux.HandleFunc("/api/state", r.handleState)
r.mux.HandleFunc("/api/version", r.handleVersion)
r.mux.HandleFunc("/api/storage/", r.handleStorage)
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
r.mux.HandleFunc("/api/charts", r.handleCharts)
r.mux.HandleFunc("/api/diagnostics", r.handleDiagnostics)
r.mux.HandleFunc("/api/config", r.handleConfig)
r.mux.HandleFunc("/api/backups", r.handleBackups)
r.mux.HandleFunc("/api/backups/", r.handleBackups)
r.mux.HandleFunc("/api/backups/unified", r.handleBackups)
r.mux.HandleFunc("/api/backups/pve", r.handleBackupsPVE)
r.mux.HandleFunc("/api/backups/pbs", r.handleBackupsPBS)
r.mux.HandleFunc("/api/snapshots", r.handleSnapshots)
// Guest metadata routes
r.mux.HandleFunc("/api/guests/metadata", guestMetadataHandler.HandleGetMetadata)
r.mux.HandleFunc("/api/guests/metadata/", func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
guestMetadataHandler.HandleGetMetadata(w, req)
case http.MethodPut, http.MethodPost:
guestMetadataHandler.HandleUpdateMetadata(w, req)
case http.MethodDelete:
guestMetadataHandler.HandleDeleteMetadata(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Update routes
r.mux.HandleFunc("/api/updates/check", updateHandlers.HandleCheckUpdates)
r.mux.HandleFunc("/api/updates/apply", updateHandlers.HandleApplyUpdate)
r.mux.HandleFunc("/api/updates/status", updateHandlers.HandleUpdateStatus)
// Config management routes
r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
configHandlers.HandleGetNodes(w, r)
case http.MethodPost:
configHandlers.HandleAddNode(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Test node configuration endpoint (for new nodes)
r.mux.HandleFunc("/api/config/nodes/test-config", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
configHandlers.HandleTestNodeConfig(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Test connection endpoint
r.mux.HandleFunc("/api/config/nodes/test-connection", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
configHandlers.HandleTestConnection(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
r.mux.HandleFunc("/api/config/nodes/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
configHandlers.HandleUpdateNode(w, r)
case http.MethodDelete:
configHandlers.HandleDeleteNode(w, r)
case http.MethodPost:
// Handle test endpoint
if strings.HasSuffix(r.URL.Path, "/test") {
configHandlers.HandleTestNode(w, r)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// System settings routes
r.mux.HandleFunc("/api/config/system", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
configHandlers.HandleGetSystemSettings(w, r)
case http.MethodPut:
configHandlers.HandleUpdateSystemSettings(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Registration token routes removed - feature deprecated
// Security routes
r.mux.HandleFunc("/api/security/change-password", r.handleChangePassword)
r.mux.HandleFunc("/api/security/remove-password", r.handleRemovePassword)
r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
// Check for basic auth configuration
hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" || os.Getenv("REQUIRE_AUTH") == "true"
// Check for audit logging
hasAuditLogging := os.Getenv("PULSE_AUDIT_LOG") == "true" || os.Getenv("AUDIT_LOG_ENABLED") == "true"
// Credentials are always encrypted in current implementation
credentialsEncrypted := true
// Check network context
clientIP := utils.GetClientIP(
req.RemoteAddr,
req.Header.Get("X-Forwarded-For"),
req.Header.Get("X-Real-IP"),
)
isPrivateNetwork := utils.IsPrivateIP(clientIP)
// Get trusted networks from environment
trustedNetworks := []string{}
if nets := os.Getenv("PULSE_TRUSTED_NETWORKS"); nets != "" {
trustedNetworks = strings.Split(nets, ",")
}
isTrustedNetwork := utils.IsTrustedNetwork(clientIP, trustedNetworks)
status := map[string]interface{}{
"apiTokenConfigured": r.config.APIToken != "",
"requiresAuth": r.config.APIToken != "",
"exportProtected": r.config.APIToken != "" || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true",
"unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true",
"hasAuthentication": hasAuthentication,
"hasAuditLogging": hasAuditLogging,
"credentialsEncrypted": credentialsEncrypted,
"hasHTTPS": req.TLS != nil,
"clientIP": clientIP,
"isPrivateNetwork": isPrivateNetwork,
"isTrustedNetwork": isTrustedNetwork,
"publicAccess": !isPrivateNetwork,
}
json.NewEncoder(w).Encode(status)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Quick security setup route
r.mux.HandleFunc("/api/security/quick-setup", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Parse request body
var setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
APIToken string `json:"apiToken"`
}
if err := json.NewDecoder(req.Body).Decode(&setupRequest); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate inputs
if setupRequest.Username == "" || setupRequest.Password == "" || setupRequest.APIToken == "" {
http.Error(w, "Username, password, and API token are required", http.StatusBadRequest)
return
}
// Check if we're running under systemd
isSystemd := os.Getenv("INVOCATION_ID") != ""
isDocker := os.Getenv("PULSE_DOCKER") == "true"
if isSystemd {
// We're running under systemd but may not have root permissions
// Write config to Pulse's data directory and provide a one-liner to apply it
configPath := filepath.Join(r.config.DataPath, "security-override.conf")
scriptPath := filepath.Join(r.config.DataPath, "apply-security.sh")
// Create override content
overrideContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
# Generated on %s
[Service]
Environment="PULSE_AUTH_USER=%s"
Environment="PULSE_AUTH_PASS=%s"
Environment="API_TOKEN=%s"
Environment="ENABLE_AUDIT_LOG=true"
`, time.Now().Format(time.RFC3339), setupRequest.Username, setupRequest.Password, setupRequest.APIToken)
// Write override file to data directory
if err := os.WriteFile(configPath, []byte(overrideContent), 0644); err != nil {
log.Error().Err(err).Msg("Failed to write security config")
http.Error(w, "Failed to write security configuration", http.StatusInternalServerError)
return
}
// Create apply script
scriptContent := fmt.Sprintf(`#!/bin/bash
# Auto-generated script to apply Pulse security settings
echo "Applying security settings to Pulse..."
sudo mkdir -p /etc/systemd/system/pulse-backend.service.d/
sudo cp %s /etc/systemd/system/pulse-backend.service.d/override.conf
sudo systemctl daemon-reload
sudo systemctl restart pulse-backend
echo "Security enabled! Pulse is restarting..."
echo "You will need to log in with your saved credentials."
`, configPath)
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
log.Error().Err(err).Msg("Failed to write apply script")
}
// Try to apply the override file
// First, try with sudo (will work if user has passwordless sudo)
if err := utils.RunCommand("sudo", "mkdir", "-p", "/etc/systemd/system/pulse-backend.service.d/"); err == nil {
if err := utils.RunCommand("sudo", "cp", configPath, "/etc/systemd/system/pulse-backend.service.d/override.conf"); err == nil {
// Reload systemd config
utils.RunCommand("sudo", "systemctl", "daemon-reload")
// Don't auto-restart - let the user do it after saving credentials
response := map[string]interface{}{
"success": true,
"method": "systemd",
"automatic": true,
"readyToRestart": true,
"message": "Security configured! Save your credentials, then restart Pulse to apply settings.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
}
// If automatic didn't work, provide manual command
response := map[string]interface{}{
"success": true,
"method": "systemd",
"automatic": false,
"configPath": configPath,
"scriptPath": scriptPath,
"command": fmt.Sprintf("sudo bash %s", scriptPath),
"message": "Security configured! Run the command shown to apply settings.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else if isDocker {
// For Docker, we can't modify the running container
// But we can save settings and provide docker run command
response := map[string]interface{}{
"success": true,
"method": "docker",
"requiresManualRestart": true,
"message": "Security configuration generated. Restart your Docker container with the environment variables shown.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else {
// Development or manual installation
// Save to .env file for next restart
envPath := filepath.Join(r.config.ConfigPath, ".env")
envContent := fmt.Sprintf(`# Auto-generated by Pulse Quick Security Setup
PULSE_AUTH_USER=%s
PULSE_AUTH_PASS=%s
API_TOKEN=%s
ENABLE_AUDIT_LOG=true
`, setupRequest.Username, setupRequest.Password, setupRequest.APIToken)
if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil {
log.Error().Err(err).Msg("Failed to write .env file")
}
response := map[string]interface{}{
"success": true,
"method": "manual",
"envFile": envPath,
"message": "Security configuration saved. Restart Pulse to apply settings.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Apply security restart endpoint
r.mux.HandleFunc("/api/security/apply-restart", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Only allow restart if we're running under systemd (safer)
isSystemd := os.Getenv("INVOCATION_ID") != ""
if !isSystemd {
response := map[string]interface{}{
"success": false,
"message": "Automatic restart is only available when running under systemd. Please restart Pulse manually.",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
// Write a recovery flag file before restarting
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
recoveryContent := fmt.Sprintf("Auth setup at %s\nIf locked out, delete this file and restart to disable auth temporarily\n", time.Now().Format(time.RFC3339))
os.WriteFile(recoveryFile, []byte(recoveryContent), 0600)
// Schedule restart
go func() {
time.Sleep(2 * time.Second)
log.Info().Msg("Restarting to apply security settings (systemd will handle restart)")
// Exit cleanly - systemd will restart us
os.Exit(0)
}()
response := map[string]interface{}{
"success": true,
"message": "Restarting Pulse to apply security settings...",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Recovery endpoint - only accessible from localhost
r.mux.HandleFunc("/api/security/recovery", func(w http.ResponseWriter, req *http.Request) {
// Only allow from localhost
ip := strings.Split(req.RemoteAddr, ":")[0]
if ip != "127.0.0.1" && ip != "::1" && ip != "localhost" {
http.Error(w, "Recovery endpoint only accessible from localhost", http.StatusForbidden)
return
}
if req.Method == http.MethodPost {
// Parse action
var recoveryRequest struct {
Action string `json:"action"`
}
if err := json.NewDecoder(req.Body).Decode(&recoveryRequest); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
response := map[string]interface{}{}
switch recoveryRequest.Action {
case "disable_auth":
// Temporarily disable auth by creating recovery file
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
content := fmt.Sprintf("Recovery mode enabled at %s\nAuth temporarily disabled for local access\n", time.Now().Format(time.RFC3339))
if err := os.WriteFile(recoveryFile, []byte(content), 0600); err != nil {
response["success"] = false
response["message"] = fmt.Sprintf("Failed to enable recovery mode: %v", err)
} else {
response["success"] = true
response["message"] = "Recovery mode enabled. Auth disabled for localhost. Delete .auth_recovery file to re-enable."
log.Warn().Msg("AUTH RECOVERY: Authentication disabled for localhost via recovery endpoint")
}
case "enable_auth":
// Re-enable auth by removing recovery file
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
if err := os.Remove(recoveryFile); err != nil {
response["success"] = false
response["message"] = fmt.Sprintf("Failed to disable recovery mode: %v", err)
} else {
response["success"] = true
response["message"] = "Recovery mode disabled. Authentication re-enabled."
log.Info().Msg("AUTH RECOVERY: Authentication re-enabled via recovery endpoint")
}
default:
response["success"] = false
response["message"] = "Invalid action. Use 'disable_auth' or 'enable_auth'"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else if req.Method == http.MethodGet {
// Check recovery status
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
_, err := os.Stat(recoveryFile)
response := map[string]interface{}{
"recovery_mode": err == nil,
"message": "Recovery endpoint accessible from localhost only",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Config export/import routes (requires API token for security)
r.mux.HandleFunc("/api/config/export", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Check for API token if configured
if r.config.APIToken != "" {
authHeader := req.Header.Get("X-API-Token")
if authHeader != r.config.APIToken {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Msg("Unauthorized export attempt")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
} else if os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true" {
// If no API token and unprotected export not explicitly allowed
log.Warn().
Str("ip", req.RemoteAddr).
Msg("Export blocked - API token required")
http.Error(w, "Export requires API_TOKEN to be set (or set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden)
return
}
// Log successful export attempt
log.Info().
Str("ip", req.RemoteAddr).
Bool("authenticated", r.config.APIToken != "").
Msg("Configuration export initiated")
configHandlers.HandleExportConfig(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
r.mux.HandleFunc("/api/config/import", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Check for API token if configured
if r.config.APIToken != "" {
authHeader := req.Header.Get("X-API-Token")
if authHeader != r.config.APIToken {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Msg("Unauthorized import attempt")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
} else if os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true" {
// If no API token and unprotected import not explicitly allowed
log.Warn().
Str("ip", req.RemoteAddr).
Msg("Import blocked - API token required")
http.Error(w, "Import requires API_TOKEN to be set (or set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden)
return
}
// Log successful import attempt
log.Info().
Str("ip", req.RemoteAddr).
Bool("authenticated", r.config.APIToken != "").
Msg("Configuration import initiated")
configHandlers.HandleImportConfig(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
// Discovery route
// Setup script route
r.mux.HandleFunc("/api/setup-script", configHandlers.HandleSetupScript)
// Generate setup script URL with temporary token (for authenticated users)
r.mux.HandleFunc("/api/setup-script-url", configHandlers.HandleSetupScriptURL)
// Auto-register route for setup scripts
r.mux.HandleFunc("/api/auto-register", configHandlers.HandleAutoRegister)
// Discovery endpoint
r.mux.HandleFunc("/api/discover", r.handleDiscovery)
// Test endpoint for WebSocket notifications
r.mux.HandleFunc("/api/test-notification", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Send a test auto-registration notification
r.wsHub.BroadcastMessage(websocket.Message{
Type: "node_auto_registered",
Data: map[string]interface{}{
"type": "pve",
"host": "test-node.example.com",
"name": "Test Node",
"tokenId": "test-token",
"hasToken": true,
},
Timestamp: time.Now().Format(time.RFC3339),
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "notification sent"})
})
// Alert routes
r.mux.HandleFunc("/api/alerts/", alertHandlers.HandleAlerts)
// Notification routes
r.mux.HandleFunc("/api/notifications/", notificationHandlers.HandleNotifications)
// Settings routes
r.mux.HandleFunc("/api/settings", getSettings)
r.mux.HandleFunc("/api/settings/update", updateSettings)
// System settings and API token management
systemSettingsHandler := NewSystemSettingsHandler(r.config, r.persistence)
r.mux.HandleFunc("/api/system/settings", systemSettingsHandler.HandleGetSystemSettings)
r.mux.HandleFunc("/api/system/settings/update", systemSettingsHandler.HandleUpdateSystemSettings)
r.mux.HandleFunc("/api/system/api-token", systemSettingsHandler.HandleGetAPIToken)
r.mux.HandleFunc("/api/system/api-token/generate", systemSettingsHandler.HandleGenerateAPIToken)
r.mux.HandleFunc("/api/system/api-token/delete", systemSettingsHandler.HandleDeleteAPIToken)
// WebSocket endpoint
r.mux.HandleFunc("/ws", r.handleWebSocket)
// Socket.io compatibility endpoints
r.mux.HandleFunc("/socket.io/", r.handleSocketIO)
// Simple stats page
r.mux.HandleFunc("/simple-stats", r.handleSimpleStats)
// Serve embedded frontend
log.Info().Msg("Serving embedded frontend")
r.mux.Handle("/", serveFrontendHandler())
}
// ServeHTTP implements http.Handler
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Apply security headers first
SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Add CORS headers if configured
if r.config.AllowedOrigins != "" {
w.Header().Set("Access-Control-Allow-Origin", r.config.AllowedOrigins)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Token, X-CSRF-Token")
}
// Handle preflight requests
if req.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Recovery mechanism: Check if recovery mode is enabled
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
if _, err := os.Stat(recoveryFile); err == nil {
// Recovery mode is enabled - allow local access only
ip := strings.Split(req.RemoteAddr, ":")[0]
log.Debug().
Str("recovery_file", recoveryFile).
Str("remote_ip", ip).
Str("path", req.URL.Path).
Bool("file_exists", err == nil).
Msg("Checking auth recovery mode")
if ip == "127.0.0.1" || ip == "::1" || ip == "localhost" {
log.Warn().
Str("recovery_file", recoveryFile).
Msg("AUTH RECOVERY MODE: Allowing local access without authentication")
// Allow access but add a warning header
w.Header().Set("X-Auth-Recovery", "true")
// Recovery mode bypasses auth for localhost
} else {
// Non-local access in recovery mode - still require auth
if !CheckAuth(r.config, w, req) {
// Only send WWW-Authenticate for non-API requests
isAPIRequest := strings.HasPrefix(req.URL.Path, "/api/") ||
req.Header.Get("X-Requested-With") == "XMLHttpRequest" ||
strings.Contains(req.Header.Get("Accept"), "application/json")
if r.config.AuthUser != "" && r.config.AuthPass != "" && !isAPIRequest {
w.Header().Set("WWW-Authenticate", `Basic realm="Pulse"`)
}
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
}
} else {
// Normal authentication check
// Skip auth for certain public endpoints and static assets
publicPaths := []string{
"/api/health",
"/api/security/status",
"/api/version",
}
// Also allow static assets without auth (JS, CSS, etc)
isStaticAsset := strings.HasPrefix(req.URL.Path, "/assets/") ||
req.URL.Path == "/" ||
req.URL.Path == "/index.html" ||
req.URL.Path == "/logo.svg" ||
strings.HasSuffix(req.URL.Path, ".js") ||
strings.HasSuffix(req.URL.Path, ".css") ||
strings.HasSuffix(req.URL.Path, ".ico")
isPublic := isStaticAsset
for _, path := range publicPaths {
if req.URL.Path == path {
isPublic = true
break
}
}
// Special case: setup-script with a token parameter should be allowed
if req.URL.Path == "/api/setup-script" && req.URL.Query().Get("token") != "" {
// Let the handler validate the token
isPublic = true
}
// Check auth for protected routes
if !isPublic && !CheckAuth(r.config, w, req) {
// Only send WWW-Authenticate for non-API requests
isAPIRequest := strings.HasPrefix(req.URL.Path, "/api/") ||
req.Header.Get("X-Requested-With") == "XMLHttpRequest" ||
strings.Contains(req.Header.Get("Accept"), "application/json")
if r.config.AuthUser != "" && r.config.AuthPass != "" && !isAPIRequest {
w.Header().Set("WWW-Authenticate", `Basic realm="Pulse"`)
}
http.Error(w, "Authentication required", http.StatusUnauthorized)
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Msg("Unauthorized access attempt")
return
}
}
// Check CSRF for state-changing requests
// CSRF is only needed when using session-based auth
if strings.HasPrefix(req.URL.Path, "/api/") && !CheckCSRF(w, req) {
http.Error(w, "CSRF token validation failed", http.StatusForbidden)
LogAuditEvent("csrf_failure", "", GetClientIP(req), req.URL.Path, false, "Invalid CSRF token")
return
}
// Apply rate limiting for API endpoints
if strings.HasPrefix(req.URL.Path, "/api/") {
// Skip rate limiting for certain high-frequency endpoints
skipRateLimit := false
for _, path := range []string{
"/api/state", // WebSocket updates
"/api/guests/metadata", // Guest metadata (many requests)
"/api/health", // Health checks
"/ws", // WebSocket
} {
if strings.Contains(req.URL.Path, path) {
skipRateLimit = true
break
}
}
if !skipRateLimit {
clientIP := GetClientIP(req)
if !apiLimiter.Allow(clientIP) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
}
}
// Log request
start := time.Now()
r.mux.ServeHTTP(w, req)
log.Debug().
Str("method", req.Method).
Str("path", req.URL.Path).
Dur("duration", time.Since(start)).
Msg("Request handled")
})).ServeHTTP(w, req)
}
// handleHealth handles health check requests
func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
health := map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().Unix(),
"uptime": time.Since(r.monitor.GetStartTime()).Seconds(),
}
utils.WriteJSONResponse(w, health)
}
// handleChangePassword handles password change requests
func (r *Router) handleChangePassword(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
"Only POST method is allowed", nil)
return
}
// Parse request
var changeReq struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := json.NewDecoder(req.Body).Decode(&changeReq); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request",
"Invalid request body", nil)
return
}
// Validate new password
if len(changeReq.NewPassword) < 8 {
writeErrorResponse(w, http.StatusBadRequest, "invalid_password",
"Password must be at least 8 characters", nil)
return
}
// Verify current password matches
auth := req.Header.Get("Authorization")
if auth == "" {
writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
"Current password required", nil)
return
}
// Hash the new password
hashedPassword, err := internalauth.HashPassword(changeReq.NewPassword)
if err != nil {
log.Error().Err(err).Msg("Failed to hash new password")
writeErrorResponse(w, http.StatusInternalServerError, "hash_error",
"Failed to process new password", nil)
return
}
// Update the systemd override file
overridePath := "/etc/systemd/system/pulse-backend.service.d/override.conf"
content, err := os.ReadFile(overridePath)
if err != nil {
log.Error().Err(err).Str("file", overridePath).Msg("Failed to read override file")
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
"Failed to update configuration", nil)
return
}
// Replace the password line
lines := strings.Split(string(content), "\n")
for i, line := range lines {
if strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") {
lines[i] = fmt.Sprintf("Environment=\"PULSE_AUTH_PASS=%s\"", hashedPassword)
break
}
}
// Write back the file
newContent := strings.Join(lines, "\n")
if err := os.WriteFile(overridePath, []byte(newContent), 0600); err != nil {
log.Error().Err(err).Str("file", overridePath).Msg("Failed to write override file")
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
"Failed to save new password", nil)
return
}
// Also update /etc/pulse/security-override.conf if it exists
securityOverridePath := "/etc/pulse/security-override.conf"
if content, err := os.ReadFile(securityOverridePath); err == nil {
lines := strings.Split(string(content), "\n")
for i, line := range lines {
if strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") {
lines[i] = fmt.Sprintf("Environment=\"PULSE_AUTH_PASS=%s\"", hashedPassword)
break
}
}
newContent := strings.Join(lines, "\n")
os.WriteFile(securityOverridePath, []byte(newContent), 0600)
}
log.Info().Msg("Password changed successfully")
// Invalidate all sessions for this user (forces re-login with new password)
InvalidateUserSessions(r.config.AuthUser)
// Audit log password change
LogAuditEvent("password_change", r.config.AuthUser, GetClientIP(req), req.URL.Path, true, "Password changed")
// Return success
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Password changed successfully. Service will restart.",
})
// Trigger service restart in background
go func() {
time.Sleep(1 * time.Second)
// Reload systemd and restart service
exec.Command("systemctl", "daemon-reload").Run()
exec.Command("systemctl", "restart", "pulse-backend").Run()
}()
}
// handleRemovePassword handles password removal requests
func (r *Router) handleRemovePassword(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
"Only POST method is allowed", nil)
return
}
// Parse request
var removeReq struct {
CurrentPassword string `json:"currentPassword"`
}
if err := json.NewDecoder(req.Body).Decode(&removeReq); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request",
"Invalid request body", nil)
return
}
// Verify current password matches
auth := req.Header.Get("Authorization")
if auth == "" {
// Try the provided password
if removeReq.CurrentPassword == "" {
writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
"Current password required", nil)
return
}
// Create basic auth header from provided password
credentials := base64Pkg.StdEncoding.EncodeToString([]byte(r.config.AuthUser + ":" + removeReq.CurrentPassword))
req.Header.Set("Authorization", "Basic "+credentials)
}
// Verify authentication
if !CheckAuth(r.config, nil, req) {
writeErrorResponse(w, http.StatusUnauthorized, "invalid_password",
"Current password is incorrect", nil)
return
}
// For systemd installations, we need to remove the override file
// Check if we're running under systemd
overridePath := "/etc/systemd/system/pulse-backend.service.d/override.conf"
if _, err := os.Stat(overridePath); err == nil {
// Read the override file
content, err := os.ReadFile(overridePath)
if err != nil {
log.Error().Err(err).Msg("Failed to read override file")
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
"Failed to read configuration", nil)
return
}
// Remove the password lines
lines := strings.Split(string(content), "\n")
newLines := []string{}
for _, line := range lines {
if !strings.HasPrefix(line, "Environment=\"PULSE_AUTH_USER=") &&
!strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") &&
!strings.HasPrefix(line, "Environment=\"PULSE_PASSWORD=") {
newLines = append(newLines, line)
}
}
// Write back the file
newContent := strings.Join(newLines, "\n")
if err := os.WriteFile(overridePath, []byte(newContent), 0600); err != nil {
log.Error().Err(err).Msg("Failed to write override file")
writeErrorResponse(w, http.StatusInternalServerError, "config_error",
"Failed to save configuration", nil)
return
}
// Also check /etc/pulse/security-override.conf
securityOverridePath := "/etc/pulse/security-override.conf"
if content, err := os.ReadFile(securityOverridePath); err == nil {
lines := strings.Split(string(content), "\n")
newLines := []string{}
for _, line := range lines {
if !strings.HasPrefix(line, "Environment=\"PULSE_AUTH_USER=") &&
!strings.HasPrefix(line, "Environment=\"PULSE_AUTH_PASS=") &&
!strings.HasPrefix(line, "Environment=\"PULSE_PASSWORD=") {
newLines = append(newLines, line)
}
}
newContent := strings.Join(newLines, "\n")
os.WriteFile(securityOverridePath, []byte(newContent), 0600)
}
}
// Clear password from running config
r.config.AuthUser = ""
r.config.AuthPass = ""
log.Info().Msg("Password authentication removed successfully")
// Invalidate all sessions (forces logout)
InvalidateUserSessions(r.config.AuthUser)
// Audit log password removal
LogAuditEvent("password_removed", r.config.AuthUser, GetClientIP(req), req.URL.Path, true, "Password authentication disabled")
// Return success
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Password authentication removed successfully",
})
}
// handleState handles state requests
func (r *Router) handleState(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
"Only GET method is allowed", nil)
return
}
// Use standard auth check (supports both basic auth and API tokens)
if !CheckAuth(r.config, w, req) {
writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
"Authentication required", nil)
return
}
state := r.monitor.GetState()
if err := utils.WriteJSONResponse(w, state); err != nil {
log.Error().Err(err).Msg("Failed to encode state response")
writeErrorResponse(w, http.StatusInternalServerError, "encoding_error",
"Failed to encode state data", nil)
}
}
// handleVersion handles version requests
func (r *Router) handleVersion(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
versionInfo, err := updates.GetCurrentVersion()
if err != nil {
// Fallback to VERSION file
versionBytes, _ := os.ReadFile("VERSION")
version := map[string]interface{}{
"version": strings.TrimSpace(string(versionBytes)),
"build": "development",
"runtime": "go",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(version)
return
}
// Add update channel from config
versionInfo.Channel = r.config.UpdateChannel
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(versionInfo)
}
// handleStorage handles storage detail requests
func (r *Router) handleStorage(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
"Only GET method is allowed", nil)
return
}
// Extract storage ID from path
path := strings.TrimPrefix(req.URL.Path, "/api/storage/")
if path == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_storage_id",
"Storage ID is required", nil)
return
}
// Get current state
state := r.monitor.GetState()
// Find the storage by ID
var storageDetail *models.Storage
for _, storage := range state.Storage {
if storage.ID == path {
storageDetail = &storage
break
}
}
if storageDetail == nil {
writeErrorResponse(w, http.StatusNotFound, "storage_not_found",
fmt.Sprintf("Storage with ID '%s' not found", path), nil)
return
}
// Return storage details
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"data": storageDetail,
"timestamp": time.Now().Unix(),
}); err != nil {
log.Error().Err(err).Str("storage_id", path).Msg("Failed to encode storage details")
writeErrorResponse(w, http.StatusInternalServerError, "encoding_error",
"Failed to encode response", nil)
}
}
// handleCharts handles chart data requests
func (r *Router) handleCharts(w http.ResponseWriter, req *http.Request) {
log.Debug().Str("method", req.Method).Str("url", req.URL.String()).Msg("Charts endpoint hit")
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get time range from query parameters
query := req.URL.Query()
timeRange := query.Get("range")
if timeRange == "" {
timeRange = "1h"
}
// Convert time range to duration
var duration time.Duration
switch timeRange {
case "5m":
duration = 5 * time.Minute
case "15m":
duration = 15 * time.Minute
case "30m":
duration = 30 * time.Minute
case "1h":
duration = time.Hour
case "4h":
duration = 4 * time.Hour
case "12h":
duration = 12 * time.Hour
case "24h":
duration = 24 * time.Hour
case "7d":
duration = 7 * 24 * time.Hour
default:
duration = time.Hour
}
// Get current state from monitor
state := r.monitor.GetState()
// Create chart data structure that matches frontend expectations
chartData := make(map[string]map[string][]map[string]interface{})
nodeData := make(map[string]map[string][]map[string]interface{})
currentTime := time.Now().Unix() * 1000 // JavaScript timestamp format
oldestTimestamp := currentTime
// Process VMs - get historical data
for _, vm := range state.VMs {
if chartData[vm.ID] == nil {
chartData[vm.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics
metrics := r.monitor.GetGuestMetrics(vm.ID, duration)
// Convert metric points to API format
for metricType, points := range metrics {
chartData[vm.ID][metricType] = make([]map[string]interface{}, len(points))
for i, point := range points {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
chartData[vm.ID][metricType][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
}
// If no historical data, add current value
if len(chartData[vm.ID]["cpu"]) == 0 {
chartData[vm.ID]["cpu"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.CPU * 100},
}
chartData[vm.ID]["memory"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.Memory.Usage},
}
chartData[vm.ID]["disk"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.Disk.Usage},
}
chartData[vm.ID]["diskread"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.DiskRead},
}
chartData[vm.ID]["diskwrite"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.DiskWrite},
}
chartData[vm.ID]["netin"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.NetworkIn},
}
chartData[vm.ID]["netout"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.NetworkOut},
}
}
}
// Process Containers - get historical data
for _, ct := range state.Containers {
if chartData[ct.ID] == nil {
chartData[ct.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics
metrics := r.monitor.GetGuestMetrics(ct.ID, duration)
// Convert metric points to API format
for metricType, points := range metrics {
chartData[ct.ID][metricType] = make([]map[string]interface{}, len(points))
for i, point := range points {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
chartData[ct.ID][metricType][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
}
// If no historical data, add current value
if len(chartData[ct.ID]["cpu"]) == 0 {
chartData[ct.ID]["cpu"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.CPU * 100},
}
chartData[ct.ID]["memory"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.Memory.Usage},
}
chartData[ct.ID]["disk"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.Disk.Usage},
}
chartData[ct.ID]["diskread"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.DiskRead},
}
chartData[ct.ID]["diskwrite"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.DiskWrite},
}
chartData[ct.ID]["netin"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.NetworkIn},
}
chartData[ct.ID]["netout"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.NetworkOut},
}
}
}
// Process Storage - get historical data
storageData := make(map[string]map[string][]map[string]interface{})
for _, storage := range state.Storage {
if storageData[storage.ID] == nil {
storageData[storage.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics
metrics := r.monitor.GetStorageMetrics(storage.ID, duration)
// Convert usage metrics to chart format
if usagePoints, ok := metrics["usage"]; ok && len(usagePoints) > 0 {
// Convert MetricPoint slice to chart format
storageData[storage.ID]["disk"] = make([]map[string]interface{}, len(usagePoints))
for i, point := range usagePoints {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
storageData[storage.ID]["disk"][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
} else {
// Add current value if no historical data
usagePercent := float64(0)
if storage.Total > 0 {
usagePercent = (float64(storage.Used) / float64(storage.Total)) * 100
}
storageData[storage.ID]["disk"] = []map[string]interface{}{
{"timestamp": currentTime, "value": usagePercent},
}
}
}
// Process Nodes - get historical data
for _, node := range state.Nodes {
if nodeData[node.ID] == nil {
nodeData[node.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics for each type
for _, metricType := range []string{"cpu", "memory", "disk"} {
points := r.monitor.GetNodeMetrics(node.ID, metricType, duration)
nodeData[node.ID][metricType] = make([]map[string]interface{}, len(points))
for i, point := range points {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
nodeData[node.ID][metricType][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
// If no historical data, add current value
if len(nodeData[node.ID][metricType]) == 0 {
var value float64
switch metricType {
case "cpu":
value = node.CPU * 100
case "memory":
value = node.Memory.Usage
case "disk":
value = node.Disk.Usage
}
nodeData[node.ID][metricType] = []map[string]interface{}{
{"timestamp": currentTime, "value": value},
}
}
}
}
response := map[string]interface{}{
"data": chartData,
"nodeData": nodeData,
"storageData": storageData,
"timestamp": currentTime,
"stats": map[string]interface{}{
"oldestDataTimestamp": oldestTimestamp,
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Error().Err(err).Msg("Failed to encode chart data response")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Debug().
Int("guests", len(chartData)).
Int("nodes", len(nodeData)).
Int("storage", len(storageData)).
Str("range", timeRange).
Msg("Chart data response sent")
}
// handleStorageCharts handles storage chart data requests
func (r *Router) handleStorageCharts(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse query parameters
query := req.URL.Query()
rangeMinutes := 60 // default 1 hour
if rangeStr := query.Get("range"); rangeStr != "" {
fmt.Sscanf(rangeStr, "%d", &rangeMinutes)
}
duration := time.Duration(rangeMinutes) * time.Minute
state := r.monitor.GetState()
// Build storage chart data
storageData := make(map[string]interface{})
for _, storage := range state.Storage {
metrics := r.monitor.GetStorageMetrics(storage.ID, duration)
storageData[storage.ID] = map[string]interface{}{
"usage": metrics["usage"],
"used": metrics["used"],
"total": metrics["total"],
"avail": metrics["avail"],
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(storageData); err != nil {
log.Error().Err(err).Msg("Failed to encode storage chart data")
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// handleConfig handles configuration requests
func (r *Router) handleConfig(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Return public configuration
config := map[string]interface{}{
"pollingInterval": r.config.PollingInterval.Seconds(),
"csrfProtection": false, // Not implemented yet
"autoUpdateEnabled": r.config.AutoUpdateEnabled,
"updateChannel": r.config.UpdateChannel,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
// handleBackups handles backup requests
func (r *Router) handleBackups(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get current state
state := r.monitor.GetState()
// Return backup data structure
backups := map[string]interface{}{
"backupTasks": state.PVEBackups.BackupTasks,
"storageBackups": state.PVEBackups.StorageBackups,
"guestSnapshots": state.PVEBackups.GuestSnapshots,
"pbsBackups": state.PBSBackups,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backups)
}
// handleBackupsPVE handles PVE backup requests
func (r *Router) handleBackupsPVE(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get state and extract PVE backups
state := r.monitor.GetState()
// Return PVE backup data in expected format
backups := state.PVEBackups.StorageBackups
if backups == nil {
backups = []models.StorageBackup{}
}
pveBackups := map[string]interface{}{
"backups": backups,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pveBackups); err != nil {
log.Error().Err(err).Msg("Failed to encode PVE backups response")
// Return empty array as fallback
w.Write([]byte(`{"backups":[]}`))
}
}
// handleBackupsPBS handles PBS backup requests
func (r *Router) handleBackupsPBS(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get state and extract PBS backups
state := r.monitor.GetState()
// Return PBS backup data in expected format
instances := state.PBSInstances
if instances == nil {
instances = []models.PBSInstance{}
}
pbsData := map[string]interface{}{
"instances": instances,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pbsData); err != nil {
log.Error().Err(err).Msg("Failed to encode PBS response")
// Return empty array as fallback
w.Write([]byte(`{"instances":[]}`))
}
}
// handleSnapshots handles snapshot requests
func (r *Router) handleSnapshots(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get state and extract guest snapshots
state := r.monitor.GetState()
// Return snapshot data
snaps := state.PVEBackups.GuestSnapshots
if snaps == nil {
snaps = []models.GuestSnapshot{}
}
snapshots := map[string]interface{}{
"snapshots": snaps,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(snapshots); err != nil {
log.Error().Err(err).Msg("Failed to encode snapshots response")
// Return empty array as fallback
w.Write([]byte(`{"snapshots":[]}`))
}
}
// handleWebSocket handles WebSocket connections
func (r *Router) handleWebSocket(w http.ResponseWriter, req *http.Request) {
r.wsHub.HandleWebSocket(w, req)
}
// handleSimpleStats serves a simple stats page
func (r *Router) handleSimpleStats(w http.ResponseWriter, req *http.Request) {
html := `<!DOCTYPE html>
<html>
<head>
<title>Simple Pulse Stats</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #333;
color: white;
font-weight: bold;
position: sticky;
top: 0;
}
tr:hover {
background: #f5f5f5;
}
.status {
padding: 4px 8px;
border-radius: 4px;
color: white;
font-size: 12px;
}
.running { background: #28a745; }
.stopped { background: #dc3545; }
#status {
margin-bottom: 20px;
padding: 10px;
background: #e9ecef;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.update-indicator {
display: inline-block;
width: 10px;
height: 10px;
background: #28a745;
border-radius: 50%;
animation: pulse 0.5s ease-out;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
.update-timer {
font-family: monospace;
font-size: 14px;
color: #666;
}
.metric {
font-family: monospace;
text-align: right;
}
</style>
</head>
<body>
<h1>Simple Pulse Stats</h1>
<div id="status">
<div>
<span id="status-text">Connecting...</span>
<span class="update-indicator" id="update-indicator" style="display:none"></span>
</div>
<div class="update-timer" id="update-timer"></div>
</div>
<h2>Containers</h2>
<table id="containers">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>CPU %</th>
<th>Memory</th>
<th>Disk Read</th>
<th>Disk Write</th>
<th>Net In</th>
<th>Net Out</th>
</tr>
</thead>
<tbody></tbody>
</table>
<script>
let ws;
let lastUpdateTime = null;
let updateCount = 0;
let updateInterval = null;
function formatBytes(bytes) {
if (!bytes || bytes < 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return value.toFixed(1) + ' ' + units[i];
}
function formatMemory(used, total) {
const usedGB = (used / 1024 / 1024 / 1024).toFixed(1);
const totalGB = (total / 1024 / 1024 / 1024).toFixed(1);
const percent = ((used / total) * 100).toFixed(0);
return usedGB + '/' + totalGB + ' GB (' + percent + '%)';
}
function updateTable(containers) {
const tbody = document.querySelector('#containers tbody');
tbody.innerHTML = '';
containers.sort((a, b) => a.name.localeCompare(b.name));
containers.forEach(ct => {
const row = document.createElement('tr');
row.innerHTML =
'<td><strong>' + ct.name + '</strong></td>' +
'<td><span class="status ' + ct.status + '">' + ct.status + '</span></td>' +
'<td class="metric">' + (ct.cpu ? ct.cpu.toFixed(1) : '0.0') + '%</td>' +
'<td class="metric">' + formatMemory(ct.mem || 0, ct.maxmem || 1) + '</td>' +
'<td class="metric">' + formatBytes(ct.diskread) + '</td>' +
'<td class="metric">' + formatBytes(ct.diskwrite) + '</td>' +
'<td class="metric">' + formatBytes(ct.netin) + '</td>' +
'<td class="metric">' + formatBytes(ct.netout) + '</td>';
tbody.appendChild(row);
});
}
function updateTimer() {
if (lastUpdateTime) {
const secondsSince = Math.floor((Date.now() - lastUpdateTime) / 1000);
document.getElementById('update-timer').textContent = 'Next update in: ' + (2 - (secondsSince % 2)) + 's';
}
}
function connect() {
const statusText = document.getElementById('status-text');
const indicator = document.getElementById('update-indicator');
statusText.textContent = 'Connecting to WebSocket...';
ws = new WebSocket('ws://' + window.location.host + '/ws');
ws.onopen = function() {
statusText.textContent = 'Connected! Updates every 2 seconds';
console.log('WebSocket connected');
// Start the countdown timer
if (updateInterval) clearInterval(updateInterval);
updateInterval = setInterval(updateTimer, 100);
};
ws.onmessage = function(event) {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'initialState' || msg.type === 'rawData') {
if (msg.data && msg.data.containers) {
updateCount++;
lastUpdateTime = Date.now();
// Show update indicator with animation
indicator.style.display = 'inline-block';
indicator.style.animation = 'none';
setTimeout(() => {
indicator.style.animation = 'pulse 0.5s ease-out';
}, 10);
statusText.textContent = 'Update #' + updateCount + ' at ' + new Date().toLocaleTimeString();
updateTable(msg.data.containers);
}
}
} catch (err) {
console.error('Parse error:', err);
}
};
ws.onclose = function(event) {
statusText.textContent = 'Disconnected: ' + event.code + ' ' + event.reason + '. Reconnecting in 3s...';
indicator.style.display = 'none';
if (updateInterval) clearInterval(updateInterval);
setTimeout(connect, 3000);
};
ws.onerror = function(error) {
statusText.textContent = 'Connection error. Retrying...';
console.error('WebSocket error:', error);
};
}
// Start connection
connect();
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
// handleSocketIO handles socket.io requests
func (r *Router) handleSocketIO(w http.ResponseWriter, req *http.Request) {
// For socket.io.js, redirect to CDN
if strings.Contains(req.URL.Path, "socket.io.js") {
http.Redirect(w, req, "https://cdn.socket.io/4.8.1/socket.io.min.js", http.StatusFound)
return
}
// For other socket.io endpoints, use our WebSocket
// This provides basic compatibility
if strings.Contains(req.URL.RawQuery, "transport=websocket") {
r.wsHub.HandleWebSocket(w, req)
return
}
// For polling transport, return proper socket.io response
// Socket.io v4 expects specific format
if strings.Contains(req.URL.RawQuery, "transport=polling") {
if strings.Contains(req.URL.RawQuery, "sid=") {
// Already connected, return empty poll
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte("6"))
} else {
// Initial handshake
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusOK)
// Send open packet with session ID and config
sessionID := fmt.Sprintf("%d", time.Now().UnixNano())
response := fmt.Sprintf(`0{"sid":"%s","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":60000}`, sessionID)
w.Write([]byte(response))
}
return
}
// Default: redirect to WebSocket
http.Redirect(w, req, "/ws", http.StatusFound)
}
// forwardUpdateProgress forwards update progress to WebSocket clients
func (r *Router) forwardUpdateProgress() {
progressChan := r.updateManager.GetProgressChannel()
for status := range progressChan {
// Create update event for WebSocket
message := websocket.Message{
Type: "update:progress",
Data: status,
Timestamp: time.Now().Format(time.RFC3339),
}
// Broadcast to all connected clients
r.wsHub.BroadcastMessage(message)
// Log progress
log.Debug().
Str("status", status.Status).
Int("progress", status.Progress).
Str("message", status.Message).
Msg("Update progress")
}
}