mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 23:41:48 +01:00
Add http.MaxBytesReader to 8 additional handlers to complete API hardening against memory exhaustion attacks: - guest_metadata.go: HandleUpdateMetadata (16KB) - notification_queue.go: RetryDLQItem, DeleteDLQItem (8KB each) - temperature_proxy.go: HandleRegister (8KB) - host_agents.go: HandleReport (256KB) - updates.go: HandleApplyUpdate (8KB) - docker_metadata.go: HandleUpdateMetadata (16KB) - system_settings.go: UpdateSystemSettings (64KB) All API handlers that decode JSON request bodies now have size limits.
549 lines
16 KiB
Go
549 lines
16 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 {
|
|
config *config.Config
|
|
persistence *config.ConfigPersistence
|
|
reloadFunc func() error
|
|
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(cfg *config.Config, persistence *config.ConfigPersistence, reloadFunc func() error) *TemperatureProxyHandlers {
|
|
return &TemperatureProxyHandlers{
|
|
config: cfg,
|
|
persistence: persistence,
|
|
reloadFunc: reloadFunc,
|
|
syncStatus: make(map[string]proxySyncState),
|
|
}
|
|
}
|
|
|
|
// SetConfig updates the configuration reference used by the handler.
|
|
func (h *TemperatureProxyHandlers) SetConfig(cfg *config.Config) {
|
|
if h != nil {
|
|
h.config = cfg
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// SnapshotSyncStatus returns a copy of the current sync status for all temperature proxies.
|
|
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(instances []config.PVEInstance) []authorizedNode {
|
|
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})
|
|
}
|
|
|
|
for i := range instances {
|
|
instance := &instances[i]
|
|
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
|
|
}
|
|
|
|
// Limit request body to 8KB to prevent memory exhaustion
|
|
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
|
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
|
|
var matchedEndpointIndex = -1 // For cluster nodes, track which endpoint matched
|
|
hostnameLower := strings.ToLower(hostname)
|
|
|
|
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), hostnameLower) {
|
|
matchedInstance = instance
|
|
matchedIndex = i
|
|
break
|
|
}
|
|
|
|
// Check cluster endpoints
|
|
if instance.IsCluster {
|
|
for j, ep := range instance.ClusterEndpoints {
|
|
if strings.EqualFold(ep.NodeName, hostname) || strings.Contains(strings.ToLower(ep.Host), hostnameLower) {
|
|
matchedInstance = instance
|
|
matchedIndex = i
|
|
matchedEndpointIndex = j
|
|
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
|
|
// For clusters, store per-node tokens on the ClusterEndpoint; instance-level token is for non-cluster or fallback
|
|
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL = proxyURL
|
|
if isHTTPMode {
|
|
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken = authToken
|
|
}
|
|
|
|
// For cluster nodes, store token on the specific endpoint so each node has its own token
|
|
if matchedEndpointIndex >= 0 && matchedInstance.IsCluster {
|
|
nodesConfig.PVEInstances[matchedIndex].ClusterEndpoints[matchedEndpointIndex].TemperatureProxyControlToken = ctrlToken
|
|
log.Debug().
|
|
Str("hostname", hostname).
|
|
Int("endpoint_index", matchedEndpointIndex).
|
|
Msg("Storing control token on cluster endpoint")
|
|
} else {
|
|
// Non-cluster instance or matched by instance name directly
|
|
nodesConfig.PVEInstances[matchedIndex].TemperatureProxyControlToken = ctrlToken
|
|
}
|
|
|
|
// Save updated configuration
|
|
log.Debug().
|
|
Int("matchedIndex", matchedIndex).
|
|
Int("matchedEndpointIndex", matchedEndpointIndex).
|
|
Str("saving_url", nodesConfig.PVEInstances[matchedIndex].TemperatureProxyURL).
|
|
Bool("saving_has_token", nodesConfig.PVEInstances[matchedIndex].TemperatureProxyToken != "").
|
|
Str("instance_name", nodesConfig.PVEInstances[matchedIndex].Name).
|
|
Msg("About to save nodes config with proxy registration")
|
|
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
|
|
}
|
|
|
|
// Reload the entire config to ensure all components (router, monitor, handlers) get the fresh config
|
|
// This prevents the monitor from later overwriting nodes.enc with stale data
|
|
if h.reloadFunc != nil {
|
|
if err := h.reloadFunc(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload config after temperature proxy registration")
|
|
// Don't fail the request - the save succeeded, reload is best-effort
|
|
} else {
|
|
log.Info().Str("instance", matchedInstance.Name).Msg("Config reloaded after temperature proxy registration")
|
|
}
|
|
}
|
|
|
|
log.Info().
|
|
Str("hostname", hostname).
|
|
Str("proxy_url", proxyURL).
|
|
Str("mode", mode).
|
|
Str("pve_instance", matchedInstance.Name).
|
|
Msg("Temperature proxy registered successfully")
|
|
|
|
allowed := buildAuthorizedNodeList(nodesConfig.PVEInstances)
|
|
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]
|
|
|
|
// Check instance-level token first
|
|
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
|
|
}
|
|
|
|
// For clusters, also check per-node tokens on ClusterEndpoints
|
|
if inst.IsCluster {
|
|
for j := range inst.ClusterEndpoints {
|
|
ep := &inst.ClusterEndpoints[j]
|
|
if strings.TrimSpace(ep.TemperatureProxyControlToken) == token {
|
|
matched = inst
|
|
break
|
|
}
|
|
}
|
|
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(nodesConfig.PVEInstances)
|
|
if len(nodes) == 0 && matched != nil {
|
|
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
|
|
hostnameLower := strings.ToLower(hostname)
|
|
|
|
for i := range nodesConfig.PVEInstances {
|
|
instance := &nodesConfig.PVEInstances[i]
|
|
|
|
if strings.Contains(strings.ToLower(instance.Host), hostnameLower) {
|
|
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), hostnameLower) {
|
|
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
|
|
}
|