Pulse/internal/api/temperature_proxy.go
2025-11-15 22:14:32 +00:00

487 lines
14 KiB
Go

package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"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
syncMu sync.RWMutex
syncStatus map[string]proxySyncState
}
type proxySyncState struct {
Instance string
LastPull time.Time
RefreshInterval int
}
const defaultProxyAllowlistRefreshSeconds = 60
type authorizedNode struct {
Name string `json:"name,omitempty"`
IP string `json:"ip,omitempty"`
}
// NewTemperatureProxyHandlers constructs a new handler set for temperature proxy
func NewTemperatureProxyHandlers(persistence *config.ConfigPersistence) *TemperatureProxyHandlers {
return &TemperatureProxyHandlers{
persistence: persistence,
syncStatus: make(map[string]proxySyncState),
}
}
func (h *TemperatureProxyHandlers) recordSync(instance string, refreshSeconds int) {
if h == nil {
return
}
instance = strings.TrimSpace(instance)
if instance == "" {
return
}
if refreshSeconds <= 0 {
refreshSeconds = defaultProxyAllowlistRefreshSeconds
}
h.syncMu.Lock()
defer h.syncMu.Unlock()
key := strings.ToLower(instance)
h.syncStatus[key] = proxySyncState{
Instance: instance,
LastPull: time.Now(),
RefreshInterval: refreshSeconds,
}
}
func (h *TemperatureProxyHandlers) SnapshotSyncStatus() map[string]proxySyncState {
if h == nil {
return nil
}
h.syncMu.RLock()
defer h.syncMu.RUnlock()
if len(h.syncStatus) == 0 {
return nil
}
copy := make(map[string]proxySyncState, len(h.syncStatus))
for key, state := range h.syncStatus {
copy[key] = state
}
return copy
}
func extractHostPart(raw string) string {
host := strings.TrimSpace(raw)
if host == "" {
return ""
}
if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") {
if parsed, err := url.Parse(host); err == nil {
return parsed.Hostname()
}
}
if idx := strings.Index(host, "/"); idx != -1 {
host = host[:idx]
}
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
return strings.TrimSpace(host)
}
func buildAuthorizedNodeList(instance *config.PVEInstance) []authorizedNode {
if instance == nil {
return nil
}
nodes := make([]authorizedNode, 0)
seen := make(map[string]struct{})
add := func(name, ip string) {
name = strings.TrimSpace(name)
ip = strings.TrimSpace(ip)
if name == "" && ip == "" {
return
}
key := name + "|" + ip
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
nodes = append(nodes, authorizedNode{Name: name, IP: ip})
}
// Base instance host/name
add(instance.Name, extractHostPart(instance.Host))
add(instance.Name, extractHostPart(instance.GuestURL))
if instance.ClusterEndpoints != nil {
for _, ep := range instance.ClusterEndpoints {
name := ep.NodeName
ip := ep.IP
if ip == "" {
ip = extractHostPart(ep.Host)
}
if name == "" {
name = ep.Host
}
add(name, ip)
}
}
return nodes
}
// 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"`
Mode string `json:"mode"`
}
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
}
mode := strings.ToLower(strings.TrimSpace(req.Mode))
if mode == "" {
if proxyURL != "" {
mode = "http"
} else {
mode = "socket"
}
}
isHTTPMode := mode == "http"
if isHTTPMode {
if proxyURL == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_proxy_url", "Proxy URL is required for HTTP mode", nil)
return
}
if !strings.HasPrefix(strings.ToLower(proxyURL), "https://") {
writeErrorResponse(w, http.StatusBadRequest, "invalid_proxy_url", "Proxy URL must use HTTPS", nil)
return
}
} else {
proxyURL = ""
}
// 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 tokens
authToken := ""
if isHTTPMode {
authToken, 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
}
}
ctrlToken, 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
if isHTTPMode {
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = authToken
}
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ctrlToken
// 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("mode", mode).
Str("pve_instance", matchedInstance.Name).
Msg("Temperature proxy registered successfully")
allowed := buildAuthorizedNodeList(matchedInstance)
resp := map[string]any{
"success": true,
"token": authToken,
"control_token": ctrlToken,
"pve_instance": matchedInstance.Name,
"allowed_nodes": allowed,
"refresh_interval": defaultProxyAllowlistRefreshSeconds,
"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")
}
}
// HandleAuthorizedNodes returns the list of nodes Pulse has authorized for a proxy.
//
// GET /api/temperature-proxy/authorized-nodes
// Headers:
//
// X-Proxy-Token: <control-plane token>
func (h *TemperatureProxyHandlers) HandleAuthorizedNodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
token := strings.TrimSpace(r.Header.Get("X-Proxy-Token"))
if token == "" {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = strings.TrimSpace(authHeader[7:])
}
}
if token == "" {
writeErrorResponse(w, http.StatusUnauthorized, "missing_token", "X-Proxy-Token header required", nil)
return
}
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
}
var matched *config.PVEInstance
for i := range nodesConfig.PVEInstances {
inst := &nodesConfig.PVEInstances[i]
switch {
case strings.TrimSpace(inst.TemperatureProxyControlToken) == token:
matched = inst
case inst.TemperatureProxyControlToken == "" && strings.TrimSpace(inst.TemperatureProxyToken) == token:
// Legacy HTTP-mode proxies reuse TemperatureProxyToken
matched = inst
}
if matched != nil {
break
}
}
refreshFromProxy := 0
if hdr := strings.TrimSpace(r.Header.Get("X-Proxy-Refresh")); hdr != "" {
if val, err := strconv.Atoi(hdr); err == nil && val > 0 {
refreshFromProxy = val
}
}
if matched == nil {
writeErrorResponse(w, http.StatusUnauthorized, "invalid_token", "Proxy token not recognized", nil)
return
}
refreshInterval := defaultProxyAllowlistRefreshSeconds
if refreshFromProxy > 0 {
refreshInterval = refreshFromProxy
}
nodes := buildAuthorizedNodeList(matched)
if len(nodes) == 0 {
// Always include at least the base instance name/host
nodes = append(nodes, authorizedNode{Name: matched.Name, IP: extractHostPart(matched.Host)})
}
hashMaterial := make([]string, 0, len(nodes))
for _, node := range nodes {
hashMaterial = append(hashMaterial, fmt.Sprintf("%s|%s", node.Name, node.IP))
}
sort.Strings(hashMaterial)
hashBytes := sha256.Sum256([]byte(strings.Join(hashMaterial, "\n")))
resp := map[string]interface{}{
"instance": matched.Name,
"nodes": nodes,
"hash": hex.EncodeToString(hashBytes[:]),
"refresh_interval": refreshInterval,
"generated_at": time.Now().UTC(),
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to write authorized-nodes response")
} else {
h.recordSync(matched.Name, refreshInterval)
}
}
// 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 = ""
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ""
// 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
}