package api import ( "bufio" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "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 { svc := r.monitor.GetDiscoveryService() result, updated := svc.GetCachedResult() isScanning := svc.IsScanning() response := map[string]interface{}{ "servers": []interface{}{}, "errors": []string{}, "scanning": isScanning, } if result != nil { response["servers"] = result.Servers if result.Errors != nil { response["errors"] = result.Errors } response["updated"] = updated.Unix() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) return } // Return empty result if no discovery service w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "servers": []interface{}{}, "errors": []string{}, "scanning": false, }) } // setupRoutes configures all routes func (r *Router) setupRoutes() { // Create handlers alertHandlers := NewAlertHandlers(r.monitor) notificationHandlers := NewNotificationHandlers(r.monitor) guestMetadataHandler := NewGuestMetadataHandler(r.config.DataPath) configHandlers := NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, guestMetadataHandler) updateHandlers := NewUpdateHandlers(r.updateManager) // 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/logout", r.handleLogout) 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 if auth is globally disabled if r.config.DisableAuth { json.NewEncoder(w).Encode(map[string]interface{}{ "configured": false, "disabled": true, "message": "Authentication is disabled via DISABLE_AUTH environment variable", }) return } // Check for basic auth configuration // Check both environment variables and loaded config hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" || os.Getenv("REQUIRE_AUTH") == "true" || r.config.AuthUser != "" || r.config.AuthPass != "" // Check if .env file exists but hasn't been loaded yet (pending restart) configuredButPendingRestart := false envPath := filepath.Join(r.config.ConfigPath, ".env") if envPath == "" || r.config.ConfigPath == "" { envPath = "/etc/pulse/.env" } // If no auth is currently active but .env exists, security is pending restart if !hasAuthentication && r.config.AuthUser == "" && r.config.AuthPass == "" { if _, err := os.Stat(envPath); err == nil { // .env exists but auth not loaded - pending restart configuredButPendingRestart = 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) // Create token hint if token exists var apiTokenHint string if r.config.APIToken != "" && len(r.config.APIToken) >= 8 { apiTokenHint = r.config.APIToken[:4] + "..." + r.config.APIToken[len(r.config.APIToken)-4:] } status := map[string]interface{}{ "apiTokenConfigured": r.config.APIToken != "", "apiTokenHint": apiTokenHint, "requiresAuth": r.config.APIToken != "", "exportProtected": r.config.APIToken != "" || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true", "unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true", "hasAuthentication": hasAuthentication, "configuredButPendingRestart": configuredButPendingRestart, "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 - using fixed version r.mux.HandleFunc("/api/security/quick-setup", handleQuickSecuritySetupFixed(r)) // API token regeneration endpoint r.mux.HandleFunc("/api/security/regenerate-token", r.HandleRegenerateAPIToken) // 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 with full service restart to pick up new config go func() { time.Sleep(2 * time.Second) log.Info().Msg("Triggering restart to apply security settings") // We need to do a full systemctl restart to pick up new environment variables // First try daemon-reload cmd := exec.Command("sudo", "-n", "systemctl", "daemon-reload") if err := cmd.Run(); err != nil { log.Error().Err(err).Msg("Failed to reload systemd daemon") } // Then restart the service - this will kill us and restart with new env time.Sleep(500 * time.Millisecond) cmd = exec.Command("sudo", "-n", "systemctl", "restart", "pulse-backend") if err := cmd.Run(); err != nil { log.Error().Err(err).Msg("Failed to restart service, falling back to exit") // Fallback to exit if restart fails os.Exit(0) } // If restart succeeds, we'll be killed by systemctl }() 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 authentication) r.mux.HandleFunc("/api/config/export", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodPost { // Check authentication - accept either session auth or API token hasValidSession := false if cookie, err := req.Cookie("pulse_session"); err == nil && cookie.Value != "" { hasValidSession = ValidateSession(cookie.Value) } hasValidAPIToken := false if r.config.APIToken != "" { authHeader := req.Header.Get("X-API-Token") // Check if stored token is hashed or plain text if auth.IsAPITokenHashed(r.config.APIToken) { // Compare against hash hasValidAPIToken = auth.CompareAPIToken(authHeader, r.config.APIToken) } else { // Plain text comparison (legacy) hasValidAPIToken = (authHeader == r.config.APIToken) } } // If password auth is configured, session auth is sufficient if r.config.AuthUser != "" && r.config.AuthPass != "" { if !hasValidSession && !hasValidAPIToken { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). Msg("Unauthorized export attempt - no valid session or API token") http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) return } } else if r.config.APIToken != "" { // API token configured but no password auth - require API token if !hasValidAPIToken { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). Msg("Unauthorized export attempt - invalid API token") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } else { // No auth configured - check if this is a homelab/private network clientIP := utils.GetClientIP(req.RemoteAddr, req.Header.Get("X-Forwarded-For"), req.Header.Get("X-Real-IP")) isPrivate := utils.IsPrivateIP(clientIP) allowUnprotected := os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true" if !isPrivate && !allowUnprotected { // Public network access without auth - definitely block log.Warn(). Str("ip", req.RemoteAddr). Bool("private_network", isPrivate). Msg("Export blocked - public network requires authentication") http.Error(w, "Export requires authentication on public networks", http.StatusForbidden) return } else if isPrivate && !allowUnprotected { // Private network but ALLOW_UNPROTECTED_EXPORT not set - show helpful message log.Info(). Str("ip", req.RemoteAddr). Msg("Export allowed - private network with no auth") // Continue - allow export on private networks for homelab users } } // Log successful export attempt log.Info(). Str("ip", req.RemoteAddr). Bool("session_auth", hasValidSession). Bool("api_token_auth", hasValidAPIToken). 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 authentication - accept either session auth or API token hasValidSession := false if cookie, err := req.Cookie("pulse_session"); err == nil && cookie.Value != "" { hasValidSession = ValidateSession(cookie.Value) } hasValidAPIToken := false if r.config.APIToken != "" { authHeader := req.Header.Get("X-API-Token") // Check if stored token is hashed or plain text if auth.IsAPITokenHashed(r.config.APIToken) { // Compare against hash hasValidAPIToken = auth.CompareAPIToken(authHeader, r.config.APIToken) } else { // Plain text comparison (legacy) hasValidAPIToken = (authHeader == r.config.APIToken) } } // If password auth is configured, session auth is sufficient if r.config.AuthUser != "" && r.config.AuthPass != "" { if !hasValidSession && !hasValidAPIToken { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). Msg("Unauthorized import attempt - no valid session or API token") http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) return } } else if r.config.APIToken != "" { // API token configured but no password auth - require API token if !hasValidAPIToken { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). Msg("Unauthorized import attempt - invalid API token") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } } else { // No auth configured - check if this is a homelab/private network clientIP := utils.GetClientIP(req.RemoteAddr, req.Header.Get("X-Forwarded-For"), req.Header.Get("X-Real-IP")) isPrivate := utils.IsPrivateIP(clientIP) allowUnprotected := os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true" if !isPrivate && !allowUnprotected { // Public network access without auth - definitely block log.Warn(). Str("ip", req.RemoteAddr). Bool("private_network", isPrivate). Msg("Import blocked - public network requires authentication") http.Error(w, "Import requires authentication on public networks", http.StatusForbidden) return } else if isPrivate && !allowUnprotected { // Private network but ALLOW_UNPROTECTED_EXPORT not set - show helpful message log.Info(). Str("ip", req.RemoteAddr). Msg("Import allowed - private network with no auth") // Continue - allow import on private networks for homelab users } } // Log successful import attempt log.Info(). Str("ip", req.RemoteAddr). Bool("session_auth", hasValidSession). Bool("api_token_auth", hasValidAPIToken). 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) // Old API token endpoints removed - now using /api/security/regenerate-token // 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) // Note: Frontend handler is handled manually in ServeHTTP to prevent redirect issues // See issue #334 - ServeMux redirects empty path to "./" which breaks reverse proxies } // 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 } // Check if we need authentication needsAuth := true // Check if auth is globally disabled if r.config.DisableAuth { needsAuth = false w.Header().Set("X-Auth-Disabled", "true") } // 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 needsAuth = false } } if needsAuth { // Normal authentication check // Skip auth for certain public endpoints and static assets publicPaths := []string{ "/api/health", "/api/security/status", "/api/version", "/api/login", // Add login endpoint as public } // Also allow static assets without auth (JS, CSS, etc) // These MUST be accessible for the login page to work isStaticAsset := strings.HasPrefix(req.URL.Path, "/assets/") || req.URL.Path == "/" || req.URL.Path == "/index.html" || req.URL.Path == "/favicon.ico" || req.URL.Path == "/logo.svg" || strings.HasSuffix(req.URL.Path, ".js") || strings.HasSuffix(req.URL.Path, ".css") || strings.HasSuffix(req.URL.Path, ".map") 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 } // Special case: quick-setup should be accessible to check if already configured // The handler itself will verify if setup should be skipped if req.URL.Path == "/api/security/quick-setup" && req.Method == http.MethodPost { isPublic = true } // Check auth for protected routes (only if auth is needed) if needsAuth && !isPublic && !CheckAuth(r.config, w, req) { // Never send WWW-Authenticate - use custom login page // For API requests, return JSON if strings.HasPrefix(req.URL.Path, "/api/") || strings.Contains(req.Header.Get("Accept"), "application/json") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"Authentication required"}`)) } else { 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 // Only skip CSRF for initial setup when no auth is configured skipCSRF := false if (req.URL.Path == "/api/security/quick-setup" || req.URL.Path == "/api/security/apply-restart") && r.config.AuthUser == "" && r.config.AuthPass == "" { // Only skip CSRF for initial setup and restart when no auth exists skipCSRF = true } // Skip CSRF for setup-script-url endpoint (generates temporary tokens, not a state change) if req.URL.Path == "/api/setup-script-url" { skipCSRF = true } if strings.HasPrefix(req.URL.Path, "/api/") && !skipCSRF && !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 ONLY for real-time data endpoints skipRateLimit := false for _, path := range []string{ "/api/state", // WebSocket updates "/api/guests/metadata", // Guest metadata (polled frequently) "/api/health", // Health checks "/ws", // WebSocket } { if strings.Contains(req.URL.Path, path) { skipRateLimit = true break } } // Apply stricter rate limiting for auth endpoints (but not status checks) if (strings.Contains(req.URL.Path, "/api/security/") && req.URL.Path != "/api/security/status") || req.URL.Path == "/api/login" { clientIP := GetClientIP(req) // Use auth limiter for security endpoints (10 per minute) if !authLimiter.Allow(clientIP) { http.Error(w, "Too many requests. Please wait before trying again.", http.StatusTooManyRequests) LogAuditEvent("rate_limit", "", clientIP, req.URL.Path, false, "Auth rate limit exceeded") return } } else if !skipRateLimit { // Use general API limiter for other endpoints (500 per minute) clientIP := GetClientIP(req) if !apiLimiter.Allow(clientIP) { http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) return } } } // Log request start := time.Now() // Fix for issue #334: Custom routing to prevent ServeMux's "./" redirect // When accessing without trailing slash, ServeMux redirects to "./" which is wrong // We handle routing manually to avoid this issue // Check if this is an API or WebSocket route if strings.HasPrefix(req.URL.Path, "/api/") || strings.HasPrefix(req.URL.Path, "/ws") || strings.HasPrefix(req.URL.Path, "/socket.io/") || req.URL.Path == "/simple-stats" { // Use the mux for API and special routes r.mux.ServeHTTP(w, req) } else { // Serve frontend for all other paths (including root) handler := serveFrontendHandler() handler(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 } response := HealthResponse{ Status: "healthy", Timestamp: time.Now().Unix(), Uptime: time.Since(r.monitor.GetStartTime()).Seconds(), } utils.WriteJSONResponse(w, response) } // 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 complexity if err := auth.ValidatePasswordComplexity(changeReq.NewPassword); err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_password", err.Error(), nil) return } // Verify current password matches authHeader := req.Header.Get("Authorization") if authHeader == "" { writeErrorResponse(w, http.StatusUnauthorized, "unauthorized", "Current password required", nil) return } // Hash the new password before storing hashedPassword, err := auth.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 } // Check if we're running in Docker isDocker := os.Getenv("PULSE_DOCKER") == "true" if isDocker { // For Docker, update the .env file in the data directory envPath := filepath.Join(r.config.ConfigPath, ".env") // Read existing .env file to preserve other settings envContent := "" existingContent, err := os.ReadFile(envPath) if err == nil { // Parse existing content and update password scanner := bufio.NewScanner(strings.NewReader(string(existingContent))) for scanner.Scan() { line := scanner.Text() // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { envContent += line + "\n" continue } // Update password line, keep others if strings.HasPrefix(line, "PULSE_AUTH_PASS=") { envContent += fmt.Sprintf("PULSE_AUTH_PASS='%s'\n", hashedPassword) } else { envContent += line + "\n" } } } else { // Create new .env file if it doesn't exist envContent = fmt.Sprintf(`# Auto-generated by Pulse password change # Generated on %s PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' `, time.Now().Format(time.RFC3339), r.config.AuthUser, hashedPassword) // Include API token if configured if r.config.APIToken != "" { envContent += fmt.Sprintf("API_TOKEN='%s'\n", r.config.APIToken) } } // Write the updated .env file if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { log.Error().Err(err).Str("path", envPath).Msg("Failed to write .env file") writeErrorResponse(w, http.StatusInternalServerError, "config_error", "Failed to save new password", nil) return } // Update the running config r.config.AuthPass = hashedPassword log.Info().Msg("Password changed successfully in Docker environment") // Invalidate all sessions InvalidateUserSessions(r.config.AuthUser) // Audit log LogAuditEvent("password_change", r.config.AuthUser, GetClientIP(req), req.URL.Path, true, "Password changed (Docker)") // Return success with Docker-specific message w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Password changed successfully. Please restart your Docker container to apply changes.", }) } else { // For non-Docker (systemd/manual), save to .env file envPath := filepath.Join(r.config.ConfigPath, ".env") if r.config.ConfigPath == "" { envPath = "/etc/pulse/.env" } // Read existing .env file to preserve other settings envContent := "" existingContent, err := os.ReadFile(envPath) if err == nil { // Parse and update existing content scanner := bufio.NewScanner(strings.NewReader(string(existingContent))) for scanner.Scan() { line := scanner.Text() if line == "" || strings.HasPrefix(line, "#") { envContent += line + "\n" continue } // Update password line, keep others if strings.HasPrefix(line, "PULSE_AUTH_PASS=") { envContent += fmt.Sprintf("PULSE_AUTH_PASS='%s'\n", hashedPassword) } else { envContent += line + "\n" } } } else { // Create new .env if doesn't exist envContent = fmt.Sprintf(`# Auto-generated by Pulse password change # Generated on %s PULSE_AUTH_USER='%s' PULSE_AUTH_PASS='%s' `, time.Now().Format(time.RFC3339), r.config.AuthUser, hashedPassword) if r.config.APIToken != "" { envContent += fmt.Sprintf("API_TOKEN='%s'\n", r.config.APIToken) } } // Try to write the .env file if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { log.Error().Err(err).Str("path", envPath).Msg("Failed to write .env file") writeErrorResponse(w, http.StatusInternalServerError, "config_error", "Failed to save new password. You may need to update the password manually.", nil) return } // Update the running config r.config.AuthPass = hashedPassword log.Info().Msg("Password changed successfully") // Invalidate all sessions InvalidateUserSessions(r.config.AuthUser) // Audit log LogAuditEvent("password_change", r.config.AuthUser, GetClientIP(req), req.URL.Path, true, "Password changed") // Detect service name for restart instructions serviceName := "pulse" if _, err := os.Stat("/etc/systemd/system/pulse-backend.service"); err == nil { serviceName = "pulse-backend" } // Return success with manual restart instructions w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": fmt.Sprintf("Password changed. Restart the service to apply: sudo systemctl restart %s", serviceName), "requiresRestart": true, "serviceName": serviceName, }) } } // handleLogout handles logout requests func (r *Router) handleLogout(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 } // Get session token from cookie var sessionToken string if cookie, err := req.Cookie("pulse_session"); err == nil { sessionToken = cookie.Value } // Delete the session if it exists if sessionToken != "" { sessionMu.Lock() delete(sessions, sessionToken) sessionMu.Unlock() // Also delete CSRF token if exists csrfMu.Lock() delete(csrfTokens, sessionToken) csrfMu.Unlock() } // Get appropriate cookie settings based on proxy detection (consistent with login) isSecure, sameSitePolicy := getCookieSettings(req) // Clear the session cookie http.SetCookie(w, &http.Cookie{ Name: "pulse_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: isSecure, SameSite: sameSitePolicy, }) // Audit log logout (use admin as username since we have single user for now) LogAuditEvent("logout", "admin", GetClientIP(req), req.URL.Path, true, "User logged out") log.Info(). Str("user", "admin"). Str("ip", GetClientIP(req)). Msg("User logged out") // Return success w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Successfully logged out", }) } // 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) unless auth is disabled if !r.config.DisableAuth && !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") response := VersionResponse{ Version: strings.TrimSpace(string(versionBytes)), BuildTime: "development", GoVersion: runtime.Version(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) return } // Convert to typed response response := VersionResponse{ Version: versionInfo.Version, BuildTime: versionInfo.Build, GoVersion: runtime.Version(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // 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]VMChartData) nodeData := make(map[string]NodeChartData) 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(VMChartData) } // 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([]MetricPoint, len(points)) for i, point := range points { ts := point.Timestamp.Unix() * 1000 if ts < oldestTimestamp { oldestTimestamp = ts } chartData[vm.ID][metricType][i] = MetricPoint{ Timestamp: ts, Value: point.Value, } } } // If no historical data, add current value if len(chartData[vm.ID]["cpu"]) == 0 { chartData[vm.ID]["cpu"] = []MetricPoint{ {Timestamp: currentTime, Value: vm.CPU * 100}, } chartData[vm.ID]["memory"] = []MetricPoint{ {Timestamp: currentTime, Value: vm.Memory.Usage}, } chartData[vm.ID]["disk"] = []MetricPoint{ {Timestamp: currentTime, Value: vm.Disk.Usage}, } chartData[vm.ID]["diskread"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(vm.DiskRead)}, } chartData[vm.ID]["diskwrite"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(vm.DiskWrite)}, } chartData[vm.ID]["netin"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(vm.NetworkIn)}, } chartData[vm.ID]["netout"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(vm.NetworkOut)}, } } } // Process Containers - get historical data for _, ct := range state.Containers { if chartData[ct.ID] == nil { chartData[ct.ID] = make(VMChartData) } // 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([]MetricPoint, len(points)) for i, point := range points { ts := point.Timestamp.Unix() * 1000 if ts < oldestTimestamp { oldestTimestamp = ts } chartData[ct.ID][metricType][i] = MetricPoint{ Timestamp: ts, Value: point.Value, } } } // If no historical data, add current value if len(chartData[ct.ID]["cpu"]) == 0 { chartData[ct.ID]["cpu"] = []MetricPoint{ {Timestamp: currentTime, Value: ct.CPU * 100}, } chartData[ct.ID]["memory"] = []MetricPoint{ {Timestamp: currentTime, Value: ct.Memory.Usage}, } chartData[ct.ID]["disk"] = []MetricPoint{ {Timestamp: currentTime, Value: ct.Disk.Usage}, } chartData[ct.ID]["diskread"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(ct.DiskRead)}, } chartData[ct.ID]["diskwrite"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(ct.DiskWrite)}, } chartData[ct.ID]["netin"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(ct.NetworkIn)}, } chartData[ct.ID]["netout"] = []MetricPoint{ {Timestamp: currentTime, Value: float64(ct.NetworkOut)}, } } } // Process Storage - get historical data storageData := make(map[string]StorageChartData) for _, storage := range state.Storage { if storageData[storage.ID] == nil { storageData[storage.ID] = make(StorageChartData) } // 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([]MetricPoint, len(usagePoints)) for i, point := range usagePoints { ts := point.Timestamp.Unix() * 1000 if ts < oldestTimestamp { oldestTimestamp = ts } storageData[storage.ID]["disk"][i] = MetricPoint{ 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"] = []MetricPoint{ {Timestamp: currentTime, Value: usagePercent}, } } } // Process Nodes - get historical data for _, node := range state.Nodes { if nodeData[node.ID] == nil { nodeData[node.ID] = make(NodeChartData) } // 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([]MetricPoint, len(points)) for i, point := range points { ts := point.Timestamp.Unix() * 1000 if ts < oldestTimestamp { oldestTimestamp = ts } nodeData[node.ID][metricType][i] = MetricPoint{ 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] = []MetricPoint{ {Timestamp: currentTime, Value: value}, } } } } response := ChartResponse{ ChartData: chartData, NodeData: nodeData, StorageData: storageData, Timestamp: currentTime, Stats: ChartStats{ 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(StorageChartsResponse) for _, storage := range state.Storage { metrics := r.monitor.GetStorageMetrics(storage.ID, duration) storageData[storage.ID] = StorageMetrics{ 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 := ` Simple Pulse Stats

Simple Pulse Stats

Connecting...

Containers

Name Status CPU % Memory Disk Read Disk Write Net In Net Out
` 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") } }