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