Files
pulse/internal/api/configapi/config_setup_handlers.go
T
pulse-triage[bot] 919009d767 fix(config): honour explicit operator VerifySSL across PVE merge
ConsolidatePVEInstances promoted VerifySSL false->true whenever a merged duplicate or standalone had it enabled, silently re-enabling a disabled Verify SSL certificate setting on every save, load and monitor reconciliation (#2140). The add and update handlers now record an explicit operator choice in VerifySSLExplicit; a merge never overrides it, while the historical promotion is kept when neither side recorded a choice so an existing secure connection is not downgraded. A merged fingerprint still pins the peer.

Regression tests cover the standalone and duplicate-cluster merges and the reported PUT-save path through normalizePVEConfigState.

Change-source: pulse-maintainer
2026-09-21 11:47:26 +01:00

2690 lines
98 KiB
Go

package configapi
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"github.com/rcourtman/pulse-go-rewrite/internal/api/apicontext"
"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.
// Ordinary runtime tokens remain update-only. A Proxmox install token minted by
// a settings:write endpoint may additionally carry a bounded grant to create
// the declared PVE/PBS source: one create per canonical type, each one-shot,
// all of them sharing the token's single mint-age TTL and hostname binding.
type agentAutoRegContextKey struct{}
type agentAutoRegAuth struct {
tokenID string
installBootstrap bool
}
func getAgentAutoRegAuth(ctx context.Context) (agentAutoRegAuth, bool) {
v, ok := ctx.Value(agentAutoRegContextKey{}).(agentAutoRegAuth)
return v, ok
}
func isAgentAutoRegAuth(ctx context.Context) bool {
_, ok := getAgentAutoRegAuth(ctx)
return ok
}
const (
proxmoxInstallRegistrationCompletedKey = "proxmox_registration_completed"
proxmoxInstallRegistrationTypeKey = "proxmox_registration_type"
proxmoxInstallRegistrationHostnameKey = "proxmox_registration_hostname"
proxmoxInstallRegistrationHostKey = "proxmox_registration_host"
proxmoxInstallRegistrationNodeKey = "proxmox_registration_node"
proxmoxInstallRegistrationAtKey = "proxmox_registration_at"
// proxmoxInstallRegistrationConsumedTypesKey lists the canonical Proxmox
// types whose one-shot source-creation grant this token has already spent.
// Its presence is also the marker that distinguishes a record written by a
// per-type-aware server from a pre-#1644-followup record, where the bare
// proxmox_registration_completed flag meant "every type is spent".
proxmoxInstallRegistrationConsumedTypesKey = "proxmox_registration_consumed_types"
// agentInstallTokenIssuedAtKey records when an install token was minted so
// the one-shot source-creation grant can expire independently of the
// token's own (unbounded) reporting lifetime.
agentInstallTokenIssuedAtKey = "install_issued_at"
)
// canonicalAutoRegisterTypes is the fixed set of source types an install token
// may bootstrap. A combined PVE+PBS host — an officially supported deployment —
// consumes one grant per type, so the set doubles as the "everything is spent"
// expansion for legacy completion markers.
var canonicalAutoRegisterTypes = []string{"pve", "pbs"}
// proxmoxInstallRegistrationTypedKey suffixes a registration metadata key with
// the canonical type it describes, so a combined host keeps the PVE and PBS
// completion details side by side instead of overwriting one with the other.
func proxmoxInstallRegistrationTypedKey(baseKey, nodeType string) string {
return baseKey + "_" + strings.TrimSpace(nodeType)
}
// proxmoxInstallRegistrationConsumedTypeSet reports which canonical types have
// already spent their bootstrap grant on this token.
//
// tracked distinguishes the two record shapes. A record written by a per-type
// server carries proxmox_registration_consumed_types and the returned set is
// exactly what it lists. A record written before per-type consumption existed
// carries only proxmox_registration_completed=true, which meant the token was
// spent outright; those expand to every canonical type so an upgrade can never
// hand an already-used token a second create. A consumed-types value that
// parses to nothing usable falls back to the same conservative expansion.
func proxmoxInstallRegistrationConsumedTypeSet(record *config.APITokenRecord) (map[string]struct{}, bool) {
if record == nil {
return nil, false
}
consumed := make(map[string]struct{}, len(canonicalAutoRegisterTypes))
for _, raw := range strings.Split(record.Metadata[proxmoxInstallRegistrationConsumedTypesKey], ",") {
nodeType := strings.TrimSpace(raw)
if isCanonicalAutoRegisterType(nodeType) {
consumed[nodeType] = struct{}{}
}
}
if len(consumed) > 0 {
return consumed, true
}
if strings.EqualFold(strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationCompletedKey]), "true") {
for _, nodeType := range canonicalAutoRegisterTypes {
consumed[nodeType] = struct{}{}
}
}
return consumed, false
}
// proxmoxInstallRegistrationConsumedForType reports whether requestedType has
// already spent its one-shot grant on this token.
func proxmoxInstallRegistrationConsumedForType(record *config.APITokenRecord, requestedType string) bool {
consumed, _ := proxmoxInstallRegistrationConsumedTypeSet(record)
_, spent := consumed[strings.TrimSpace(requestedType)]
return spent
}
// proxmoxInstallRegistrationConsumedTypesValue renders the consumed-type set
// plus requestedType in a stable order, so the metadata value does not churn
// with map iteration and stays readable in a token dump.
func proxmoxInstallRegistrationConsumedTypesValue(record *config.APITokenRecord, requestedType string) string {
consumed, _ := proxmoxInstallRegistrationConsumedTypeSet(record)
if consumed == nil {
consumed = make(map[string]struct{}, len(canonicalAutoRegisterTypes))
}
consumed[strings.TrimSpace(requestedType)] = struct{}{}
ordered := make([]string, 0, len(canonicalAutoRegisterTypes))
for _, nodeType := range canonicalAutoRegisterTypes {
if _, ok := consumed[nodeType]; ok {
ordered = append(ordered, nodeType)
}
}
return strings.Join(ordered, ",")
}
// proxmoxInstallBootstrapGrantTTL bounds how long after mint the one-shot
// Proxmox source-creation grant stays exercisable. Install tokens are minted
// without an expiry because the agent keeps reporting with them forever, so
// without this bound every host install token on a Proxmox box would carry a
// live create-a-source capability for the lifetime of the install. The window
// only has to cover copy-paste-and-run of the installer command.
const proxmoxInstallBootstrapGrantTTL = 24 * time.Hour
const ProxmoxInstallBootstrapGrantTTL = proxmoxInstallBootstrapGrantTTL
// proxmoxInstallGrantExpiredMessage is the distinct denial log for a grant
// that was otherwise valid but is past proxmoxInstallBootstrapGrantTTL. The
// request still takes the ordinary update-only path and its 403.
const proxmoxInstallGrantExpiredMessage = "Proxmox install bootstrap grant expired; install token stays update-only"
const ProxmoxInstallGrantExpiredMessage = proxmoxInstallGrantExpiredMessage
// proxmoxInstallGrantIssuedAt resolves the grant clock for a token record.
// Tokens minted after #1644 carry an explicit install_issued_at; older records
// fall back to the token's own creation timestamp. A record with neither is
// treated as having no grant clock at all, which fails the grant closed.
func proxmoxInstallGrantIssuedAt(record *config.APITokenRecord) (time.Time, bool) {
if record == nil {
return time.Time{}, false
}
if raw := strings.TrimSpace(record.Metadata[agentInstallTokenIssuedAtKey]); raw != "" {
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed.UTC(), true
}
}
if !record.CreatedAt.IsZero() {
return record.CreatedAt.UTC(), true
}
return time.Time{}, false
}
func proxmoxInstallGrantExpiredAt(record *config.APITokenRecord, now time.Time) bool {
issuedAt, ok := proxmoxInstallGrantIssuedAt(record)
if !ok {
return true
}
return now.UTC().Sub(issuedAt) >= proxmoxInstallBootstrapGrantTTL
}
// installTokenTypeGrantsBootstrap decides whether an install token minted for
// installType may bootstrap a source of requestedType. Proxmox-typed tokens
// stay pinned to their declared type. A generic host-agent install token may
// bootstrap either canonical Proxmox type: the unified installer auto-detects
// PVE/PBS on the target machine (#1644), and a host with both installed is an
// officially supported deployment, so it gets one create for each. The token is
// still bounded by the same settings:write mint requirement, the same per-type
// one-shot completion marker, and the same first-use hostname binding as a
// typed token — the binding is shared across types, so whichever type registers
// first pins the hostname for both.
func installTokenTypeGrantsBootstrap(installType, requestedType string) bool {
if installType == requestedType && isCanonicalAutoRegisterType(requestedType) {
return true
}
return installType == agentInstallTypeHost && isCanonicalAutoRegisterType(requestedType)
}
// proxmoxInstallGrantEligible applies every bootstrap-grant bound except the
// mint-age TTL. It is split out so an expired-but-otherwise-valid grant can be
// reported with its own denial log instead of being indistinguishable from a
// token that never held a grant.
func proxmoxInstallGrantEligible(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
if record == nil || req == nil || strings.TrimSpace(req.Source) != "agent" {
return false
}
if proxmoxInstallRegistrationConsumedForType(record, req.Type) {
return false
}
if !installTokenTypeGrantsBootstrap(strings.TrimSpace(record.Metadata["install_type"]), strings.TrimSpace(req.Type)) {
return false
}
switch strings.TrimSpace(record.Metadata["issued_via"]) {
case agentInstallIssuedViaConfig, agentInstallIssuedViaHosted:
default:
return false
}
requestedHostname := strings.TrimSpace(req.ServerName)
if requestedHostname == "" {
return false
}
if boundHostname := strings.TrimSpace(record.Metadata["bound_hostname"]); boundHostname != "" {
return strings.EqualFold(boundHostname, requestedHostname)
}
return true
}
// proxmoxInstallGrantExpiredForRequest reports a grant that would have been
// exercisable but for the mint-age TTL.
func proxmoxInstallGrantExpiredForRequest(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
return proxmoxInstallGrantEligible(record, req) &&
proxmoxInstallGrantExpiredAt(record, time.Now().UTC())
}
func canBootstrapProxmoxInstallRegistration(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
return canBootstrapProxmoxInstallRegistrationAt(record, req, time.Now().UTC())
}
func canBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req *AutoRegisterRequest, now time.Time) bool {
return proxmoxInstallGrantEligible(record, req) && !proxmoxInstallGrantExpiredAt(record, now)
}
func CanBootstrapProxmoxInstallRegistrationAt(record *config.APITokenRecord, req *AutoRegisterRequest, now time.Time) bool {
return canBootstrapProxmoxInstallRegistrationAt(record, req, now)
}
func ProxmoxInstallGrantEligible(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
return proxmoxInstallGrantEligible(record, req)
}
// completedProxmoxInstallRegistrationMatches reports that this exact
// type-and-hostname registration is the one that already spent its grant, which
// makes a repeat completion a no-op rather than a denial. It is deliberately
// narrower than proxmoxInstallRegistrationConsumedForType: a spent grant for a
// different hostname is still a refusal.
func completedProxmoxInstallRegistrationMatches(record *config.APITokenRecord, req *AutoRegisterRequest) bool {
if record == nil || req == nil {
return false
}
requestedType := strings.TrimSpace(req.Type)
requestedHostname := strings.TrimSpace(req.ServerName)
consumed, tracked := proxmoxInstallRegistrationConsumedTypeSet(record)
if _, spent := consumed[requestedType]; !spent {
return false
}
if !tracked {
// Legacy record: the single completion block describes the one
// registration this token performed, whatever type it was for.
return strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationTypeKey]) == requestedType &&
strings.EqualFold(
strings.TrimSpace(record.Metadata[proxmoxInstallRegistrationHostnameKey]),
requestedHostname,
)
}
completedHostname := strings.TrimSpace(
record.Metadata[proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostnameKey, requestedType)],
)
return completedHostname != "" && strings.EqualFold(completedHostname, requestedHostname)
}
// prepareProxmoxInstallBootstrap binds an unused install token to the first
// fully validated host request that presents it.
func (h *ConfigHandlers) prepareProxmoxInstallBootstrap(ctx context.Context, req *AutoRegisterRequest) (bool, error) {
auth, ok := getAgentAutoRegAuth(ctx)
if !ok || !auth.installBootstrap || strings.TrimSpace(auth.tokenID) == "" {
return false, nil
}
config.Mu.Lock()
defer config.Mu.Unlock()
cfg := h.getConfig(ctx)
if cfg == nil {
return false, fmt.Errorf("configuration unavailable")
}
for i := range cfg.APITokens {
record := &cfg.APITokens[i]
if record.ID != auth.tokenID {
continue
}
if !canBootstrapProxmoxInstallRegistration(record, req) {
return false, nil
}
if strings.TrimSpace(record.Metadata["bound_hostname"]) != "" {
return true, nil
}
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
previousMetadata := make(map[string]string, len(record.Metadata))
for key, value := range record.Metadata {
previousMetadata[key] = value
}
record.Metadata["bound_hostname"] = strings.TrimSpace(req.ServerName)
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
if persistence := h.getPersistence(ctx); persistence != nil {
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
record.Metadata = previousMetadata
return false, fmt.Errorf("persist Proxmox install token host binding: %w", err)
}
}
return true, nil
}
return false, fmt.Errorf("Proxmox install token no longer exists")
}
func (h *ConfigHandlers) proxmoxInstallBootstrapAvailable(ctx context.Context, req *AutoRegisterRequest) bool {
auth, ok := getAgentAutoRegAuth(ctx)
if !ok || !auth.installBootstrap || strings.TrimSpace(auth.tokenID) == "" {
return false
}
config.Mu.RLock()
defer config.Mu.RUnlock()
cfg := h.getConfig(ctx)
if cfg == nil {
return false
}
for i := range cfg.APITokens {
record := &cfg.APITokens[i]
if record.ID == auth.tokenID {
return canBootstrapProxmoxInstallRegistration(record, req)
}
}
return false
}
// proxmoxInstallBootstrapConsumption records a grant consumption that can be
// undone when the work it authorized fails to persist. Consumption is durable
// before the source is written, so an unrecoverable SaveNodesConfig must put
// the grant back rather than leave a consumed grant with no source (or, worse,
// leave the grant live next to a persisted source).
//
// The undo is scoped to the keys this one consumption wrote. On a combined
// PVE+PBS host the two types spend their grants in sequence, so a failed PBS
// save must restore the PBS grant without resurrecting the PVE grant that the
// earlier leg already spent on a source that did persist.
type proxmoxInstallBootstrapConsumption struct {
tokenID string
proxmoxType string
// previousValues holds the pre-consumption value of every metadata key this
// consumption wrote. Keys that did not exist beforehand are listed in
// previouslyAbsent and are deleted rather than restored.
previousValues map[string]string
previouslyAbsent map[string]struct{}
consumed bool
}
// recordProxmoxInstallBootstrapConsumption writes the completion metadata for
// one canonical type and returns the undo record for it.
func recordProxmoxInstallBootstrapConsumption(record *config.APITokenRecord, req *AutoRegisterRequest, host, node string) *proxmoxInstallBootstrapConsumption {
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
requestedType := strings.TrimSpace(req.Type)
consumption := &proxmoxInstallBootstrapConsumption{
tokenID: record.ID,
proxmoxType: requestedType,
previousValues: make(map[string]string, 12),
previouslyAbsent: make(map[string]struct{}, 12),
consumed: true,
}
set := func(key, value string) {
if previous, ok := record.Metadata[key]; ok {
consumption.previousValues[key] = previous
} else {
consumption.previouslyAbsent[key] = struct{}{}
}
record.Metadata[key] = value
}
completedAt := time.Now().UTC().Format(time.RFC3339)
// The consumed-type list is the authoritative per-type ledger. Compute it
// before any other write so it reads the pre-consumption state.
consumedTypes := proxmoxInstallRegistrationConsumedTypesValue(record, requestedType)
set(proxmoxInstallRegistrationConsumedTypesKey, consumedTypes)
// Per-type completion details, so a combined host keeps both registrations.
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostnameKey, requestedType), strings.TrimSpace(req.ServerName))
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationHostKey, requestedType), strings.TrimSpace(host))
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationNodeKey, requestedType), strings.TrimSpace(node))
set(proxmoxInstallRegistrationTypedKey(proxmoxInstallRegistrationAtKey, requestedType), completedAt)
// The unsuffixed block stays as the most recent completion. It keeps
// existing operator tooling and any older Pulse binary reading this token
// store failing closed: to a pre-per-type server the bare completed flag
// still reads as "this token is spent".
set(proxmoxInstallRegistrationCompletedKey, "true")
set(proxmoxInstallRegistrationTypeKey, requestedType)
set(proxmoxInstallRegistrationHostnameKey, strings.TrimSpace(req.ServerName))
set(proxmoxInstallRegistrationHostKey, strings.TrimSpace(host))
set(proxmoxInstallRegistrationNodeKey, strings.TrimSpace(node))
set(proxmoxInstallRegistrationAtKey, completedAt)
return consumption
}
// restore undoes the metadata writes this consumption made, leaving every other
// key — including a sibling type's completed grant — untouched.
func (c *proxmoxInstallBootstrapConsumption) restore(metadata map[string]string) {
if metadata == nil {
return
}
for key, value := range c.previousValues {
metadata[key] = value
}
for key := range c.previouslyAbsent {
delete(metadata, key)
}
}
func (h *ConfigHandlers) completeProxmoxInstallBootstrap(ctx context.Context, req *AutoRegisterRequest, host, node string) (*proxmoxInstallBootstrapConsumption, error) {
auth, ok := getAgentAutoRegAuth(ctx)
if !ok || !auth.installBootstrap || strings.TrimSpace(auth.tokenID) == "" {
return nil, nil
}
persistence := h.getPersistence(ctx)
config.Mu.Lock()
defer config.Mu.Unlock()
cfg := h.getConfig(ctx)
if cfg == nil {
return nil, fmt.Errorf("configuration unavailable")
}
for i := range cfg.APITokens {
record := &cfg.APITokens[i]
if record.ID != auth.tokenID {
continue
}
if completedProxmoxInstallRegistrationMatches(record, req) {
return nil, nil
}
if !canBootstrapProxmoxInstallRegistration(record, req) {
return nil, fmt.Errorf("Proxmox install registration grant is no longer available")
}
consumption := recordProxmoxInstallBootstrapConsumption(record, req, host, node)
if persistence != nil {
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
consumption.restore(record.Metadata)
return nil, fmt.Errorf("persist Proxmox install registration grant: %w", err)
}
}
return consumption, nil
}
return nil, fmt.Errorf("Proxmox install token no longer exists")
}
// rollbackProxmoxInstallBootstrap restores a grant consumed by
// completeProxmoxInstallBootstrap. It is best effort by necessity — the token
// store just failed us once — but it keeps the common case atomic: either the
// source is persisted and the grant is spent, or neither happened. Only the
// canonical type this consumption spent is restored; a sibling type already
// registered from the same install token stays consumed.
func (h *ConfigHandlers) rollbackProxmoxInstallBootstrap(ctx context.Context, consumption *proxmoxInstallBootstrapConsumption) {
if consumption == nil || !consumption.consumed {
return
}
persistence := h.getPersistence(ctx)
config.Mu.Lock()
defer config.Mu.Unlock()
cfg := h.getConfig(ctx)
if cfg == nil {
return
}
for i := range cfg.APITokens {
record := &cfg.APITokens[i]
if record.ID != consumption.tokenID {
continue
}
if record.Metadata == nil {
record.Metadata = make(map[string]string)
}
consumption.restore(record.Metadata)
if persistence != nil {
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
log.Error().
Err(err).
Str("token_id", consumption.tokenID).
Str("type", consumption.proxmoxType).
Msg("Failed to restore Proxmox install registration grant after a failed source save")
return
}
}
log.Warn().
Str("token_id", consumption.tokenID).
Str("type", consumption.proxmoxType).
Msg("Restored Proxmox install registration grant after a failed source save")
return
}
}
// 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"`
// Name is the optional connection name the user typed in the add dialog.
// It rides the setup token so the auto-registration that the setup script
// triggers can name the connection what the user asked for, rather than
// deriving it from the node's hostname.
Name string `json:"name,omitempty"`
}
// setupDesiredNameContextKey carries the user's typed connection name from
// setup-token validation to the auto-registration path within one request.
type setupDesiredNameContextKey struct{}
func setupDesiredNameFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if name, ok := ctx.Value(setupDesiredNameContextKey{}).(string); ok {
return strings.TrimSpace(name)
}
return ""
}
// 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)
req.Name = strings.TrimSpace(req.Name)
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
pulseURL := h.resolveConfigAgentInstallBaseURL(r, h.getConfig(r.Context()))
if pulseURL == "" {
writeConfigAgentInstallBaseURLUnavailable(w)
return
}
// 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: apicontext.OrgID(r.Context()),
DesiredName: req.Name,
}
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.
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 IsCanonicalAutoRegisterType(nodeType string) bool { return isCanonicalAutoRegisterType(nodeType) }
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 IsCanonicalAutoRegisterTokenID(nodeType string, tokenID string) bool {
return isCanonicalAutoRegisterTokenID(nodeType, tokenID)
}
func isCanonicalAutoRegisterSource(source string) bool {
switch strings.TrimSpace(source) {
case "agent", "script":
return true
default:
return false
}
}
func IsCanonicalAutoRegisterSource(source string) bool { return isCanonicalAutoRegisterSource(source) }
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 CanonicalAutoRegisterMatchMessage(reason string) string {
return canonicalAutoRegisterMatchMessage(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
}
// canonicalAutoRegisterCandidate is the identity view of an already-configured
// instance that canonical auto-registration matches an incoming registration
// against. PVE and PBS instances are projected onto this shape so one matcher
// serves both node types.
type canonicalAutoRegisterCandidate struct {
Host string
Name string
Fingerprint string
TokenName string
}
// canonicalAutoRegisterIdentity is the identity of the incoming registration.
// candidateHosts is the agent's preference-ordered address list, used to decide
// whether an identity-matched node keeps its stored host.
type canonicalAutoRegisterIdentity struct {
host string
serverName string
fingerprint string
tokenName string
candidateHosts []string
}
func pveAutoRegisterCandidate(instance config.PVEInstance) canonicalAutoRegisterCandidate {
return canonicalAutoRegisterCandidate{
Host: instance.Host,
Name: instance.Name,
Fingerprint: instance.Fingerprint,
TokenName: instance.TokenName,
}
}
func pbsAutoRegisterCandidate(instance config.PBSInstance) canonicalAutoRegisterCandidate {
return canonicalAutoRegisterCandidate{
Host: instance.Host,
Name: instance.Name,
Fingerprint: instance.Fingerprint,
TokenName: instance.TokenName,
}
}
// autoRegisterCandidates projects configured instances onto the identity view
// findCanonicalAutoRegisterMatch compares against.
func autoRegisterCandidates[T any](instances []T, view func(T) canonicalAutoRegisterCandidate) []canonicalAutoRegisterCandidate {
candidates := make([]canonicalAutoRegisterCandidate, len(instances))
for i, instance := range instances {
candidates[i] = view(instance)
}
return candidates
}
// findCanonicalAutoRegisterMatch reports which configured instance an incoming
// canonical auto-registration should update in place, and whether that instance
// keeps its stored host. It returns (-1, false) when the registration belongs to
// a node Pulse has not seen, which is the caller's signal to create one.
//
// The ladder, in order: exact host match, then — only for candidates whose TLS
// fingerprint does not conflict with the incoming one — resolved host identity
// (the same box reached by a different address), then DHCP continuity by node
// name plus token identity. nodeType ("pve"/"pbs") is log attribution only.
func findCanonicalAutoRegisterMatch(nodeType string, incoming canonicalAutoRegisterIdentity, candidates []canonicalAutoRegisterCandidate) (int, bool) {
for i, node := range candidates {
if node.Host == incoming.host {
return i, false
}
if config.ProxmoxFingerprintsConflict(node.Fingerprint, incoming.fingerprint) {
log.Warn().
Str("existingHost", node.Host).
Str("newHost", incoming.host).
Str("type", nodeType).
Msg("Canonical auto-register rejected inferred node match because TLS fingerprints conflict")
continue
}
if hostsShareResolvedIdentity(node.Host, incoming.host) {
preserveHost := shouldPreserveExistingAutoRegisterHost(node.Host, incoming.candidateHosts)
log.Info().
Str("existingHost", node.Host).
Str("newHost", incoming.host).
Bool("preserveHost", preserveHost).
Str("type", nodeType).
Msg(canonicalAutoRegisterMatchMessage("resolved host identity"))
return i, preserveHost
}
if incoming.serverName != "" &&
strings.EqualFold(strings.TrimSpace(node.Name), incoming.serverName) &&
node.TokenName == incoming.tokenName {
log.Info().
Str("existingHost", node.Host).
Str("newHost", incoming.host).
Str("type", nodeType).
Str("node", incoming.serverName).
Msg(canonicalAutoRegisterMatchMessage("DHCP continuity token identity"))
return i, false
}
}
return -1, false
}
// clusterMemberOverrideIdentity canonicalizes a connection address (bare
// host, host:port, or URL) to lowercase host:port for comparisons, defaulting
// to the PVE API port. Mirrors config's cluster endpoint identity
// normalization.
func clusterMemberOverrideIdentity(value string) string {
v := strings.TrimSpace(value)
if v == "" {
return ""
}
if !strings.Contains(v, "://") {
v = "https://" + v
}
parsed, err := url.Parse(v)
if err != nil || parsed.Hostname() == "" {
return ""
}
host := strings.ToLower(parsed.Hostname())
if ip := net.ParseIP(host); ip != nil {
host = ip.String()
}
port := parsed.Port()
if port == "" {
port = config.DefaultPVEPort
}
return net.JoinHostPort(host, port)
}
// findCanonicalAutoRegisterClusterMember locates the cluster member endpoint
// an auto-registering agent corresponds to when no top-level instance host
// matched. Endpoint address identity against the agent's candidate list is
// authoritative; a corosync node-name match (agent hostnames may be FQDNs
// while corosync names are short) is accepted only when it is unambiguous
// across every configured instance.
func findCanonicalAutoRegisterClusterMember(instances []config.PVEInstance, serverName string, candidateHosts []string) (int, int, bool) {
for i := range instances {
for j := range instances[i].ClusterEndpoints {
if autoRegisterClusterEndpointMatchesCandidates(instances[i].ClusterEndpoints[j], candidateHosts) {
return i, j, true
}
}
}
name := strings.ToLower(strings.TrimSpace(serverName))
if name == "" {
return -1, -1, false
}
short := name
if dot := strings.IndexByte(short, '.'); dot > 0 {
short = short[:dot]
}
matchInstance, matchEndpoint, matches := -1, -1, 0
for i := range instances {
for j := range instances[i].ClusterEndpoints {
nodeName := strings.ToLower(strings.TrimSpace(instances[i].ClusterEndpoints[j].NodeName))
if nodeName == "" {
continue
}
if nodeName == name || nodeName == short {
matchInstance, matchEndpoint = i, j
matches++
}
}
}
if matches == 1 {
return matchInstance, matchEndpoint, true
}
return -1, -1, false
}
// shouldAdoptClusterMemberAutoRegisterAddress reports whether a member-matched
// re-registration may replace the endpoint's connection override. Discovered
// member Host/IP are rebuilt from cluster status on every re-discovery, so an
// empty override always adopts the agent's Pulse-verified address. An existing
// override is replaced only when it appears in the agent's candidate list
// (deliberately outranked, the member analogue of
// shouldPreserveExistingAutoRegisterHost); an override the agent cannot see is
// admin-managed and kept.
func shouldAdoptClusterMemberAutoRegisterAddress(existingOverride string, candidateHosts []string) bool {
override := clusterMemberOverrideIdentity(existingOverride)
if override == "" {
return true
}
for _, candidate := range candidateHosts {
if identity := clusterMemberOverrideIdentity(candidate); identity != "" && identity == override {
return true
}
}
return false
}
// adoptCanonicalAutoRegisterClusterMember handles re-registration of an agent
// whose node is a non-primary cluster member rather than a top-level instance:
// the cluster connection already covers it, so instead of creating a
// standalone duplicate (which ConsolidatePVEInstances would fold back in,
// discarding the agent's address), adopt the Pulse-verified selected host as
// the member's connection override. A covered member must not replace the
// cluster's credentials with its own per-node token; the only credential
// writes are a same-token-identity secret refresh (the agent rotates its
// token in place on reinstall, so the stored secret is already dead) and a
// full promotion onto a cluster that has no credentials at all.
func (h *ConfigHandlers) adoptCanonicalAutoRegisterClusterMember(ctx context.Context, serverName, selectedHost, fingerprint, tokenID, tokenValue string, candidateHosts []string, source string) bool {
instances := h.getConfig(ctx).PVEInstances
i, j, ok := findCanonicalAutoRegisterClusterMember(instances, serverName, candidateHosts)
if !ok {
return false
}
instance := &instances[i]
endpoint := &instance.ClusterEndpoints[j]
selectedIdentity := clusterMemberOverrideIdentity(selectedHost)
effectiveIdentity := clusterMemberOverrideIdentity(endpoint.EffectiveIP())
willAdoptSelectedAddress := selectedIdentity != "" &&
selectedIdentity != effectiveIdentity &&
shouldAdoptClusterMemberAutoRegisterAddress(endpoint.IPOverride, candidateHosts)
if willAdoptSelectedAddress && config.ProxmoxFingerprintsConflict(endpoint.Fingerprint, fingerprint) {
log.Warn().
Str("cluster", instance.Name).
Str("node", endpoint.NodeName).
Str("existingHost", endpoint.EffectiveIP()).
Str("newHost", selectedHost).
Msg("Canonical auto-register rejected inferred cluster member match because TLS fingerprints conflict")
return false
}
switch {
case tokenID != "" && instance.TokenName == tokenID:
if tokenValue != "" {
instance.TokenValue = tokenValue
}
case tokenID != "" && instance.TokenName == "" && instance.TokenValue == "" && instance.User == "" && instance.Password == "":
instance.TokenName = tokenID
instance.TokenValue = tokenValue
}
if instance.Source == "" && strings.TrimSpace(source) != "" {
instance.Source = strings.TrimSpace(source)
}
switch {
case selectedIdentity == "":
return true
case selectedIdentity == effectiveIdentity:
// The member's effective address already routes to the agent's
// selection; just refresh the certificate pin below.
case shouldAdoptClusterMemberAutoRegisterAddress(endpoint.IPOverride, candidateHosts):
override, err := normalizeClusterEndpointIPOverride(selectedHost)
if err != nil || override == "" {
return true
}
log.Info().
Str("cluster", instances[i].Name).
Str("node", endpoint.NodeName).
Str("previousOverride", endpoint.IPOverride).
Str("override", override).
Msg(canonicalAutoRegisterMatchMessage("cluster member endpoint; adopted agent connection address"))
endpoint.IPOverride = override
default:
// Admin-managed override the agent cannot see; keep it and leave the
// fingerprint pinned to that address.
log.Info().
Str("cluster", instances[i].Name).
Str("node", endpoint.NodeName).
Str("override", endpoint.IPOverride).
Msg(canonicalAutoRegisterMatchMessage("cluster member endpoint; preserved admin-managed override"))
return true
}
// The fingerprint was captured from selectedHost, which is now the
// member's effective address; a reinstall may have regenerated the node's
// TLS certificate, so refresh the pin whenever one was captured.
if strings.TrimSpace(fingerprint) != "" {
endpoint.Fingerprint = fingerprint
}
return true
}
func canonicalAutoRegisterCompletionPayloadMessage() string {
return "Incomplete canonical auto-register token completion payload"
}
func CanonicalAutoRegisterCompletionPayloadMessage() string {
return canonicalAutoRegisterCompletionPayloadMessage()
}
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 CanonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName string) string {
return canonicalAutoRegisterCheckMissingFieldsMessage(typeValue, host, serverName)
}
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 CanonicalAutoRegisterMissingFieldsMessage(typeValue, host string, hasTokenID bool, serverName string) string {
return canonicalAutoRegisterMissingFieldsMessage(typeValue, host, hasTokenID, serverName)
}
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 CanonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName string) string {
return canonicalAutoUnregisterMissingFieldsMessage(typeValue, host, serverName)
}
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 := apicontext.OrgID(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(), apicontext.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(), apicontext.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"`
SourceExists bool `json:"sourceExists"`
CanRegister bool `json:"canRegister"`
}
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
}
func (h *ConfigHandlers) autoRegisteredNodeConfigured(ctx context.Context, req *AutoRegisterRequest, candidates []string) bool {
if req == nil {
return false
}
cfg := h.getConfig(ctx)
if cfg == nil {
return false
}
switch req.Type {
case "pve":
for _, node := range cfg.PVEInstances {
if matched, _ := autoRegisterPVEInstanceMatchesCandidates(node, candidates); matched {
return true
}
}
case "pbs":
for _, node := range cfg.PBSInstances {
if autoRegisterHostMatchesCandidates(node.Host, candidates) {
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(), apicontext.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
// Carry the name the user typed in the add dialog to the
// registration path, same transport as the token's OrgID.
if trimmed := strings.TrimSpace(setupTokenRecord.DesiredName); trimmed != "" {
r = r.WithContext(context.WithValue(r.Context(), setupDesiredNameContextKey{}, trimmed))
}
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
// an existing Proxmox node. Ordinary tokens remain update-only. A
// settings-authorized Proxmox install token may additionally bootstrap one
// source of its declared type, bound to the first presenting hostname.
if !authenticated {
if apiToken, hasToken := explicitAPITokenFromRequest(r); hasToken {
config.Mu.Lock()
cfg := h.getConfig(r.Context())
if cfg != nil {
record, ok := cfg.ValidateAPIToken(apiToken)
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 := apicontext.OrgID(r.Context())
orgMismatch := requestOrgID != "" && requestOrgID != "default" &&
record.OrgID != "" && record.OrgID != requestOrgID
if !orgMismatch {
installBootstrap := canBootstrapProxmoxInstallRegistration(record, &req)
if !installBootstrap && proxmoxInstallGrantExpiredForRequest(record, &req) {
log.Warn().
Str("token_id", record.ID).
Str("type", req.Type).
Str("host", req.Host).
Str("grant_ttl", proxmoxInstallBootstrapGrantTTL.String()).
Msg(proxmoxInstallGrantExpiredMessage)
}
authenticated = true
r = r.WithContext(context.WithValue(r.Context(), agentAutoRegContextKey{}, agentAutoRegAuth{
tokenID: record.ID,
installBootstrap: installBootstrap,
}))
event := log.Info()
if req.CheckRegistration {
event = log.Debug()
}
message := "Auto-register authenticated via agent API token (update-only mode)"
if installBootstrap {
message = "Auto-register authenticated via Proxmox install token"
}
event.
Str("type", req.Type).
Str("host", req.Host).
Bool("install_bootstrap", installBootstrap).
Msg(message)
}
}
}
config.Mu.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
}
// Resolve the source through the root security policy supplied at wiring time.
clientIP := h.runtimeDependencies().ClientIP(r)
requestEvent := log.Info()
if req.CheckRegistration {
requestEvent = log.Debug()
}
requestEvent.Str("clientIP", clientIP).Msg("Auto-register request from")
requestEvent = log.Info()
if req.CheckRegistration {
requestEvent = log.Debug()
}
requestEvent.
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) {
event := log.Info()
if req.CheckRegistration {
event = log.Debug()
}
event.
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
// Serialize checks and completions. Checks can bind or consume an install
// grant, and must not race the source mutation performed by completion.
h.autoRegisterMutex.Lock()
defer h.autoRegisterMutex.Unlock()
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
}
if _, err := h.prepareProxmoxInstallBootstrap(r.Context(), req); err != nil {
log.Error().
Err(err).
Str("type", req.Type).
Str("host", req.Host).
Msg("Failed to bind Proxmox install registration token")
http.Error(w, "Failed to persist Proxmox install registration authorization", http.StatusInternalServerError)
return
}
sourceExists := h.autoRegisteredNodeConfigured(r.Context(), req, normalizedCandidates)
registered := h.autoRegisteredNodeExists(r.Context(), req, normalizedCandidates)
if registered {
actualName := h.findInstanceNameByHost(r.Context(), req.Type, normalizedCandidates[0])
if actualName == "" {
actualName = req.ServerName
}
if _, err := h.completeProxmoxInstallBootstrap(r.Context(), req, normalizedCandidates[0], actualName); err != nil {
log.Error().
Err(err).
Str("type", req.Type).
Str("host", req.Host).
Msg("Failed to consume Proxmox install registration grant for existing source")
http.Error(w, "Failed to finalize Proxmox install registration authorization", http.StatusInternalServerError)
return
}
}
canRegister := sourceExists || !isAgentAutoRegAuth(r.Context()) ||
h.proxmoxInstallBootstrapAvailable(r.Context(), req)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(autoRegisterCheckResponse{
Registered: registered,
SourceExists: sourceExists,
CanRegister: canRegister,
}); 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
}
if _, err := h.prepareProxmoxInstallBootstrap(r.Context(), req); err != nil {
log.Error().
Err(err).
Str("type", req.Type).
Str("host", req.Host).
Msg("Failed to bind Proxmox install registration token")
http.Error(w, "Failed to persist Proxmox install registration authorization", http.StatusInternalServerError)
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)
incomingIdentity := canonicalAutoRegisterIdentity{
host: host,
serverName: serverName,
fingerprint: fingerprint,
tokenName: fullTokenID,
candidateHosts: candidateHosts,
}
// Add the node to configuration. A name typed in the add dialog rides the
// setup token as the desired name and wins over the node's own hostname;
// the dedup/adoption identity below still matches on serverName so
// renaming cannot fork an existing connection.
connectionName := serverName
if desired := setupDesiredNameFromContext(r.Context()); desired != "" {
connectionName = desired
}
created := false
if req.Type == "pve" {
pveDisplayName := h.disambiguateNodeName(r.Context(), connectionName, 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, preserveHost := findCanonicalAutoRegisterMatch("pve", incomingIdentity,
autoRegisterCandidates(h.getConfig(r.Context()).PVEInstances, pveAutoRegisterCandidate))
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
}
// Preserve the operator's TLS choice on an existing connection.
// The incoming value records only whether this registration
// captured a fingerprint; applying it unconditionally re-enabled a
// disabled "Verify SSL certificate" setting on every agent
// health-check re-registration (#2140). Heal only the legacy state
// where strict verification is on with no pin and no recorded
// operator choice, which can never connect to a self-signed
// endpoint (#1303).
if !instance.VerifySSLExplicit && instance.VerifySSL && instance.Fingerprint == "" {
instance.VerifySSL = false
}
log.Info().Str("host", host).Str("type", "pve").Msg(canonicalAutoRegisterMatchMessage("host; updated token in-place"))
} else if h.adoptCanonicalAutoRegisterClusterMember(r.Context(), serverName, host, fingerprint, fullTokenID, tokenValue, candidateHosts, registrationSource) {
// A non-primary cluster member: the cluster connection already
// covers this node, so no instance is created and the shared
// cluster token is left untouched.
log.Info().Str("host", host).Str("type", "pve").Msg(canonicalAutoRegisterMatchMessage("cluster member endpoint"))
} else {
// Agent-token auth is restricted to updating existing nodes only.
if isAgentAutoRegAuth(r.Context()) && !h.proxmoxInstallBootstrapAvailable(r.Context(), req) {
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(), connectionName, 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, preserveHost := findCanonicalAutoRegisterMatch("pbs", incomingIdentity,
autoRegisterCandidates(h.getConfig(r.Context()).PBSInstances, pbsAutoRegisterCandidate))
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
}
// Preserve the operator's TLS choice on an existing connection.
// The incoming value records only whether this registration
// captured a fingerprint; applying it unconditionally re-enabled a
// disabled "Verify SSL certificate" setting on every agent
// health-check re-registration (#2140). Heal only the legacy state
// where strict verification is on with no pin, which can never
// connect to a self-signed endpoint (#1303).
if instance.VerifySSL && instance.Fingerprint == "" {
instance.VerifySSL = false
}
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()) && !h.proxmoxInstallBootstrapAvailable(r.Context(), req) {
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
actualName := h.findInstanceNameByHost(r.Context(), req.Type, host)
if actualName == "" {
actualName = serverName
}
// Consume the one-shot install grant BEFORE the source is persisted. The
// other order leaves a repeatable create-a-source primitive whenever the
// token store write fails persistently: the node lands on disk while the
// grant stays unconsumed, so the same install token can be replayed. If
// the node save then fails, the consumption is rolled back so the failure
// stays atomic and a genuine retry still works.
consumption, err := h.completeProxmoxInstallBootstrap(r.Context(), req, host, actualName)
if err != nil {
log.Error().
Err(err).
Str("type", req.Type).
Str("host", host).
Msg("Failed to consume Proxmox install registration grant")
http.Error(w, "Failed to finalize Proxmox install registration authorization", http.StatusInternalServerError)
return
}
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")
h.rollbackProxmoxInstallBootstrap(r.Context(), consumption)
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")
}
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)
}
func (h *ConfigHandlers) HandleCanonicalAutoRegister(w http.ResponseWriter, r *http.Request, req *AutoRegisterRequest, clientIP string) {
h.handleCanonicalAutoRegister(w, r, req, clientIP)
}
// 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: " + "/docs/SECURITY.md#critical-security-notice-for-container-deployments")
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,
}
}
func (h *ConfigHandlers) GetOrGenerateSSHKeys() SSHKeyPair { return h.getOrGenerateSSHKeys() }
// 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", "pbs", or "host"
EnableCommands bool `json:"enableCommands,omitempty"`
Insecure bool `json:"insecure,omitempty"`
Name string `json:"name,omitempty"` // optional token name (host installs)
}
// AgentInstallCommandResponse contains the generated install command
type AgentInstallCommandResponse struct {
Command string `json:"command"`
Token string `json:"token"`
Record *apiTokenDTO `json:"record,omitempty"`
}
// 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
}
if strings.EqualFold(strings.TrimSpace(req.Type), agentInstallTypeHost) {
h.handleHostAgentInstallToken(w, r, req)
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())
baseURL := h.resolveConfigAgentInstallBaseURL(r, cfg)
if baseURL == "" {
writeConfigAgentInstallBaseURLUnavailable(w)
return
}
rawToken := ""
if h.authConfiguredForAgentLifecycle(cfg) {
tokenName := fmt.Sprintf("proxmox-agent-%s-%d", installType, time.Now().Unix())
rawToken, _, err = issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{
TokenName: tokenName,
OwnerUserID: h.apiTokenOwnerUserIDForRequest(cfg, r),
Scopes: proxmoxAgentInstallScopes(req.EnableCommands),
Metadata: map[string]string{
"install_type": installType,
"issued_via": "config_agent_install_command",
agentInstallCommandPolicyIntentKey: agentInstallCommandPolicyIntent(req.EnableCommands),
},
})
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
}
}
command := buildProxmoxAgentInstallCommand(agentInstallCommandOptions{
BaseURL: baseURL,
Token: rawToken,
InstallType: installType,
IncludeInstallType: true,
EnableCommands: req.EnableCommands,
Insecure: req.Insecure,
})
log.Info().
Str("type", installType).
Bool("token_issued", rawToken != "").
Bool("enable_commands", req.EnableCommands).
Bool("insecure", req.Insecure).
Msg("Generated agent install command")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AgentInstallCommandResponse{
Command: command,
Token: rawToken,
})
}
// handleHostAgentInstallToken mints an install token for the generic unified
// host agent flow (Settings > Infrastructure > Add Pulse Agent). The exec
// scope must be decided here at mint time, because scopes cannot be upgraded
// on an existing token; the frontend regenerates the token when the operator
// toggles command execution. The install_type metadata makes the token
// eligible for first-use binding in canBindAgentInstallExecToken, without
// which the command channel rejects it even with the scope present (#1586).
func (h *ConfigHandlers) handleHostAgentInstallToken(w http.ResponseWriter, r *http.Request, req AgentInstallCommandRequest) {
cfg := h.getConfig(r.Context())
persistence := h.getPersistence(r.Context())
if !h.authConfiguredForAgentLifecycle(cfg) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AgentInstallCommandResponse{})
return
}
tokenName := strings.TrimSpace(req.Name)
if tokenName == "" {
tokenName = fmt.Sprintf("host-agent-%d", time.Now().Unix())
}
rawToken, record, err := issueAndPersistAgentInstallToken(cfg, persistence, issueAgentInstallTokenOptions{
TokenName: tokenName,
OwnerUserID: h.apiTokenOwnerUserIDForRequest(cfg, r),
Scopes: hostAgentInstallScopes(req.EnableCommands),
Metadata: map[string]string{
"install_type": agentInstallTypeHost,
"issued_via": agentInstallIssuedViaConfig,
agentInstallCommandPolicyIntentKey: agentInstallCommandPolicyIntent(req.EnableCommands),
},
})
if err != nil {
log.Error().Err(err).Str("token_name", tokenName).Msg("Failed to create host agent install token")
http.Error(w, "Failed to generate API token", http.StatusInternalServerError)
return
}
log.Info().
Str("type", agentInstallTypeHost).
Bool("enable_commands", req.EnableCommands).
Msg("Generated host agent install token")
dto := toAPITokenDTO(*record)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AgentInstallCommandResponse{
Token: rawToken,
Record: &dto,
})
}