mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 17:19:57 +00:00
1175 lines
39 KiB
Go
1175 lines
39 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"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/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
|
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// 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 = "\npveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin"
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
switch req.Type {
|
|
case "pve":
|
|
for _, node := range h.getConfig(ctx).PVEInstances {
|
|
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
|
return true
|
|
}
|
|
}
|
|
case "pbs":
|
|
for _, node := range h.getConfig(ctx).PBSInstances {
|
|
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
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,
|
|
"host": host,
|
|
"name": eventName,
|
|
"nodeId": eventName,
|
|
"nodeName": eventName,
|
|
"tokenId": tokenID,
|
|
"hasToken": true,
|
|
"verifySSL": true,
|
|
"status": "connected",
|
|
}
|
|
}
|
|
|
|
func (h *ConfigHandlers) notifyAutoRegistrationSuccess(ctx context.Context, req *AutoRegisterRequest, host string, actualName string, tokenID string) {
|
|
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: "node_auto_registered",
|
|
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", "node_auto_registered").
|
|
Msg("Broadcasted auto-registration success via WebSocket")
|
|
}
|
|
|
|
// 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 := time.Now().Add(1 * time.Minute)
|
|
if setupTokenRecord.ExpiresAt.Before(graceExpiry) {
|
|
graceExpiry = setupTokenRecord.ExpiresAt
|
|
}
|
|
h.recentSetupTokens[tokenHash] = 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()
|
|
}
|
|
|
|
// 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")
|
|
// Add the node to configuration
|
|
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 {
|
|
if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{
|
|
Source: unifiedresources.SourceProxmox,
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: serverName,
|
|
Hostname: pulseTokenHostCandidate(host),
|
|
HostURL: host,
|
|
}) {
|
|
return
|
|
}
|
|
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pveNode)
|
|
h.normalizePVEConfigState(r.Context())
|
|
}
|
|
} 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,
|
|
}
|
|
// 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
|
|
// 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 {
|
|
if enforceMonitoredSystemLimitForConfigRegistration(w, r.Context(), h.getConfig(r.Context()), h.getMonitor(r.Context()), unifiedresources.MonitoredSystemCandidate{
|
|
Source: unifiedresources.SourcePBS,
|
|
Type: unifiedresources.ResourceTypePBS,
|
|
Name: serverName,
|
|
Hostname: pulseTokenHostCandidate(host),
|
|
HostURL: host,
|
|
}) {
|
|
return
|
|
}
|
|
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbsNode)
|
|
}
|
|
}
|
|
|
|
// Save configuration
|
|
h.normalizePVEConfigState(r.Context())
|
|
if err := h.getPersistence(r.Context()).SaveNodesConfig(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-registered node")
|
|
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
actualName := h.findInstanceNameByHost(r.Context(), req.Type, host)
|
|
if actualName == "" {
|
|
actualName = serverName
|
|
}
|
|
h.markAutoRegistered(req.Type, actualName)
|
|
|
|
// Reload monitor
|
|
if 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)
|
|
|
|
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,
|
|
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,
|
|
})
|
|
}
|