package api import ( "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "golang.org/x/crypto/ssh" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/system" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" ) // agentAutoRegContextKey marks a request authenticated via agent:report token. // When set, handleCanonicalAutoRegister restricts to updating existing nodes only. type agentAutoRegContextKey struct{} func isAgentAutoRegAuth(ctx context.Context) bool { v, _ := ctx.Value(agentAutoRegContextKey{}).(bool) return v } // HandleSetupScript serves the setup script for Proxmox/PBS nodes func (h *ConfigHandlers) handleSetupScript(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Get query parameters query := r.URL.Query() serverType := strings.TrimSpace(query.Get("type")) // "pve" or "pbs" serverHost := strings.TrimSpace(query.Get("host")) pulseURL := strings.TrimSpace(query.Get("pulse_url")) // URL of the Pulse server for auto-registration backupPerms := query.Get("backup_perms") == "true" // Whether to add backup management permissions setupToken := strings.TrimSpace(query.Get("setup_token")) // Temporary setup token for auto-registration // Validate required parameters if serverType == "" { http.Error(w, "Missing required parameter: type (must be 'pve' or 'pbs')", http.StatusBadRequest) return } if !isCanonicalAutoRegisterType(serverType) { http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest) return } if serverHost == "" { http.Error(w, "Missing required parameter: host", http.StatusBadRequest) return } if safeHost, err := sanitizeInstallerURL(serverHost); err != nil { http.Error(w, fmt.Sprintf("Invalid host parameter: %v", err), http.StatusBadRequest) return } else { serverHost = safeHost } if normalizedHost, err := normalizeNodeHost(serverHost, serverType); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } else { serverHost = normalizedHost } if pulseURL == "" { http.Error(w, "Missing required parameter: pulse_url", http.StatusBadRequest) return } if safeURL, err := sanitizeInstallerURL(pulseURL); err != nil { http.Error(w, fmt.Sprintf("Invalid pulse_url parameter: %v", err), http.StatusBadRequest) return } else { pulseURL = safeURL } if backupPerms && serverType != "pve" { http.Error(w, "backup_perms is only supported for type 'pve'", http.StatusBadRequest) return } if sanitizedToken, err := sanitizeSetupAuthToken(setupToken); err != nil { http.Error(w, fmt.Sprintf("Invalid setup_token parameter: %v", err), http.StatusBadRequest) return } else { setupToken = sanitizedToken } // Ensure validated pulseURL stays normalized before it reaches generated script state. if safeURL, err := sanitizeInstallerURL(pulseURL); err == nil { pulseURL = safeURL } log.Info(). Str("type", serverType). Str("host", serverHost). Bool("has_auth", h.getConfig(r.Context()).AuthUser != "" || h.getConfig(r.Context()).AuthPass != "" || h.getConfig(r.Context()).HasAPITokens()). Msg("HandleSetupScript called") // The setup script is now public; authentication happens via setup token. // No need to check auth here since the script will prompt for a code serverName := deriveSetupScriptServerName(serverHost) pulseTokenScope := pulseTokenSuffix(pulseURL) tokenName := buildPulseMonitorTokenName(pulseURL) tokenMatchPrefix := tokenName // Log the token name for debugging log.Info(). Str("pulseURL", pulseURL). Str("pulseTokenScope", pulseTokenScope). Str("tokenName", tokenName). Msg("Generated deterministic token name for setup script") artifact := buildSetupScriptInstallArtifact( pulseURL, serverType, serverHost, pulseURL, backupPerms, setupToken, 0, ) // Get or generate SSH public key for temperature monitoring sshKeys := h.getOrGenerateSSHKeys() storagePerms := "" if backupPerms { storagePerms = ` pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin if [ "$TOKEN_CREATED" = true ]; then pveum aclmod /storage -token "$PULSE_TOKEN_ID" -role PVEDatastoreAdmin fi` } script := renderSetupScript(serverType, setupScriptRenderContext{ ServerName: serverName, PulseURL: pulseURL, ServerHost: serverHost, SetupToken: setupToken, TokenName: tokenName, TokenMatchPrefix: tokenMatchPrefix, StoragePerms: storagePerms, SensorsPublicKey: sshKeys.SensorsPublicKey, Artifact: artifact, }) // Serve setup scripts as canonical shell-script downloads instead of generic text. w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", buildSetupScriptFileName(serverType))) w.Write([]byte(script)) } type setupScriptURLRequest struct { Type string `json:"type"` Host string `json:"host"` BackupPerms bool `json:"backupPerms"` } // generateSetupTokenRecord generates a secure hex token that satisfies sanitizeSetupAuthToken. func (h *ConfigHandlers) generateSetupTokenRecord() string { // 16 bytes => 32 hex characters which matches the sanitizer's lower bound. const tokenBytes = 16 buf := make([]byte, tokenBytes) if _, err := rand.Read(buf); err == nil { return hex.EncodeToString(buf) } // rand.Read should never fail, but if it does fall back to timestamp-based token. log.Warn().Msg("fallback setup token generator used due to entropy failure") return fmt.Sprintf("%d", time.Now().UnixNano()) } // HandleSetupScriptURL generates a one-time setup token and URL for the setup script. func (h *ConfigHandlers) handleSetupScriptURL(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Limit request body to 8KB to prevent memory exhaustion r.Body = http.MaxBytesReader(w, r.Body, 8*1024) // Parse request var req setupScriptURLRequest decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } if decoder.More() { http.Error(w, "Invalid request", http.StatusBadRequest) return } if err := decoder.Decode(&struct{}{}); err != io.EOF { http.Error(w, "Invalid request", http.StatusBadRequest) return } req.Type = strings.TrimSpace(req.Type) req.Host = strings.TrimSpace(req.Host) if !isCanonicalAutoRegisterType(req.Type) { http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest) return } if req.BackupPerms && req.Type != "pve" { http.Error(w, "backupPerms is only supported for type 'pve'", http.StatusBadRequest) return } normalizedHost, err := normalizeNodeHost(req.Host, req.Type) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } req.Host = normalizedHost // Generate a temporary setup token for setup-script bootstrap transport. token := h.generateSetupTokenRecord() tokenHash := internalauth.HashAPIToken(token) // Store the token with expiry (5 minutes) expiry := time.Now().Add(5 * time.Minute) h.codeMutex.Lock() h.setupTokens[tokenHash] = &SetupTokenRecord{ ExpiresAt: expiry, Used: false, NodeType: req.Type, Host: req.Host, OrgID: GetOrgID(r.Context()), } h.codeMutex.Unlock() log.Info(). Str("token_hash", safePrefixForLog(tokenHash, 8)+"..."). Time("expiry", expiry). Str("type", req.Type). Msg("Generated temporary setup token") // Build the canonical bootstrap artifact for setup-script transport. pulseURL := resolveLoopbackAwarePublicBaseURL(r, h.getConfig(r.Context())) artifact := buildSetupScriptInstallArtifact( pulseURL, req.Type, req.Host, pulseURL, req.BackupPerms, token, expiry.Unix(), ) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(artifact) } // AutoRegisterRequest represents a request from the setup script or agent to auto-register a node type AutoRegisterRequest struct { Type string `json:"type"` // "pve" or "pbs" Host string `json:"host"` // The preferred host URL CandidateHosts []string `json:"candidateHosts,omitempty"` // Alternate host URLs the server can try from its own network view TokenID string `json:"tokenId,omitempty"` // Full token ID like pulse-monitor@pve!pulse-token TokenValue string `json:"tokenValue,omitempty"` // The token value for the node ServerName string `json:"serverName,omitempty"` // Hostname or IP AuthToken string `json:"authToken,omitempty"` // One-time setup token from setup/install flows Source string `json:"source,omitempty"` // "agent" or "script" - indicates how the node was registered CheckRegistration bool `json:"checkRegistration,omitempty"` // Check whether a matching registered node already exists without completing registration } // AutoRegisterResponse is the canonical success shape for /api/auto-register. type AutoRegisterResponse struct { Status string `json:"status"` Message string `json:"message"` Action string `json:"action"` Type string `json:"type"` Source string `json:"source"` Host string `json:"host"` NodeID string `json:"nodeId"` NodeName string `json:"nodeName"` TokenID string `json:"tokenId"` TokenValue string `json:"tokenValue,omitempty"` } // AutoUnregisterRequest represents a request from the setup script to tear down // a script-managed node connection after local credentials have been removed. type AutoUnregisterRequest struct { Type string `json:"type"` // "pve" or "pbs" Host string `json:"host"` // The canonical node host URL TokenID string `json:"tokenId,omitempty"` // Full token ID like pulse-monitor@pve!pulse-token ServerName string `json:"serverName,omitempty"` // Hostname or IP AuthToken string `json:"authToken,omitempty"` // One-time setup token from the setup flow Source string `json:"source,omitempty"` // Must be "script" } // AutoUnregisterResponse is the canonical success shape for /api/auto-unregister. type AutoUnregisterResponse struct { Status string `json:"status"` Message string `json:"message"` Action string `json:"action"` Type string `json:"type"` Source string `json:"source"` Host string `json:"host"` NodeID string `json:"nodeId,omitempty"` NodeName string `json:"nodeName,omitempty"` Removed bool `json:"removed"` } func isCanonicalAutoRegisterType(nodeType string) bool { switch strings.TrimSpace(nodeType) { case "pve", "pbs": return true default: return false } } func isCanonicalAutoRegisterTokenID(nodeType string, tokenID string) bool { trimmedType := strings.TrimSpace(nodeType) trimmedTokenID := strings.TrimSpace(tokenID) if !isCanonicalAutoRegisterType(trimmedType) || trimmedTokenID == "" { return false } prefix := "pulse-monitor@" + trimmedType + "!" if !strings.HasPrefix(trimmedTokenID, prefix) { return false } suffix := strings.TrimSpace(strings.TrimPrefix(trimmedTokenID, prefix)) return strings.HasPrefix(suffix, "pulse-") && suffix != "pulse-" } func isCanonicalAutoRegisterSource(source string) bool { switch strings.TrimSpace(source) { case "agent", "script": return true default: return false } } func normalizeAutoRegisterHostCandidates(nodeType, primary string, alternates []string) ([]string, error) { candidates := make([]string, 0, len(alternates)+1) seen := make(map[string]struct{}, len(alternates)+1) addCandidate := func(raw string) error { if strings.TrimSpace(raw) == "" { return nil } normalized, err := normalizeNodeHost(raw, nodeType) if err != nil { return err } if _, exists := seen[normalized]; exists { return nil } seen[normalized] = struct{}{} candidates = append(candidates, normalized) return nil } if err := addCandidate(primary); err != nil { return nil, err } for _, candidate := range alternates { if err := addCandidate(candidate); err != nil { log.Debug(). Str("type", nodeType). Str("candidate", candidate). Err(err). Msg("Ignoring invalid auto-register host candidate") } } if len(candidates) == 0 { return nil, fmt.Errorf("host is required") } return candidates, nil } func selectAutoRegisterHost(nodeType, primary string, alternates []string) (string, string, bool, []string, error) { candidates, err := normalizeAutoRegisterHostCandidates(nodeType, primary, alternates) if err != nil { return "", "", false, nil, err } for idx, candidate := range candidates { fingerprint, err := fetchTLSFingerprint(candidate) if err != nil || strings.TrimSpace(fingerprint) == "" { log.Debug(). Str("type", nodeType). Str("candidate", candidate). Err(err). Msg("Auto-register candidate not reachable for fingerprint capture") continue } if idx > 0 { log.Info(). Str("type", nodeType). Str("selectedHost", candidate). Str("requestedHost", candidates[0]). Msg("Auto-register switched to fallback host candidate reachable from Pulse") } return candidate, fingerprint, true, candidates, nil } return candidates[0], "", false, candidates, nil } func canonicalAutoRegisterMatchMessage(reason string) string { return "Canonical auto-register matched existing node by " + reason } func canonicalAutoRegisterCompletionPayloadMessage() string { return "Incomplete canonical auto-register token completion payload" } func canonicalAutoRegisterCheckMissingFieldsMessage(typeValue string, host string, serverName string) string { missing := make([]string, 0, 3) if strings.TrimSpace(typeValue) == "" { missing = append(missing, "type") } if strings.TrimSpace(host) == "" { missing = append(missing, "host") } if strings.TrimSpace(serverName) == "" { missing = append(missing, "serverName") } if len(missing) == 0 { return "Missing required canonical auto-register check fields" } return "Missing required canonical auto-register check fields: " + strings.Join(missing, ", ") } func canonicalAutoRegisterMissingFieldsMessage(typeValue string, host string, hasTokenID bool, serverName string) string { missing := make([]string, 0, 4) if strings.TrimSpace(typeValue) == "" { missing = append(missing, "type") } if strings.TrimSpace(host) == "" { missing = append(missing, "host") } if !hasTokenID { missing = append(missing, "tokenId/tokenValue") } if strings.TrimSpace(serverName) == "" { missing = append(missing, "serverName") } if len(missing) == 0 { return "Missing required canonical auto-register fields" } return "Missing required canonical auto-register fields: " + strings.Join(missing, ", ") } func canonicalAutoRegisterNodeIdentity(req *AutoRegisterRequest, actualName string, host string) string { eventName := strings.TrimSpace(actualName) if eventName == "" { eventName = strings.TrimSpace(req.ServerName) } if eventName == "" { eventName = strings.TrimSpace(host) } return eventName } func canonicalAutoRegisterSuccessMessage(nodeName string, host string) string { trimmedNodeName := strings.TrimSpace(nodeName) trimmedHost := strings.TrimSpace(host) if trimmedNodeName == "" { return fmt.Sprintf("Node registered successfully at %s", trimmedHost) } if trimmedHost == "" { return fmt.Sprintf("Node %s registered successfully", trimmedNodeName) } return fmt.Sprintf("Node %s registered successfully at %s", trimmedNodeName, trimmedHost) } func canonicalAutoUnregisterMissingFieldsMessage(typeValue string, host string, serverName string) string { missing := make([]string, 0, 3) if strings.TrimSpace(typeValue) == "" { missing = append(missing, "type") } if strings.TrimSpace(host) == "" { missing = append(missing, "host") } if strings.TrimSpace(serverName) == "" { missing = append(missing, "serverName") } if len(missing) == 0 { return "Missing required canonical auto-unregister fields" } return "Missing required canonical auto-unregister fields: " + strings.Join(missing, ", ") } func canonicalAutoUnregisterSuccessMessage(nodeName string, host string, removed bool) string { if !removed { return fmt.Sprintf("No matching node is currently configured for %s", strings.TrimSpace(host)) } trimmedNodeName := strings.TrimSpace(nodeName) trimmedHost := strings.TrimSpace(host) if trimmedNodeName == "" { return fmt.Sprintf("Node removed successfully from %s", trimmedHost) } if trimmedHost == "" { return fmt.Sprintf("Node %s removed successfully", trimmedNodeName) } return fmt.Sprintf("Node %s removed successfully from %s", trimmedNodeName, trimmedHost) } func buildCanonicalAutoUnregisterResponse(req *AutoUnregisterRequest, host string, actualName string, removed bool) AutoUnregisterResponse { action := "noop" if removed { action = "remove_connection" } return AutoUnregisterResponse{ Status: "success", Message: canonicalAutoUnregisterSuccessMessage(actualName, host, removed), Action: action, Type: strings.TrimSpace(req.Type), Source: strings.TrimSpace(req.Source), Host: strings.TrimSpace(host), NodeID: strings.TrimSpace(actualName), NodeName: strings.TrimSpace(actualName), Removed: removed, } } func (h *ConfigHandlers) authenticateSetupScriptTeardownRequest(r *http.Request, nodeType string, authToken string) (*http.Request, bool) { setupToken := strings.TrimSpace(authToken) if setupToken == "" { return r, false } requestOrgID := GetOrgID(r.Context()) if !h.ValidateSetupTokenForOrg(setupToken, requestOrgID) { return r, false } tokenHash := internalauth.HashAPIToken(setupToken) now := time.Now() h.codeMutex.Lock() defer h.codeMutex.Unlock() record, exists := h.setupTokens[tokenHash] if exists { if strings.TrimSpace(record.NodeType) != "" && strings.TrimSpace(record.NodeType) != strings.TrimSpace(nodeType) { return r, false } if !record.Used && now.Before(record.ExpiresAt) { record.Used = true graceExpiry := recentSetupTokenGraceExpiry(record, now) h.recentSetupTokens[tokenHash] = buildRecentSetupTokenRecord(record, graceExpiry) } if record.OrgID != "" { r = r.WithContext(context.WithValue(r.Context(), OrgIDContextKey, record.OrgID)) } return r, true } recentRecord, exists := h.recentSetupTokens[tokenHash] if !exists || !now.Before(recentRecord.ExpiresAt) { return r, false } if recentRecord.NodeType != "" && recentRecord.NodeType != strings.TrimSpace(nodeType) { return r, false } if recentRecord.OrgID != "" { r = r.WithContext(context.WithValue(r.Context(), OrgIDContextKey, recentRecord.OrgID)) } return r, true } func autoUnregisterNodeMatch(nodeHost string, candidateHosts []string, nodeTokenID string, requestedTokenID string) bool { if !autoRegisterHostMatchesCandidates(nodeHost, candidateHosts) { return false } trimmedRequestedTokenID := strings.TrimSpace(requestedTokenID) if trimmedRequestedTokenID == "" { return true } return strings.TrimSpace(nodeTokenID) == trimmedRequestedTokenID } func (h *ConfigHandlers) removeAutoRegisteredNode(ctx context.Context, req *AutoUnregisterRequest, candidateHosts []string) (removed bool, actualName string, removedHost string) { cfg := h.getConfig(ctx) if cfg == nil { return false, "", "" } switch strings.TrimSpace(req.Type) { case "pve": hostOnlyFallback := -1 for i, node := range cfg.PVEInstances { if autoUnregisterNodeMatch(node.Host, candidateHosts, node.TokenName, req.TokenID) { actualName = node.Name removedHost = node.Host cfg.PVEInstances = append(cfg.PVEInstances[:i], cfg.PVEInstances[i+1:]...) h.normalizePVEConfigState(ctx) return true, actualName, removedHost } if hostOnlyFallback == -1 && autoRegisterHostMatchesCandidates(node.Host, candidateHosts) && node.Source == "script" { hostOnlyFallback = i } } if hostOnlyFallback >= 0 { node := cfg.PVEInstances[hostOnlyFallback] actualName = node.Name removedHost = node.Host cfg.PVEInstances = append(cfg.PVEInstances[:hostOnlyFallback], cfg.PVEInstances[hostOnlyFallback+1:]...) h.normalizePVEConfigState(ctx) return true, actualName, removedHost } case "pbs": hostOnlyFallback := -1 for i, node := range cfg.PBSInstances { if autoUnregisterNodeMatch(node.Host, candidateHosts, node.TokenName, req.TokenID) { actualName = node.Name removedHost = node.Host cfg.PBSInstances = append(cfg.PBSInstances[:i], cfg.PBSInstances[i+1:]...) return true, actualName, removedHost } if hostOnlyFallback == -1 && autoRegisterHostMatchesCandidates(node.Host, candidateHosts) && node.Source == "script" { hostOnlyFallback = i } } if hostOnlyFallback >= 0 { node := cfg.PBSInstances[hostOnlyFallback] actualName = node.Name removedHost = node.Host cfg.PBSInstances = append(cfg.PBSInstances[:hostOnlyFallback], cfg.PBSInstances[hostOnlyFallback+1:]...) return true, actualName, removedHost } } return false, "", "" } func (h *ConfigHandlers) notifyAutoUnregisterSuccess(ctx context.Context, req *AutoUnregisterRequest) { if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil { log.Info().Msg("Triggering discovery refresh after auto-unregister") h.getMonitor(ctx).GetDiscoveryService().ForceRefresh() } if h.wsHub == nil { return } h.wsHub.BroadcastMessage(websocket.Message{ Type: "node_deleted", Data: map[string]interface{}{ "nodeType": strings.TrimSpace(req.Type), }, Timestamp: time.Now().Format(time.RFC3339), }) if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil { if result, _ := h.getMonitor(ctx).GetDiscoveryService().GetCachedResult(); result != nil { h.wsHub.BroadcastMessage(websocket.Message{ Type: "discovery_update", Data: map[string]interface{}{ "servers": result.Servers, "errors": result.LegacyErrors(), "structured_errors": result.StructuredErrors, "timestamp": time.Now().Unix(), }, Timestamp: time.Now().Format(time.RFC3339), }) } } } type autoRegisterCheckResponse struct { Registered bool `json:"registered"` } func autoRegisterHostMatchesCandidates(existingHost string, candidates []string) bool { trimmedExisting := strings.TrimSpace(existingHost) if trimmedExisting == "" { return false } for _, candidate := range candidates { trimmedCandidate := strings.TrimSpace(candidate) if trimmedCandidate == "" { continue } if trimmedExisting == trimmedCandidate || hostsShareResolvedIdentity(trimmedExisting, trimmedCandidate) { return true } } return false } func (h *ConfigHandlers) autoRegisteredNodeExists(ctx context.Context, req *AutoRegisterRequest, candidates []string) bool { if req == nil { return false } // Snapshot connection statuses once; nil if monitor not available yet. var connStatuses map[string]bool if m := h.getMonitor(ctx); m != nil { connStatuses = m.GetConnectionStatuses() } switch req.Type { case "pve": for _, node := range h.getConfig(ctx).PVEInstances { if autoRegisterHostMatchesCandidates(node.Host, candidates) { // Node exists in config. If the monitor has definitively confirmed // it is disconnected (stale token), allow re-registration so the // agent can update the token. Default to "registered" when there // is no monitor status yet (e.g. startup race). if isKnownDisconnected(connStatuses, "pve", node.Name, node.Host) { return false } return true } } case "pbs": for _, node := range h.getConfig(ctx).PBSInstances { if autoRegisterHostMatchesCandidates(node.Host, candidates) { if isKnownDisconnected(connStatuses, "pbs", node.Name, node.Host) { return false } return true } } } return false } // isKnownDisconnected reports whether the monitor has a definitive "unhealthy" // entry for the given instance. Returns false (i.e., assume connected) when // there is no monitor data yet so we don't mistakenly trigger re-registration // on every cold startup before the monitor has had a chance to poll. // // Key format must match the PollProvider connectionKey functions: // - pve: bare instance name (e.g. "delly") // - pbs: "pbs-" + instance name func isKnownDisconnected(connStatuses map[string]bool, instanceType, name, host string) bool { if connStatuses == nil { return false } instanceName := strings.TrimSpace(name) if instanceName == "" { instanceName = strings.TrimSpace(host) } if instanceName == "" { return false } var key string switch instanceType { case "pve": key = instanceName // PVE connectionKey returns bare name case "pbs": key = "pbs-" + instanceName default: key = instanceType + "-" + instanceName } connected, known := connStatuses[key] return known && !connected } func buildCanonicalAutoRegisterResponse(req *AutoRegisterRequest, host string, actualName string, tokenID string, tokenValue string) AutoRegisterResponse { nodeName := canonicalAutoRegisterNodeIdentity(req, actualName, host) return AutoRegisterResponse{ Status: "success", Message: canonicalAutoRegisterSuccessMessage(nodeName, host), Action: "use_token", Type: req.Type, Source: req.Source, Host: host, NodeID: nodeName, NodeName: nodeName, TokenID: tokenID, TokenValue: strings.TrimSpace(tokenValue), } } func buildAutoRegisterEventData(req *AutoRegisterRequest, host string, actualName string, tokenID string) map[string]interface{} { eventName := canonicalAutoRegisterNodeIdentity(req, actualName, host) return map[string]interface{}{ "type": req.Type, "source": req.Source, "host": host, "name": eventName, "nodeId": eventName, "nodeName": eventName, "tokenId": tokenID, "hasToken": true, "verifySSL": true, "status": "connected", } } func autoRegisterWebSocketEventType(created bool) string { if created { return "node_auto_registered" } return "nodes_changed" } func (h *ConfigHandlers) notifyAutoRegistrationSuccess(ctx context.Context, req *AutoRegisterRequest, host string, actualName string, tokenID string, created bool) { if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil { log.Info().Msg("Triggering discovery refresh after auto-registration") h.getMonitor(ctx).GetDiscoveryService().ForceRefresh() } if h.wsHub == nil { log.Warn().Msg("WebSocket hub is nil, cannot broadcast auto-registration") return } nodeInfo := buildAutoRegisterEventData(req, host, actualName, tokenID) h.wsHub.BroadcastMessage(websocket.Message{ Type: autoRegisterWebSocketEventType(created), Data: nodeInfo, Timestamp: time.Now().Format(time.RFC3339), }) if h.getMonitor(ctx) != nil && h.getMonitor(ctx).GetDiscoveryService() != nil { result, _ := h.getMonitor(ctx).GetDiscoveryService().GetCachedResult() if result != nil { h.wsHub.BroadcastMessage(websocket.Message{ Type: "discovery_update", Data: map[string]interface{}{ "servers": result.Servers, "errors": result.LegacyErrors(), "structured_errors": result.StructuredErrors, "timestamp": time.Now().Unix(), }, Timestamp: time.Now().Format(time.RFC3339), }) log.Info().Msg("Broadcasted discovery update after auto-registration") } } log.Info(). Str("host", host). Str("name", actualName). Str("type", autoRegisterWebSocketEventType(created)). Msg("Broadcasted auto-registration success via WebSocket") } // HandleAutoUnregister removes a script-managed node connection after the host // has removed the Pulse-created credentials locally. func (h *ConfigHandlers) handleAutoUnregister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req AutoUnregisterRequest body, err := io.ReadAll(r.Body) if err != nil { log.Error().Err(err).Msg("Failed to read auto-unregister request body") http.Error(w, "Failed to read request body", http.StatusBadRequest) return } if err := json.Unmarshal(body, &req); err != nil { log.Error().Err(err).Str("body", string(body)).Msg("Failed to parse auto-unregister request") http.Error(w, "Invalid request format", http.StatusBadRequest) return } req.Type = strings.TrimSpace(req.Type) req.Host = strings.TrimSpace(req.Host) req.TokenID = strings.TrimSpace(req.TokenID) req.ServerName = strings.TrimSpace(req.ServerName) req.AuthToken = strings.TrimSpace(req.AuthToken) req.Source = strings.TrimSpace(req.Source) if req.Source == "" { http.Error(w, "source is required", http.StatusBadRequest) return } if req.Source != "script" { http.Error(w, "source must be 'script'", http.StatusBadRequest) return } if !isCanonicalAutoRegisterType(req.Type) { http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest) return } if req.Type == "" || req.Host == "" || req.ServerName == "" { http.Error(w, canonicalAutoUnregisterMissingFieldsMessage(req.Type, req.Host, req.ServerName), http.StatusBadRequest) return } if req.TokenID != "" && !isCanonicalAutoRegisterTokenID(req.Type, req.TokenID) { http.Error(w, "tokenId must be a canonical Pulse-managed token id", http.StatusBadRequest) return } normalizedCandidates, err := normalizeAutoRegisterHostCandidates(req.Type, req.Host, nil) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } req.Host = normalizedCandidates[0] updatedRequest, authenticated := h.authenticateSetupScriptTeardownRequest(r, req.Type, req.AuthToken) if !authenticated { if req.AuthToken == "" { http.Error(w, "Pulse setup token required", http.StatusUnauthorized) } else { http.Error(w, "Invalid or expired setup token", http.StatusUnauthorized) } return } r = updatedRequest removed, actualName, removedHost := h.removeAutoRegisteredNode(r.Context(), &req, normalizedCandidates) if removed { if err := h.getPersistence(r.Context()).SaveNodesConfigAllowEmpty( h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances, ); err != nil { log.Error().Err(err).Msg("Failed to save auto-unregistered node configuration") http.Error(w, "Failed to save configuration", http.StatusInternalServerError) return } if h.reloadFunc != nil { if err := h.reloadFunc(); err != nil { log.Error().Err(err).Msg("Failed to reload monitor after auto-unregister") http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError) return } } h.notifyAutoUnregisterSuccess(r.Context(), &req) } else { removedHost = req.Host actualName = req.ServerName } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(buildCanonicalAutoUnregisterResponse(&req, removedHost, actualName, removed)); err != nil { log.Error().Err(err).Msg("Failed to encode auto-unregister response") } } // nodesConfigFingerprint computes a deterministic SHA-256 fingerprint of the // PVE / PBS / PMG instance lists that would be persisted by SaveNodesConfig. // Used by handleCanonicalAutoRegister to detect no-op re-registrations: when // an already-known agent re-announces itself the auto-register handler still // runs the full add-then-consolidate path, which produces the exact same // persisted state. Writing+reloading on those no-ops triggers a full monitor // rebuild that briefly empties the resource store, producing a visible // flicker for every connected WebSocket client. Comparing fingerprints lets // us short-circuit and skip both the save and the reload. func nodesConfigFingerprint(pve []config.PVEInstance, pbs []config.PBSInstance, pmg []config.PMGInstance) string { payload := struct { PVE []config.PVEInstance `json:"pve"` PBS []config.PBSInstance `json:"pbs"` PMG []config.PMGInstance `json:"pmg"` }{pve, pbs, pmg} data, err := json.Marshal(payload) if err != nil { // Fall back to a non-comparable sentinel so callers treat this as // "changed" and persist+reload defensively. return fmt.Sprintf("fingerprint-error:%d:%d:%d:%v", len(pve), len(pbs), len(pmg), err) } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } // HandleAutoRegister receives token details from the setup script and auto-configures the node func (h *ConfigHandlers) handleAutoRegister(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse request body first so the setup token can be validated early. var req AutoRegisterRequest body, err := io.ReadAll(r.Body) if err != nil { log.Error().Err(err).Msg("Failed to read request body") http.Error(w, "Failed to read request body", http.StatusBadRequest) return } if err := json.Unmarshal(body, &req); err != nil { log.Error().Err(err).Str("body", string(body)).Msg("Failed to parse auto-register request") http.Error(w, "Invalid request format", http.StatusBadRequest) return } // Check authentication through the one-time setup token carried in authToken. authenticated := false setupToken := strings.TrimSpace(req.AuthToken) log.Debug(). Bool("hasAuthToken", setupToken != ""). Bool("hasConfigToken", h.getConfig(r.Context()).HasAPITokens()). Msg("Checking authentication for auto-register") // First check for the one-time setup token in the request. if setupToken != "" { tokenHash := internalauth.HashAPIToken(setupToken) log.Debug(). Bool("hasSetupToken", true). Str("tokenHash", safePrefixForLog(tokenHash, 8)+"..."). Msg("Checking auth token as one-time setup token") h.codeMutex.Lock() setupTokenRecord, exists := h.setupTokens[tokenHash] log.Debug(). Bool("exists", exists). Int("totalTokens", len(h.setupTokens)). Msg("Setup token lookup result") if exists && !setupTokenRecord.Used && time.Now().Before(setupTokenRecord.ExpiresAt) { // Validate that the token matches the node type. // Note: We don't validate the host anymore as it may differ between // what's entered in the UI and what's provided in the setup script URL if setupTokenRecord.NodeType == req.Type { setupTokenRecord.Used = true // Mark as used immediately. // Inject OrgID from the setup token into context for subsequent processing. if setupTokenRecord.OrgID != "" { ctx := context.WithValue(r.Context(), OrgIDContextKey, setupTokenRecord.OrgID) r = r.WithContext(ctx) } // Allow a short grace period for follow-up actions without keeping tokens alive too long. graceExpiry := recentSetupTokenGraceExpiry(setupTokenRecord, time.Now()) h.recentSetupTokens[tokenHash] = buildRecentSetupTokenRecord(setupTokenRecord, graceExpiry) authenticated = true log.Info(). Str("type", req.Type). Str("host", req.Host). Bool("via_authToken", req.AuthToken != ""). Msg("Auto-register authenticated via setup token") } else { log.Warn(). Str("expected_type", setupTokenRecord.NodeType). Str("got_type", req.Type). Msg("Setup token validation failed - type mismatch") } } else if exists && setupTokenRecord.Used { log.Warn().Msg("Setup token already used") } else if exists { log.Warn().Msg("Setup token expired") } else { log.Warn().Msg("Invalid setup token - not in setup token registry") } h.codeMutex.Unlock() } // Fallback: allow agents with a valid agent:report API token to re-register their // Proxmox node. This is needed when an agent reinstalls — it rotates the PVE token // but can't fetch a one-time setup token (that endpoint requires settings:write). // For security this path is restricted to updating existing nodes only. if !authenticated { if apiToken, hasToken := explicitAPITokenFromRequest(r); hasToken { config.Mu.RLock() cfg := h.getConfig(r.Context()) isValid := cfg != nil && cfg.IsValidAPIToken(apiToken) config.Mu.RUnlock() if isValid { config.Mu.Lock() record, ok := cfg.ValidateAPIToken(apiToken) config.Mu.Unlock() if ok && record != nil && record.HasScope(config.ScopeAgentReport) { // Reject cross-org tokens: if the request context has an explicit // non-default org, the token must belong to that same org. requestOrgID := GetOrgID(r.Context()) orgMismatch := requestOrgID != "" && requestOrgID != "default" && record.OrgID != "" && record.OrgID != requestOrgID if !orgMismatch { authenticated = true r = r.WithContext(context.WithValue(r.Context(), agentAutoRegContextKey{}, true)) log.Info(). Str("type", req.Type). Str("host", req.Host). Msg("Auto-register authenticated via agent API token (update-only mode)") } } } } } // Abort when no authentication succeeded. This applies even when API tokens // are not configured to ensure one-time setup tokens are always required. if !authenticated { log.Warn(). Str("ip", r.RemoteAddr). Bool("has_setup_token", setupToken != ""). Msg("Unauthorized auto-register attempt rejected") if setupToken == "" { http.Error(w, "Pulse setup token required", http.StatusUnauthorized) } else { http.Error(w, "Invalid or expired setup token", http.StatusUnauthorized) } return } // Log source IP for security auditing clientIP := r.RemoteAddr // Only trust X-Forwarded-For if request comes from a trusted proxy peerIP := extractRemoteIP(clientIP) if isTrustedProxyIP(peerIP) { if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { clientIP = forwarded } } log.Info().Str("clientIP", clientIP).Msg("Auto-register request from") log.Info(). Str("type", req.Type). Str("host", req.Host). Str("tokenId", req.TokenID). Bool("hasTokenValue", req.TokenValue != ""). Str("serverName", req.ServerName). Msg("Processing auto-register request") h.handleCanonicalAutoRegister(w, r, &req, clientIP) } // handleCanonicalAutoRegister handles the canonical /api/auto-register flow. func (h *ConfigHandlers) handleCanonicalAutoRegister(w http.ResponseWriter, r *http.Request, req *AutoRegisterRequest, clientIP string) { log.Info(). Str("type", req.Type). Str("host", req.Host). Msg("Processing canonical auto-register request") typeValue := strings.TrimSpace(req.Type) hostValue := strings.TrimSpace(req.Host) tokenID := strings.TrimSpace(req.TokenID) tokenValue := strings.TrimSpace(req.TokenValue) serverName := strings.TrimSpace(req.ServerName) registrationSource := strings.TrimSpace(req.Source) hasTokenID := tokenID != "" hasTokenValue := tokenValue != "" if registrationSource == "" { http.Error(w, "source is required", http.StatusBadRequest) return } if !isCanonicalAutoRegisterSource(registrationSource) { http.Error(w, "source must be 'agent' or 'script'", http.StatusBadRequest) return } if !isCanonicalAutoRegisterType(typeValue) { http.Error(w, "type must be 'pve' or 'pbs'", http.StatusBadRequest) return } req.Type = typeValue req.Host = hostValue req.TokenID = tokenID req.TokenValue = tokenValue req.ServerName = serverName req.Source = registrationSource if req.CheckRegistration { if typeValue == "" || hostValue == "" || serverName == "" { missingMessage := canonicalAutoRegisterCheckMissingFieldsMessage(typeValue, hostValue, serverName) log.Error(). Str("type", req.Type). Str("host", req.Host). Str("serverName", req.ServerName). Msg(missingMessage) http.Error(w, missingMessage, http.StatusBadRequest) return } normalizedCandidates, err := normalizeAutoRegisterHostCandidates(req.Type, req.Host, req.CandidateHosts) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(autoRegisterCheckResponse{ Registered: h.autoRegisteredNodeExists(r.Context(), req, normalizedCandidates), }); err != nil { log.Error().Err(err).Msg("Failed to encode auto-register registration check response") } return } if hasTokenID != hasTokenValue { log.Error(). Bool("hasTokenID", hasTokenID). Bool("hasTokenValue", hasTokenValue). Msg(canonicalAutoRegisterCompletionPayloadMessage()) http.Error(w, "tokenId and tokenValue must be provided together", http.StatusBadRequest) return } if typeValue == "" || hostValue == "" || !hasTokenID || serverName == "" { missingMessage := canonicalAutoRegisterMissingFieldsMessage(typeValue, hostValue, hasTokenID, serverName) log.Error(). Str("type", req.Type). Str("host", req.Host). Str("serverName", req.ServerName). Bool("hasTokenID", hasTokenID). Bool("hasTokenValue", hasTokenValue). Msg(missingMessage) http.Error(w, missingMessage, http.StatusBadRequest) return } if !isCanonicalAutoRegisterTokenID(typeValue, tokenID) { http.Error(w, "tokenId must be a canonical Pulse-managed token id", http.StatusBadRequest) return } host, fingerprint, verifySSL, candidateHosts, err := selectAutoRegisterHost(req.Type, req.Host, req.CandidateHosts) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } log.Info(). Str("requestedHost", req.Host). Str("selectedHost", host). Strs("candidateHosts", candidateHosts). Bool("verifySSL", verifySSL). Msg("Resolved canonical auto-register host candidates") fullTokenID := req.TokenID tokenValue = req.TokenValue log.Info(). Str("host", host). Str("tokenID", fullTokenID). Str("source", registrationSource). Msg("Using caller-supplied token for canonical /api/auto-register completion") // Snapshot the pre-mutation persisted-state fingerprint so we can detect // no-op auto-registrations and skip the SaveNodesConfig + monitor reload // that would otherwise wipe the live resource store for every connected // client. See nodesConfigFingerprint for the full rationale. preCfg := h.getConfig(r.Context()) beforeFingerprint := nodesConfigFingerprint(preCfg.PVEInstances, preCfg.PBSInstances, preCfg.PMGInstances) // Add the node to configuration created := false if req.Type == "pve" { pveDisplayName := h.disambiguateNodeName(r.Context(), serverName, host, "pve") pveNode := config.PVEInstance{ Name: pveDisplayName, Host: host, TokenName: fullTokenID, TokenValue: tokenValue, Fingerprint: fingerprint, VerifySSL: verifySSL, MonitorVMs: true, MonitorContainers: true, MonitorStorage: true, MonitorBackups: true, Source: registrationSource, } // Deduplicate by host to keep canonical auto-registration idempotent on reruns. existingIndex := -1 preserveHost := false for i, node := range h.getConfig(r.Context()).PVEInstances { if node.Host == host { existingIndex = i break } if hostsShareResolvedIdentity(node.Host, host) { existingIndex = i preserveHost = true log.Info(). Str("existingHost", node.Host). Str("newHost", host). Str("type", "pve"). Msg(canonicalAutoRegisterMatchMessage("resolved host identity")) break } if serverName != "" && strings.EqualFold(strings.TrimSpace(node.Name), serverName) && node.TokenName == pveNode.TokenName { existingIndex = i log.Info(). Str("existingHost", node.Host). Str("newHost", host). Str("type", "pve"). Str("node", serverName). Msg(canonicalAutoRegisterMatchMessage("DHCP continuity token identity")) break } } if existingIndex >= 0 { instance := &h.getConfig(r.Context()).PVEInstances[existingIndex] if !preserveHost { instance.Host = host } instance.User = "" instance.Password = "" instance.TokenName = pveNode.TokenName instance.TokenValue = pveNode.TokenValue if pveNode.Source != "" { instance.Source = pveNode.Source } // Update TLS fingerprint only when one was captured; a failed // FetchFingerprint must not erase a previously valid pin. if pveNode.Fingerprint != "" { instance.Fingerprint = pveNode.Fingerprint } instance.VerifySSL = pveNode.VerifySSL log.Info().Str("host", host).Str("type", "pve").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place")) } else { // Agent-token auth is restricted to updating existing nodes only. if isAgentAutoRegAuth(r.Context()) { log.Warn().Str("host", host).Str("type", "pve").Msg("Agent API token auth rejected for new PVE node registration") http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden) return } h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pveNode) h.normalizePVEConfigState(r.Context()) created = true } } else if req.Type == "pbs" { pbsDisplayName := h.disambiguateNodeName(r.Context(), serverName, host, "pbs") pbsNode := config.PBSInstance{ Name: pbsDisplayName, Host: host, TokenName: fullTokenID, TokenValue: tokenValue, Fingerprint: fingerprint, VerifySSL: verifySSL, MonitorBackups: true, MonitorDatastores: true, MonitorSyncJobs: true, MonitorVerifyJobs: true, MonitorPruneJobs: true, Source: registrationSource, } // Deduplicate by host to keep canonical auto-registration idempotent on reruns. existingIndex := -1 preserveHost := false for i, node := range h.getConfig(r.Context()).PBSInstances { if node.Host == host { existingIndex = i break } if hostsShareResolvedIdentity(node.Host, host) { existingIndex = i preserveHost = true log.Info(). Str("existingHost", node.Host). Str("newHost", host). Str("type", "pbs"). Msg(canonicalAutoRegisterMatchMessage("resolved host identity")) break } if serverName != "" && strings.EqualFold(strings.TrimSpace(node.Name), serverName) && node.TokenName == pbsNode.TokenName { existingIndex = i log.Info(). Str("existingHost", node.Host). Str("newHost", host). Str("type", "pbs"). Str("node", serverName). Msg(canonicalAutoRegisterMatchMessage("DHCP continuity token identity")) break } } if existingIndex >= 0 { instance := &h.getConfig(r.Context()).PBSInstances[existingIndex] if !preserveHost { instance.Host = host } instance.User = "" instance.Password = "" instance.TokenName = pbsNode.TokenName instance.TokenValue = pbsNode.TokenValue if pbsNode.Source != "" { instance.Source = pbsNode.Source } // Update TLS fingerprint only when one was captured; a failed // FetchFingerprint must not erase a previously valid pin. if pbsNode.Fingerprint != "" { instance.Fingerprint = pbsNode.Fingerprint } instance.VerifySSL = pbsNode.VerifySSL log.Info().Str("host", host).Str("type", "pbs").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place")) } else { // Agent-token auth is restricted to updating existing nodes only. if isAgentAutoRegAuth(r.Context()) { log.Warn().Str("host", host).Str("type", "pbs").Msg("Agent API token auth rejected for new PBS node registration") http.Error(w, "agent token auth permits token updates for existing nodes only", http.StatusForbidden) return } h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbsNode) created = true } } // Save configuration h.normalizePVEConfigState(r.Context()) postCfg := h.getConfig(r.Context()) afterFingerprint := nodesConfigFingerprint(postCfg.PVEInstances, postCfg.PBSInstances, postCfg.PMGInstances) configChanged := beforeFingerprint != afterFingerprint if configChanged { if err := h.getPersistence(r.Context()).SaveNodesConfig(postCfg.PVEInstances, postCfg.PBSInstances, postCfg.PMGInstances); err != nil { log.Error().Err(err).Msg("Failed to save auto-registered node") http.Error(w, "Failed to save configuration", http.StatusInternalServerError) return } } else { log.Debug(). Str("host", host). Str("type", req.Type). Msg("Auto-register resulted in no persisted-config change; skipping SaveNodesConfig and monitor reload") } actualName := h.findInstanceNameByHost(r.Context(), req.Type, host) if actualName == "" { actualName = serverName } h.markAutoRegistered(req.Type, actualName) // Reload monitor only when the persisted state actually changed. A full // reload stops the multi-tenant monitor and rebuilds it from scratch, // which momentarily empties the WebSocket-visible resource store; for // no-op re-registrations there is nothing for the monitor to pick up. if configChanged && h.reloadFunc != nil { go func() { if err := h.reloadFunc(); err != nil { log.Error().Err(err).Msg("Failed to reload monitor after auto-registration") } }() } h.notifyAutoRegistrationSuccess(r.Context(), req, host, actualName, fullTokenID, created) response := buildCanonicalAutoRegisterResponse(req, host, actualName, fullTokenID, tokenValue) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // SSHKeyPair holds the sensors SSH public key for temperature monitoring. type SSHKeyPair struct { SensorsPublicKey string } // getOrGenerateSSHKeys returns the SSH public key for temperature monitoring // If keys don't exist, they are generated automatically // SECURITY: Blocks key generation when running in containers unless dev mode override is enabled func (h *ConfigHandlers) getOrGenerateSSHKeys() SSHKeyPair { // CRITICAL SECURITY CHECK: Never generate SSH keys in containers (unless dev mode) // Container compromise = SSH key compromise = root access to Proxmox devModeAllowSSH := os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH") == "true" isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer() if isContainer && !devModeAllowSSH { log.Error().Msg("SECURITY BLOCK: SSH key generation disabled in containerized deployments") log.Error().Msg("Temperature monitoring via SSH is disabled in containerized deployments") log.Error().Msg("See: " + shippedSecurityContainerNoticeDocAnchor) log.Error().Msg("To test SSH keys in dev/lab only: PULSE_DEV_ALLOW_CONTAINER_SSH=true (NEVER in production!)") return SSHKeyPair{} } if devModeAllowSSH && isContainer { log.Warn().Msg("⚠️ DEV MODE: SSH key generation ENABLED in container - FOR TESTING ONLY") log.Warn().Msg("⚠️ This grants root SSH access from container - NEVER use in production!") } homeDir, err := os.UserHomeDir() if err != nil { log.Warn().Err(err).Msg("Could not determine home directory for SSH keys") return SSHKeyPair{} } sshDir := filepath.Join(homeDir, ".ssh") // Generate/load sensors key (for temperature collection) sensorsPrivPath := filepath.Join(sshDir, "id_ed25519_sensors") sensorsPubPath := filepath.Join(sshDir, "id_ed25519_sensors.pub") sensorsKey := h.generateOrLoadSSHKey(sshDir, sensorsPrivPath, sensorsPubPath, "sensors") return SSHKeyPair{ SensorsPublicKey: sensorsKey, } } // generateOrLoadSSHKey generates or loads a single SSH keypair func (h *ConfigHandlers) generateOrLoadSSHKey(sshDir, privateKeyPath, publicKeyPath, keyType string) string { // Check if public key already exists if pubKeyBytes, err := os.ReadFile(publicKeyPath); err == nil { publicKey := strings.TrimSpace(string(pubKeyBytes)) log.Info().Str("keyPath", publicKeyPath).Str("type", keyType).Msg("Using existing SSH public key") return publicKey } // Key doesn't exist - generate one log.Info().Str("sshDir", sshDir).Str("type", keyType).Msg("Generating new SSH keypair for temperature monitoring") // Create .ssh directory if it doesn't exist if err := os.MkdirAll(sshDir, 0700); err != nil { log.Error().Err(err).Str("sshDir", sshDir).Msg("Failed to create .ssh directory") return "" } // Generate Ed25519 key pair (more secure and faster than RSA) publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { log.Error().Err(err).Msg("Failed to generate Ed25519 key") return "" } // Save private key in OpenSSH format privateKeyFile, err := os.OpenFile(privateKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { log.Error().Err(err).Str("path", privateKeyPath).Msg("Failed to create private key file") return "" } defer privateKeyFile.Close() // Marshal Ed25519 private key to OpenSSH format privKeyBytes, err := ssh.MarshalPrivateKey(privateKey, "") if err != nil { log.Error().Err(err).Msg("Failed to marshal private key") return "" } if err := pem.Encode(privateKeyFile, privKeyBytes); err != nil { log.Error().Err(err).Msg("Failed to write private key") return "" } // Generate public key in OpenSSH format sshPublicKey, err := ssh.NewPublicKey(publicKey) if err != nil { log.Error().Err(err).Msg("Failed to generate public key") return "" } publicKeyBytes := ssh.MarshalAuthorizedKey(sshPublicKey) publicKeyString := strings.TrimSpace(string(publicKeyBytes)) // Save public key if err := os.WriteFile(publicKeyPath, publicKeyBytes, 0644); err != nil { log.Error().Err(err).Str("path", publicKeyPath).Msg("Failed to write public key") return "" } log.Info(). Str("privateKey", privateKeyPath). Str("publicKey", publicKeyPath). Msg("Successfully generated SSH keypair") return publicKeyString } // AgentInstallCommandRequest represents a request for an agent install command type AgentInstallCommandRequest struct { Type string `json:"type"` // "pve" or "pbs" } // AgentInstallCommandResponse contains the generated install command type AgentInstallCommandResponse struct { Command string `json:"command"` Token string `json:"token"` } // HandleAgentInstallCommand generates an API token and install command for agent-based Proxmox setup func (h *ConfigHandlers) handleAgentInstallCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req AgentInstallCommandRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } installType, err := normalizeProxmoxInstallType(req.Type) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } cfg := h.getConfig(r.Context()) persistence := h.getPersistence(r.Context()) rawToken := "" if authConfiguredForAgentLifecycle(cfg) { tokenName := fmt.Sprintf("proxmox-agent-%s-%d", installType, time.Now().Unix()) rawToken, _, err = issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{ TokenName: tokenName, OwnerUserID: apiTokenOwnerUserIDForRequest(cfg, r), Metadata: map[string]string{ "install_type": installType, "issued_via": "config_agent_install_command", }, }) if err != nil { switch { case errors.Is(err, errAgentInstallTokenGeneration): log.Error().Err(err).Msg("Failed to generate API token for agent install") http.Error(w, "Failed to generate API token", http.StatusInternalServerError) case errors.Is(err, errAgentInstallTokenRecord): log.Error().Err(err).Str("token_name", tokenName).Msg("Failed to construct API token record") http.Error(w, "Failed to generate token", http.StatusInternalServerError) case errors.Is(err, errAgentInstallTokenPersist): log.Error().Err(err).Msg("Failed to persist API tokens after creation") http.Error(w, "Failed to save token", http.StatusInternalServerError) default: log.Error().Err(err).Msg("Failed to create API token for agent install") http.Error(w, "Failed to generate API token", http.StatusInternalServerError) } return } } baseURL := resolveConfigAgentInstallBaseURL(r, cfg) command := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{ BaseURL: baseURL, Token: rawToken, InstallType: installType, IncludeInstallType: true, }) log.Info(). Str("type", installType). Bool("token_issued", rawToken != ""). Msg("Generated agent install command") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AgentInstallCommandResponse{ Command: command, Token: rawToken, }) }