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 { // Initialize persistent session and CSRF stores InitSessionStore(cfg.DataPath) InitCSRFStore(cfg.DataPath) 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() // Load system settings to configure security headers allowEmbedding := false allowedOrigins := "" if systemSettings, err := r.persistence.LoadSystemSettings(); err == nil && systemSettings != nil { allowEmbedding = systemSettings.AllowEmbedding allowedOrigins = systemSettings.AllowedEmbedOrigins } // Apply middleware chain: // 1. Universal rate limiting (outermost to stop attacks early) // 2. Error handling // 3. Security headers with embedding configuration // Note: TimeoutHandler breaks WebSocket upgrades handler := SecurityHeadersWithConfig(r, allowEmbedding, allowedOrigins) handler = ErrorHandler(handler) handler = UniversalRateLimitMiddleware(handler) return handler } // 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", RequireAuth(r.config, 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: // DEPRECATED - use /api/system/settings/update instead configHandlers.HandleUpdateSystemSettingsOLD(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 { // Even with auth disabled, report API token status for API access 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:] } json.NewEncoder(w).Encode(map[string]interface{}{ "configured": false, "disabled": true, "message": "Authentication is disabled via DISABLE_AUTH environment variable", "apiTokenConfigured": r.config.APIToken != "", "apiTokenHint": apiTokenHint, "hasAuthentication": false, }) 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:] } // Check for proxy auth hasProxyAuth := r.config.ProxyAuthSecret != "" proxyAuthUsername := "" proxyAuthIsAdmin := false if hasProxyAuth { // Check if current request has valid proxy auth if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid { proxyAuthUsername = username proxyAuthIsAdmin = isAdmin } } 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, "hasProxyAuth": hasProxyAuth, "proxyAuthLogoutURL": r.config.ProxyAuthLogoutURL, "proxyAuthUsername": proxyAuthUsername, "proxyAuthIsAdmin": proxyAuthIsAdmin, } 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) } }) // Initialize recovery token store InitRecoveryTokenStore(r.config.DataPath) // Recovery endpoint - requires localhost access OR valid recovery token r.mux.HandleFunc("/api/security/recovery", func(w http.ResponseWriter, req *http.Request) { // Get client IP ip := strings.Split(req.RemoteAddr, ":")[0] isLocalhost := ip == "127.0.0.1" || ip == "::1" || ip == "localhost" // Check for recovery token in header recoveryToken := req.Header.Get("X-Recovery-Token") hasValidToken := false if recoveryToken != "" { hasValidToken = GetRecoveryTokenStore().ValidateRecoveryTokenConstantTime(recoveryToken, ip) } // Only allow from localhost OR with valid recovery token if !isLocalhost && !hasValidToken { log.Warn(). Str("ip", ip). Bool("has_token", recoveryToken != ""). Msg("Unauthorized recovery endpoint access attempt") http.Error(w, "Recovery endpoint requires localhost access or valid recovery token", http.StatusForbidden) return } if req.Method == http.MethodPost { // Parse action var recoveryRequest struct { Action string `json:"action"` Duration int `json:"duration,omitempty"` // Duration in minutes for token generation } 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 "generate_token": // Only allow token generation from localhost if !isLocalhost { http.Error(w, "Token generation only allowed from localhost", http.StatusForbidden) return } // Default to 15 minutes if not specified duration := 15 if recoveryRequest.Duration > 0 && recoveryRequest.Duration <= 60 { duration = recoveryRequest.Duration } token, err := GetRecoveryTokenStore().GenerateRecoveryToken(time.Duration(duration) * time.Minute) if err != nil { response["success"] = false response["message"] = fmt.Sprintf("Failed to generate recovery token: %v", err) } else { response["success"] = true response["token"] = token response["expires_in_minutes"] = duration response["message"] = fmt.Sprintf("Recovery token generated. Valid for %d minutes.", duration) log.Warn(). Str("ip", ip). Int("duration_minutes", duration). Msg("Recovery token generated") } 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\nEnabled by: %s\n", time.Now().Format(time.RFC3339), ip) 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(). Str("ip", ip). Bool("via_token", hasValidToken). Msg("AUTH RECOVERY: Authentication disabled 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 proxy auth first hasValidProxyAuth := false if r.config.ProxyAuthSecret != "" { if valid, _, _ := CheckProxyAuth(r.config, req); valid { hasValidProxyAuth = true } } // Check authentication - accept proxy auth, 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) } } // Check if any valid auth method is present hasValidAuth := hasValidProxyAuth || hasValidSession || hasValidAPIToken // Determine if auth is required authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" || r.config.APIToken != "" || r.config.ProxyAuthSecret != "" if authRequired && !hasValidAuth { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). Bool("proxyAuth", hasValidProxyAuth). Bool("session", hasValidSession). Bool("apiToken", hasValidAPIToken). Msg("Unauthorized export attempt") http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) return } else if !authRequired { // 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("proxy_auth", hasValidProxyAuth). 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 proxy auth first hasValidProxyAuth := false if r.config.ProxyAuthSecret != "" { if valid, _, _ := CheckProxyAuth(r.config, req); valid { hasValidProxyAuth = true } } // Check authentication - accept proxy auth, 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) } } // Check if any valid auth method is present hasValidAuth := hasValidProxyAuth || hasValidSession || hasValidAPIToken // Determine if auth is required authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" || r.config.APIToken != "" || r.config.ProxyAuthSecret != "" if authRequired && !hasValidAuth { log.Warn(). Str("ip", req.RemoteAddr). Str("path", req.URL.Path). Bool("proxyAuth", hasValidProxyAuth). Bool("session", hasValidSession). Bool("apiToken", hasValidAPIToken). Msg("Unauthorized import attempt") http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized) return } else if !authRequired { // 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", RequireAuth(r.config, configHandlers.HandleDiscoverServers)) // 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.wsHub, r.monitor) 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) { // Load system settings to get embedding configuration var allowEmbedding bool var allowedEmbedOrigins string if systemSettings, err := r.persistence.LoadSystemSettings(); err == nil && systemSettings != nil { allowEmbedding = systemSettings.AllowEmbedding allowedEmbedOrigins = systemSettings.AllowedEmbedOrigins } // Apply security headers with embedding configuration SecurityHeadersWithConfig(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 // BUT still check for API tokens if provided (for API access when auth is disabled) if r.config.DisableAuth { // Check if an API token was provided providedToken := req.Header.Get("X-API-Token") if providedToken == "" { providedToken = req.URL.Query().Get("token") } // If a valid API token is provided, allow access even with DisableAuth if providedToken != "" && r.config.APIToken != "" { if auth.CompareAPIToken(providedToken, r.config.APIToken) { // Valid API token provided, allow access needsAuth = false w.Header().Set("X-Auth-Method", "api-token") } else { // Invalid API token - reject even with DisableAuth http.Error(w, "Invalid API token", http.StatusUnauthorized) return } } else { // No API token provided with DisableAuth - allow open access 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 should be public (uses setup codes for auth) if req.URL.Path == "/api/setup-script" { // The script itself prompts for a setup code isPublic = true } // Auto-register endpoint needs to be public (validates tokens internally) // BUT the tokens must be generated by authenticated users via setup-script-url if req.URL.Path == "/api/auto-register" { 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") }), allowEmbedding, allowedEmbedOrigins).ServeHTTP(w, req) } // handleHealth handles health check requests func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet && req.Method != http.MethodHead { 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 != "" { GetSessionStore().DeleteSession(sessionToken) // Also delete CSRF token if exists GetCSRFStore().DeleteCSRFToken(sessionToken) } // 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 && req.Method != http.MethodHead { 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") } }