Pulse/internal/api/temperature_proxy.go
rcourtman aa357e5013 Fix HTTP mode for pulse-sensor-proxy and improve installer safety
## HTTP Server Fixes
- Add source IP middleware to enforce allowed_source_subnets
- Fix missing source subnet validation for external HTTP requests
- HTTP health endpoint now respects subnet restrictions

## Installer Improvements
- Auto-configure allowed_source_subnets with Pulse server IP
- Add cluster node hostnames to allowed_nodes (not just IPs)
- Fix node validation to accept both hostnames and IPs
- Add Pulse server reachability check before installation
- Add port availability check for HTTP mode
- Add automatic rollback on service startup failure
- Add HTTP endpoint health check after installation
- Fix config backup and deduplication (prevent duplicate keys)
- Fix IPv4 validation with loopback rejection
- Improve registration retry logic with detailed errors
- Add automatic LXC bind mount cleanup on uninstall

## Temperature Collection Fixes
- Add local temperature collection for self-monitoring nodes
- Fix node identifier matching (use hostname not SSH host)
- Fix JSON double-encoding in HTTP client response

Related to #XXX (temperature monitoring fixes)
2025-11-13 18:22:36 +00:00

233 lines
7.4 KiB
Go

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 instance name
if strings.EqualFold(instance.Name, hostname) {
matchedInstance = instance
matchedIndex = i
break
}
// 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
}