Pulse/internal/api/config_setup_handlers.go
rcourtman f850099134 Adopt the agent's preferred host when re-registering a matched node
The resolved-identity match in canonical auto-register always preserved
the stored host, so the route-aware IP preference added to the agent in
022f170df could never take effect for an already-registered node: a
reinstall re-matched the node and kept the stale short-DNS host forever.
Preserve the stored host only when it is absent from the agent's ordered
candidate list (an admin-managed endpoint the agent cannot see); when the
agent itself lists the stored host as a lower-priority candidate, adopt
the newly selected host. Identity continuity (same node record, same
token) is unchanged.

Note: commit c21693489 carries this same message by mistake; it actually
contains concurrent OIDC/SAML mapping work from a parallel session that
was staged when the shared index raced. This commit is the real change.
2026-07-09 09:56:04 +01:00

1707 lines
58 KiB
Go

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 := ""
storageRepairPerms := ""
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`
storageRepairPerms = `
pveum aclmod /storage -user pulse-monitor@pve -role PVEDatastoreAdmin
if pulse_pve_token_exists; 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,
StorageRepairPerms: storageRepairPerms,
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
}
// shouldPreserveExistingAutoRegisterHost reports whether an identity-matched
// node keeps its stored host on re-registration. The agent orders
// candidateHosts by preference, so an existing host that appears anywhere in
// that list was deliberately outranked by the selected host and is replaced.
// A host absent from the list is an admin-managed endpoint the agent cannot
// see; keep it.
func shouldPreserveExistingAutoRegisterHost(existingHost string, candidateHosts []string) bool {
existing := strings.TrimSpace(existingHost)
for _, candidate := range candidateHosts {
if strings.EqualFold(strings.TrimSpace(candidate), existing) {
return false
}
}
return true
}
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 autoRegisterClusterEndpointMatchesCandidates(endpoint config.ClusterEndpoint, candidates []string) bool {
for _, endpointHost := range []string{
endpoint.Host,
endpoint.GuestURL,
endpoint.IPOverride,
endpoint.IP,
} {
if autoRegisterHostMatchesCandidates(endpointHost, candidates) {
return true
}
if normalized, err := normalizeNodeHost(endpointHost, "pve"); err == nil && autoRegisterHostMatchesCandidates(normalized, candidates) {
return true
}
}
return false
}
func autoRegisterPVEInstanceMatchesCandidates(node config.PVEInstance, candidates []string) (matched bool, primary bool) {
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
return true, true
}
for _, endpoint := range node.ClusterEndpoints {
if autoRegisterClusterEndpointMatchesCandidates(endpoint, candidates) {
return true, false
}
}
return false, false
}
func autoRegisteredPVEInstanceExists(instances []config.PVEInstance, connStatuses map[string]bool, candidates []string) bool {
for _, node := range instances {
matched, primary := autoRegisterPVEInstanceMatchesCandidates(node, candidates)
if !matched {
continue
}
// A non-primary cluster endpoint is already covered by the cluster
// connection. Letting endpoint agents rotate the shared PVE token can
// invalidate the primary configured connection.
if primary && isKnownDisconnected(connStatuses, "pve", node.Name, node.Host) {
return false
}
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":
return autoRegisteredPVEInstanceExists(h.getConfig(ctx).PVEInstances, connStatuses, candidates)
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 = shouldPreserveExistingAutoRegisterHost(node.Host, candidateHosts)
log.Info().
Str("existingHost", node.Host).
Str("newHost", host).
Bool("preserveHost", preserveHost).
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 = shouldPreserveExistingAutoRegisterHost(node.Host, candidateHosts)
log.Info().
Str("existingHost", node.Host).
Str("newHost", host).
Bool("preserveHost", preserveHost).
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"
EnableCommands bool `json:"enableCommands,omitempty"`
}
// 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,
EnableCommands: req.EnableCommands,
})
log.Info().
Str("type", installType).
Bool("token_issued", rawToken != "").
Bool("enable_commands", req.EnableCommands).
Msg("Generated agent install command")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AgentInstallCommandResponse{
Command: command,
Token: rawToken,
})
}