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
|
monitor *monitoring.Monitor
|
||||||
alertHandlers *AlertHandlers
|
alertHandlers *AlertHandlers
|
||||||
configHandlers *ConfigHandlers
|
configHandlers *ConfigHandlers
|
||||||
notificationHandlers *NotificationHandlers
|
notificationHandlers *NotificationHandlers
|
||||||
notificationQueueHandlers *NotificationQueueHandlers
|
notificationQueueHandlers *NotificationQueueHandlers
|
||||||
dockerAgentHandlers *DockerAgentHandlers
|
dockerAgentHandlers *DockerAgentHandlers
|
||||||
hostAgentHandlers *HostAgentHandlers
|
hostAgentHandlers *HostAgentHandlers
|
||||||
systemSettingsHandler *SystemSettingsHandler
|
temperatureProxyHandlers *TemperatureProxyHandlers
|
||||||
wsHub *websocket.Hub
|
systemSettingsHandler *SystemSettingsHandler
|
||||||
|
wsHub *websocket.Hub
|
||||||
reloadFunc func() error
|
reloadFunc func() error
|
||||||
updateManager *updates.Manager
|
updateManager *updates.Manager
|
||||||
exportLimiter *RateLimiter
|
exportLimiter *RateLimiter
|
||||||
|
|
@ -153,6 +154,7 @@ func (r *Router) setupRoutes() {
|
||||||
updateHandlers := NewUpdateHandlers(r.updateManager, r.config.DataPath)
|
updateHandlers := NewUpdateHandlers(r.updateManager, r.config.DataPath)
|
||||||
r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub)
|
r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub)
|
||||||
r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub)
|
r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub)
|
||||||
|
r.temperatureProxyHandlers = NewTemperatureProxyHandlers(r.persistence)
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
r.mux.HandleFunc("/api/health", r.handleHealth)
|
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/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/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/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/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/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
|
||||||
r.mux.HandleFunc("/api/version", r.handleVersion)
|
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