Pulse/internal/api/temperature_proxy.go
rcourtman 0c06bee737 Add Pulse API endpoints for temperature proxy registration
Implements REST API endpoints to enable automatic registration of
temperature proxies during sensor-proxy installation.

API endpoints:
- POST /api/temperature-proxy/register
  - Accepts: hostname, proxy_url
  - Returns: authentication token
  - Finds matching PVE instance and configures proxy URL/token
  - No authentication required (called during installation)

- DELETE /api/temperature-proxy/unregister?hostname=X
  - Removes proxy configuration from PVE instance
  - Requires admin authentication

Implementation:
- Uses config.ConfigPersistence for loading/saving nodes.enc
- Matches PVE instances by hostname in Host field or ClusterEndpoints
- Generates cryptographically secure random tokens (32 bytes, base64)
- Atomic config updates (load → modify → save)

Next step: Update install-sensor-proxy.sh to call registration API

Related to #571
2025-11-13 16:20:47 +00:00

226 lines
7.3 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 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
}