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
This commit is contained in:
parent
22f092f941
commit
a2b8940873
2 changed files with 235 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
226
internal/api/temperature_proxy.go
Normal file
226
internal/api/temperature_proxy.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in a new issue