diff --git a/internal/api/router.go b/internal/api/router.go index 988f026..5920ec4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -41,12 +41,13 @@ type Router struct { monitor *monitoring.Monitor alertHandlers *AlertHandlers configHandlers *ConfigHandlers - notificationHandlers *NotificationHandlers + notificationHandlers *NotificationHandlers notificationQueueHandlers *NotificationQueueHandlers - dockerAgentHandlers *DockerAgentHandlers - hostAgentHandlers *HostAgentHandlers - systemSettingsHandler *SystemSettingsHandler - wsHub *websocket.Hub + dockerAgentHandlers *DockerAgentHandlers + hostAgentHandlers *HostAgentHandlers + temperatureProxyHandlers *TemperatureProxyHandlers + systemSettingsHandler *SystemSettingsHandler + wsHub *websocket.Hub reloadFunc func() error updateManager *updates.Manager exportLimiter *RateLimiter @@ -153,6 +154,7 @@ func (r *Router) setupRoutes() { updateHandlers := NewUpdateHandlers(r.updateManager, r.config.DataPath) r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub) r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub) + r.temperatureProxyHandlers = NewTemperatureProxyHandlers(r.persistence) // API routes r.mux.HandleFunc("/api/health", r.handleHealth) @@ -162,6 +164,8 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup))) r.mux.HandleFunc("/api/agents/host/", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleDeleteHost))) + r.mux.HandleFunc("/api/temperature-proxy/register", r.temperatureProxyHandlers.HandleRegister) + r.mux.HandleFunc("/api/temperature-proxy/unregister", RequireAdmin(r.config, r.temperatureProxyHandlers.HandleUnregister)) r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) r.mux.HandleFunc("/api/version", r.handleVersion) diff --git a/internal/api/temperature_proxy.go b/internal/api/temperature_proxy.go new file mode 100644 index 0000000..1ae1227 --- /dev/null +++ b/internal/api/temperature_proxy.go @@ -0,0 +1,226 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rs/zerolog/log" +) + +// TemperatureProxyHandlers manages temperature proxy registration +type TemperatureProxyHandlers struct { + persistence *config.ConfigPersistence +} + +// NewTemperatureProxyHandlers constructs a new handler set for temperature proxy +func NewTemperatureProxyHandlers(persistence *config.ConfigPersistence) *TemperatureProxyHandlers { + return &TemperatureProxyHandlers{persistence: persistence} +} + +// HandleRegister handles temperature proxy registration from the installer +// +// POST /api/temperature-proxy/register +// Body: {"hostname": "pve1", "proxy_url": "https://pve1.lan:8443"} +// Response: {"success": true, "token": "...", "pve_instance": "pve1"} +func (h *TemperatureProxyHandlers) HandleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil) + return + } + + defer r.Body.Close() + + var req struct { + Hostname string `json:"hostname"` + ProxyURL string `json:"proxy_url"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()}) + return + } + + // Validate inputs + hostname := strings.TrimSpace(req.Hostname) + proxyURL := strings.TrimSpace(req.ProxyURL) + + if hostname == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_hostname", "Hostname is required", nil) + return + } + + if proxyURL == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_proxy_url", "Proxy URL is required", nil) + return + } + + // Validate proxy URL format + if !strings.HasPrefix(proxyURL, "https://") { + writeErrorResponse(w, http.StatusBadRequest, "invalid_proxy_url", "Proxy URL must use HTTPS", nil) + return + } + + // Load current config + nodesConfig, err := h.persistence.LoadNodesConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "config_load_failed", "Failed to load configuration", map[string]string{"error": err.Error()}) + return + } + + // Find matching PVE instance by hostname + var matchedInstance *config.PVEInstance + var matchedIndex int + + for i := range nodesConfig.PVEInstances { + instance := &nodesConfig.PVEInstances[i] + + // Try to match by hostname in the Host field or cluster endpoints + if strings.Contains(strings.ToLower(instance.Host), strings.ToLower(hostname)) { + matchedInstance = instance + matchedIndex = i + break + } + + // Check cluster endpoints + if instance.IsCluster { + for _, ep := range instance.ClusterEndpoints { + if strings.EqualFold(ep.NodeName, hostname) || strings.Contains(strings.ToLower(ep.Host), strings.ToLower(hostname)) { + matchedInstance = instance + matchedIndex = i + break + } + } + } + } + + if matchedInstance == nil { + writeErrorResponse(w, http.StatusNotFound, "pve_instance_not_found", fmt.Sprintf("No PVE instance configured for hostname '%s'. Add the instance in Pulse first.", hostname), nil) + return + } + + // Generate a secure random token + token, err := generateSecureToken(32) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate authentication token", map[string]string{"error": err.Error()}) + return + } + + // Update the instance with proxy configuration + nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = proxyURL + nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = token + + // Save updated configuration + if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "config_save_failed", "Failed to save configuration", map[string]string{"error": err.Error()}) + return + } + + log.Info(). + Str("hostname", hostname). + Str("proxy_url", proxyURL). + Str("pve_instance", matchedInstance.Name). + Msg("Temperature proxy registered successfully") + + resp := map[string]any{ + "success": true, + "token": token, + "pve_instance": matchedInstance.Name, + "message": fmt.Sprintf("Temperature proxy registered for instance '%s'", matchedInstance.Name), + } + + if err := utils.WriteJSONResponse(w, resp); err != nil { + log.Error().Err(err).Msg("Failed to serialize temperature proxy registration response") + } +} + +// HandleUnregister removes temperature proxy configuration from a PVE instance +// +// DELETE /api/temperature-proxy/unregister?hostname=pve1 +func (h *TemperatureProxyHandlers) HandleUnregister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only DELETE is allowed", nil) + return + } + + hostname := strings.TrimSpace(r.URL.Query().Get("hostname")) + if hostname == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_hostname", "Hostname query parameter is required", nil) + return + } + + // Load current config + nodesConfig, err := h.persistence.LoadNodesConfig() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "config_load_failed", "Failed to load configuration", map[string]string{"error": err.Error()}) + return + } + + // Find matching PVE instance + var matchedIndex = -1 + var matchedName string + + for i := range nodesConfig.PVEInstances { + instance := &nodesConfig.PVEInstances[i] + + if strings.Contains(strings.ToLower(instance.Host), strings.ToLower(hostname)) { + matchedIndex = i + matchedName = instance.Name + break + } + + if instance.IsCluster { + for _, ep := range instance.ClusterEndpoints { + if strings.EqualFold(ep.NodeName, hostname) || strings.Contains(strings.ToLower(ep.Host), strings.ToLower(hostname)) { + matchedIndex = i + matchedName = instance.Name + break + } + } + } + } + + if matchedIndex == -1 { + writeErrorResponse(w, http.StatusNotFound, "pve_instance_not_found", fmt.Sprintf("No PVE instance found for hostname '%s'", hostname), nil) + return + } + + // Clear proxy configuration + nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = "" + nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = "" + + // Save updated configuration + if err := h.persistence.SaveNodesConfig(nodesConfig.PVEInstances, nodesConfig.PBSInstances, nodesConfig.PMGInstances); err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "config_save_failed", "Failed to save configuration", map[string]string{"error": err.Error()}) + return + } + + log.Info(). + Str("hostname", hostname). + Str("pve_instance", matchedName). + Msg("Temperature proxy unregistered") + + resp := map[string]any{ + "success": true, + "pve_instance": matchedName, + "message": "Temperature proxy configuration removed", + } + + if err := utils.WriteJSONResponse(w, resp); err != nil { + log.Error().Err(err).Msg("Failed to serialize temperature proxy unregistration response") + } +} + +// generateSecureToken creates a cryptographically secure random token +func generateSecureToken(byteLength int) (string, error) { + bytes := make([]byte, byteLength) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +}