Pulse/internal/api/config_handlers.go

6646 lines
223 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
discoveryinternal "github.com/rcourtman/pulse-go-rewrite/internal/discovery"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"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"
pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery"
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
"github.com/rcourtman/pulse-go-rewrite/pkg/pmg"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
"github.com/rs/zerolog/log"
)
var (
setupAuthTokenPattern = regexp.MustCompile(`^[A-Fa-f0-9]{32,128}$`)
pulseTokenSlugPattern = regexp.MustCompile(`[^a-z0-9]+`)
)
const (
autoRegisterAuthMissing = "Pulse requires authentication"
autoRegisterAuthInvalidAPI = "Invalid API token"
autoRegisterAuthMissingScope = "API token missing required scope; requires settings:write or host-agent:report"
autoRegisterAuthExpiredSetup = "Expired setup token"
autoRegisterAuthUsedSetup = "Setup token already used"
autoRegisterAuthInvalidSetup = "Invalid setup token"
autoRegisterAuthSetupNodeType = "Setup token is not valid for this node type"
)
func sanitizeInstallerURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if strings.ContainsAny(trimmed, "\r\n") {
return "", fmt.Errorf("value must not contain control characters")
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("scheme must be http or https")
}
if parsed.Host == "" {
return "", fmt.Errorf("host component is required")
}
parsed.Fragment = ""
return parsed.String(), nil
}
func sanitizeSetupAuthToken(token string) (string, error) {
trimmed := strings.TrimSpace(token)
if trimmed == "" {
return "", nil
}
if strings.ContainsAny(trimmed, "\r\n") {
return "", fmt.Errorf("token must not contain control characters")
}
if !setupAuthTokenPattern.MatchString(trimmed) {
return "", fmt.Errorf("token must be hexadecimal")
}
return trimmed, nil
}
func shellSingleQuoteLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
// buildPulseMonitorTokenName returns a deterministic Pulse-managed token name.
// The result is stable across reruns for the same Pulse instance.
func buildPulseMonitorTokenName(candidates ...string) string {
return "pulse-" + pulseTokenSuffix(candidates...)
}
func pulseTokenSuffix(candidates ...string) string {
for _, candidate := range candidates {
host := pulseTokenHostCandidate(candidate)
if host == "" {
continue
}
slug := pulseTokenSlug(host)
if slug != "" {
return slug
}
}
return "server"
}
func pulseTokenHostCandidate(candidate string) string {
raw := strings.TrimSpace(candidate)
if raw == "" {
return ""
}
if strings.Contains(raw, "://") {
if parsed, err := url.Parse(raw); err == nil && parsed.Hostname() != "" {
return parsed.Hostname()
}
}
if host, _, err := net.SplitHostPort(raw); err == nil {
return strings.Trim(host, "[]")
}
if parsed, err := url.Parse("https://" + raw); err == nil && parsed.Hostname() != "" {
return parsed.Hostname()
}
return strings.Trim(raw, "[]")
}
func pulseTokenSlug(raw string) string {
trimmed := strings.Trim(strings.ToLower(strings.TrimSpace(raw)), ".")
if trimmed == "" {
return ""
}
slug := pulseTokenSlugPattern.ReplaceAllString(trimmed, "-")
slug = strings.Trim(slug, "-")
if slug == "" {
return ""
}
const maxSlugLen = 48
if len(slug) > maxSlugLen {
slug = strings.Trim(slug[:maxSlugLen], "-")
}
return slug
}
// SetupCode represents a one-time setup code for secure node registration
type SetupCode struct {
ExpiresAt time.Time
Used bool
NodeType string // "pve" or "pbs"
Host string // The host URL for validation
OrgID string // Organization ID creating this code
}
type recentSetupToken struct {
ExpiresAt time.Time
NodeType string
}
// ConfigHandlers handles configuration-related API endpoints
type ConfigHandlers struct {
mtPersistence *config.MultiTenantPersistence
mtMonitor *monitoring.MultiTenantMonitor
// Legacy fields - to be removed or used as fallback
legacyConfig *config.Config
legacyPersistence *config.ConfigPersistence
legacyMonitor *monitoring.Monitor
reloadFunc func() error
reloadSystemSettingsFunc func() // Function to reload cached system settings
wsHub *websocket.Hub
guestMetadataHandler *GuestMetadataHandler
setupCodes map[string]*SetupCode // Map of code hash -> setup code details
recentSetupTokens map[string]recentSetupToken // Temporary map for recently used setup tokens (grace period)
codeMutex sync.RWMutex // Mutex for thread-safe code access
clusterDetectMutex sync.Mutex
lastClusterDetection map[string]time.Time
recentAutoRegistered map[string]time.Time
recentAutoRegMutex sync.Mutex
}
// NewConfigHandlers creates a new ConfigHandlers instance
func NewConfigHandlers(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenantMonitor, reloadFunc func() error, wsHub *websocket.Hub, guestMetadataHandler *GuestMetadataHandler, reloadSystemSettingsFunc func()) *ConfigHandlers {
// Initialize with default (legacy) values if available, for backward compat during migration
// Ideally we fetch them from mtp/mtm for "default" org.
var defaultConfig *config.Config
var defaultMonitor *monitoring.Monitor
var defaultPersistence *config.ConfigPersistence
if mtm != nil {
if m, err := mtm.GetMonitor("default"); err == nil {
defaultMonitor = m
if m != nil {
defaultConfig = m.GetConfig()
}
}
}
if mtp != nil {
if p, err := mtp.GetPersistence("default"); err == nil {
defaultPersistence = p
}
}
h := &ConfigHandlers{
mtPersistence: mtp,
mtMonitor: mtm,
legacyConfig: defaultConfig,
legacyMonitor: defaultMonitor,
legacyPersistence: defaultPersistence,
reloadFunc: reloadFunc,
reloadSystemSettingsFunc: reloadSystemSettingsFunc,
wsHub: wsHub,
guestMetadataHandler: guestMetadataHandler,
setupCodes: make(map[string]*SetupCode),
recentSetupTokens: make(map[string]recentSetupToken),
lastClusterDetection: make(map[string]time.Time),
recentAutoRegistered: make(map[string]time.Time),
}
// Clean up expired codes periodically
go h.cleanupExpiredCodes()
return h
}
// SetMultiTenantMonitor updates the monitor reference used by the config handlers.
func (h *ConfigHandlers) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) {
h.mtMonitor = mtm
if mtm != nil {
if m, err := mtm.GetMonitor("default"); err == nil {
h.legacyMonitor = m
h.legacyConfig = m.GetConfig()
}
}
}
// SetMonitor updates the monitor reference used by the config handlers (legacy support).
func (h *ConfigHandlers) SetMonitor(m *monitoring.Monitor) {
h.legacyMonitor = m
if m != nil {
h.legacyConfig = m.GetConfig()
}
}
// SetConfig updates the configuration reference used by the handlers.
func (h *ConfigHandlers) SetConfig(cfg *config.Config) {
if cfg == nil {
return
}
h.legacyConfig = cfg
}
// SetPersistence updates the legacy persistence used for single-tenant runtime paths.
func (h *ConfigHandlers) SetPersistence(p *config.ConfigPersistence) {
if p == nil {
return
}
h.legacyPersistence = p
}
// getContextState helper to retrieve tenant-specific state
func (h *ConfigHandlers) getContextState(ctx context.Context) (*config.Config, *config.ConfigPersistence, *monitoring.Monitor) {
orgID := "default"
if ctx != nil {
if id := GetOrgID(ctx); id != "" {
orgID = id
}
}
// Try to get from multi-tenant managers first
if h.mtMonitor != nil {
if m, err := h.mtMonitor.GetMonitor(orgID); err == nil && m != nil {
cfg := m.GetConfig()
var p *config.ConfigPersistence
if h.mtPersistence != nil {
p, _ = h.mtPersistence.GetPersistence(orgID)
}
return cfg, p, m
} else if err != nil {
log.Warn().Str("orgID", orgID).Err(err).Msg("Falling back to legacy config - failed to get tenant monitor")
}
}
// Fallback to legacy (should mostly happen for "default" or initialization)
return h.legacyConfig, h.legacyPersistence, h.legacyMonitor
}
func (h *ConfigHandlers) getConfig(ctx context.Context) *config.Config {
c, _, _ := h.getContextState(ctx)
return c
}
func (h *ConfigHandlers) getPersistence(ctx context.Context) *config.ConfigPersistence {
_, p, _ := h.getContextState(ctx)
return p
}
func (h *ConfigHandlers) getMonitor(ctx context.Context) *monitoring.Monitor {
_, _, m := h.getContextState(ctx)
return m
}
// cleanupExpiredCodes removes expired or used setup codes periodically
func (h *ConfigHandlers) cleanupExpiredCodes() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
h.codeMutex.Lock()
now := time.Now()
for codeHash, code := range h.setupCodes {
if now.After(code.ExpiresAt) || code.Used {
delete(h.setupCodes, codeHash)
log.Debug().Bool("was_used", code.Used).Msg("Cleaned up setup code")
}
}
for tokenHash, recent := range h.recentSetupTokens {
if now.After(recent.ExpiresAt) {
delete(h.recentSetupTokens, tokenHash)
}
}
h.codeMutex.Unlock()
}
}
// ValidateSetupToken checks whether the provided temporary setup token is still valid.
func (h *ConfigHandlers) ValidateSetupToken(token string) bool {
if token == "" {
return false
}
tokenHash := internalauth.HashAPIToken(token)
now := time.Now()
h.codeMutex.RLock()
defer h.codeMutex.RUnlock()
if code, exists := h.setupCodes[tokenHash]; exists {
if !code.Used && now.Before(code.ExpiresAt) {
return true
}
}
return false
}
func (h *ConfigHandlers) validateAutoRegisterSetupToken(token, nodeType string) (bool, string) {
if strings.TrimSpace(token) == "" {
return false, autoRegisterAuthInvalidSetup
}
tokenHash := internalauth.HashAPIToken(token)
now := time.Now()
h.codeMutex.Lock()
defer h.codeMutex.Unlock()
if recent, ok := h.recentSetupTokens[tokenHash]; ok && now.Before(recent.ExpiresAt) {
if recent.NodeType != "" && recent.NodeType != nodeType {
return false, autoRegisterAuthSetupNodeType
}
return true, ""
}
setupCode, exists := h.setupCodes[tokenHash]
if !exists {
return false, autoRegisterAuthInvalidSetup
}
if !now.Before(setupCode.ExpiresAt) {
return false, autoRegisterAuthExpiredSetup
}
if setupCode.NodeType != "" && setupCode.NodeType != nodeType {
return false, autoRegisterAuthSetupNodeType
}
if setupCode.Used {
return false, autoRegisterAuthUsedSetup
}
setupCode.Used = true
graceExpiry := now.Add(1 * time.Minute)
if setupCode.ExpiresAt.Before(graceExpiry) {
graceExpiry = setupCode.ExpiresAt
}
h.recentSetupTokens[tokenHash] = recentSetupToken{
ExpiresAt: graceExpiry,
NodeType: setupCode.NodeType,
}
return true, ""
}
func (h *ConfigHandlers) markAutoRegistered(nodeType, nodeName string) {
if nodeType == "" || nodeName == "" {
return
}
key := nodeType + ":" + nodeName
h.recentAutoRegMutex.Lock()
h.recentAutoRegistered[key] = time.Now()
h.recentAutoRegMutex.Unlock()
}
func (h *ConfigHandlers) clearAutoRegistered(nodeType, nodeName string) {
if nodeType == "" || nodeName == "" {
return
}
key := nodeType + ":" + nodeName
h.recentAutoRegMutex.Lock()
delete(h.recentAutoRegistered, key)
h.recentAutoRegMutex.Unlock()
}
func (h *ConfigHandlers) isRecentlyAutoRegistered(nodeType, nodeName string) bool {
if nodeType == "" || nodeName == "" {
return false
}
key := nodeType + ":" + nodeName
now := time.Now()
h.recentAutoRegMutex.Lock()
defer h.recentAutoRegMutex.Unlock()
registeredAt, ok := h.recentAutoRegistered[key]
if !ok {
return false
}
if now.Sub(registeredAt) > 2*time.Minute {
delete(h.recentAutoRegistered, key)
return false
}
return true
}
func (h *ConfigHandlers) findInstanceNameByHost(ctx context.Context, nodeType, host string) string {
switch nodeType {
case "pve":
for _, node := range h.getConfig(ctx).PVEInstances {
if node.Host == host {
return node.Name
}
}
case "pbs":
for _, node := range h.getConfig(ctx).PBSInstances {
if node.Host == host {
return node.Name
}
}
}
return ""
}
// sanitizeErrorMessage returns a safe error message for external responses
// It logs the detailed error internally while returning a generic message
func sanitizeErrorMessage(err error, operation string) string {
// Log the detailed error internally
log.Error().Err(err).Str("operation", operation).Msg("Operation failed")
// Return generic messages based on operation type
switch operation {
case "create_client":
return "Failed to initialize connection"
case "connection":
return "Connection failed. Please check your credentials and network settings"
case "validation":
return "Invalid configuration"
default:
return "Operation failed"
}
}
func normalizePVEUser(user string) string {
user = strings.TrimSpace(user)
if user == "" {
return user
}
if strings.Contains(user, "@") {
return user
}
return user + "@pam"
}
const clusterDetectionCooldown = 30 * time.Second
func shouldSkipClusterAutoDetection(host, name string) bool {
if host == "" {
return false
}
lowerHost := strings.ToLower(host)
lowerName := strings.ToLower(name)
return strings.Contains(lowerHost, "192.168.77.") ||
strings.Contains(lowerHost, "192.168.88.") ||
strings.Contains(lowerHost, "test-") ||
strings.Contains(lowerName, "test-") ||
strings.Contains(lowerName, "persist-") ||
strings.Contains(lowerName, "concurrent-")
}
func (h *ConfigHandlers) maybeRefreshClusterInfo(ctx context.Context, instance *config.PVEInstance) {
if instance == nil {
return
}
if shouldSkipClusterAutoDetection(instance.Host, instance.Name) {
return
}
// Require credentials to attempt detection
if instance.TokenValue == "" && instance.Password == "" {
return
}
trimmedName := strings.TrimSpace(instance.ClusterName)
needsRefresh := !instance.IsCluster ||
len(instance.ClusterEndpoints) == 0 ||
trimmedName == "" ||
strings.EqualFold(trimmedName, "unknown cluster")
if !needsRefresh {
return
}
h.clusterDetectMutex.Lock()
last := h.lastClusterDetection[instance.Name]
if time.Since(last) < clusterDetectionCooldown {
h.clusterDetectMutex.Unlock()
return
}
h.lastClusterDetection[instance.Name] = time.Now()
h.clusterDetectMutex.Unlock()
clientConfig := config.CreateProxmoxConfig(instance)
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, instance.Name, instance.ClusterEndpoints)
if !isCluster || len(clusterEndpoints) == 0 {
log.Debug().
Str("instance", instance.Name).
Bool("previous_cluster", instance.IsCluster).
Msg("Cluster validation retry did not produce usable endpoints")
return
}
trimmedCluster := strings.TrimSpace(clusterName)
if trimmedCluster == "" || strings.EqualFold(trimmedCluster, "unknown cluster") {
clusterName = instance.Name
}
instance.IsCluster = true
instance.ClusterName = clusterName
instance.ClusterEndpoints = clusterEndpoints
log.Info().
Str("instance", instance.Name).
Str("cluster", clusterName).
Int("endpoints", len(clusterEndpoints)).
Msg("Updated cluster metadata after validation retry")
if h.getPersistence(ctx) != nil {
if err := h.getPersistence(ctx).SaveNodesConfig(h.getConfig(ctx).PVEInstances, h.getConfig(ctx).PBSInstances, h.getConfig(ctx).PMGInstances); err != nil {
log.Warn().
Err(err).
Str("instance", instance.Name).
Msg("Failed to persist cluster detection update")
}
}
}
// NodeConfigRequest represents a request to add/update a node
type NodeConfigRequest struct {
Type string `json:"type"` // "pve", "pbs", or "pmg"
Name string `json:"name"`
Host string `json:"host"`
GuestURL string `json:"guestURL,omitempty"` // Optional guest-accessible URL (for navigation)
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
TokenName string `json:"tokenName,omitempty"`
TokenValue string `json:"tokenValue,omitempty"`
TokenID string `json:"tokenId,omitempty"`
TokenSecret string `json:"tokenSecret,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
VerifySSL *bool `json:"verifySSL,omitempty"`
MonitorVMs *bool `json:"monitorVMs,omitempty"` // PVE only
MonitorContainers *bool `json:"monitorContainers,omitempty"` // PVE only
MonitorStorage *bool `json:"monitorStorage,omitempty"` // PVE only
MonitorBackups *bool `json:"monitorBackups,omitempty"` // PVE only
MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"` // PVE only (nil = enabled by default)
PhysicalDiskPollingMinutes *int `json:"physicalDiskPollingMinutes,omitempty"` // PVE only (0 = default 5m)
TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // All types (nil = use global setting)
MonitorDatastores *bool `json:"monitorDatastores,omitempty"` // PBS only
MonitorSyncJobs *bool `json:"monitorSyncJobs,omitempty"` // PBS only
MonitorVerifyJobs *bool `json:"monitorVerifyJobs,omitempty"` // PBS only
MonitorPruneJobs *bool `json:"monitorPruneJobs,omitempty"` // PBS only
MonitorGarbageJobs *bool `json:"monitorGarbageJobs,omitempty"` // PBS only
ExcludeDatastores []string `json:"excludeDatastores,omitempty"` // PBS only - datastores to exclude from monitoring
MonitorMailStats *bool `json:"monitorMailStats,omitempty"` // PMG only
MonitorQueues *bool `json:"monitorQueues,omitempty"` // PMG only
MonitorQuarantine *bool `json:"monitorQuarantine,omitempty"` // PMG only
MonitorDomainStats *bool `json:"monitorDomainStats,omitempty"` // PMG only
}
func (r *NodeConfigRequest) normalizeTokenAliases() {
r.TokenName = strings.TrimSpace(r.TokenName)
r.TokenValue = strings.TrimSpace(r.TokenValue)
if r.TokenName == "" {
r.TokenName = strings.TrimSpace(r.TokenID)
}
if r.TokenValue == "" {
if tokenSecret := strings.TrimSpace(r.TokenSecret); tokenSecret != "" {
r.TokenValue = tokenSecret
}
}
}
// NodeResponse represents a node in API responses
type NodeResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Host string `json:"host"`
GuestURL string `json:"guestURL,omitempty"`
User string `json:"user,omitempty"`
HasPassword bool `json:"hasPassword"`
TokenName string `json:"tokenName,omitempty"`
HasToken bool `json:"hasToken"`
Fingerprint string `json:"fingerprint,omitempty"`
VerifySSL bool `json:"verifySSL"`
MonitorVMs bool `json:"monitorVMs,omitempty"`
MonitorContainers bool `json:"monitorContainers,omitempty"`
MonitorStorage bool `json:"monitorStorage,omitempty"`
MonitorBackups bool `json:"monitorBackups,omitempty"`
MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"`
PhysicalDiskPollingMinutes int `json:"physicalDiskPollingMinutes,omitempty"`
TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"`
MonitorDatastores bool `json:"monitorDatastores,omitempty"`
MonitorSyncJobs bool `json:"monitorSyncJobs,omitempty"`
MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"`
MonitorPruneJobs bool `json:"monitorPruneJobs,omitempty"`
MonitorGarbageJobs bool `json:"monitorGarbageJobs,omitempty"`
ExcludeDatastores []string `json:"excludeDatastores,omitempty"` // PBS only
MonitorMailStats bool `json:"monitorMailStats,omitempty"`
MonitorQueues bool `json:"monitorQueues,omitempty"`
MonitorQuarantine bool `json:"monitorQuarantine,omitempty"`
MonitorDomainStats bool `json:"monitorDomainStats,omitempty"`
Status string `json:"status"` // "connected", "disconnected", "error"
IsCluster bool `json:"isCluster,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
ClusterEndpoints []config.ClusterEndpoint `json:"clusterEndpoints,omitempty"`
Source string `json:"source,omitempty"` // "agent" or "script" - how this node was registered
}
func isContainerSSHRestricted() bool {
isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer()
if !isContainer {
return false
}
return strings.ToLower(strings.TrimSpace(os.Getenv("PULSE_DEV_ALLOW_CONTAINER_SSH"))) != "true"
}
// deriveSchemeAndPort infers the scheme (without ://) and port from a base host URL.
// Defaults align with Proxmox expectations when details are omitted.
func deriveSchemeAndPort(baseHost string) (scheme string, port string) {
scheme = "https"
port = "8006"
baseHost = strings.TrimSpace(baseHost)
if baseHost == "" {
return scheme, port
}
candidate := baseHost
if !strings.Contains(candidate, "://") {
candidate = "https://" + candidate
}
parsed, err := url.Parse(candidate)
if err != nil {
return scheme, port
}
if parsed.Scheme != "" {
scheme = parsed.Scheme
}
if parsed.Port() != "" {
port = parsed.Port()
}
return scheme, port
}
// ensureHostHasPort guarantees that a host string contains an explicit port.
func ensureHostHasPort(host, port string) string {
host = strings.TrimSpace(host)
if host == "" || port == "" {
return host
}
if _, _, err := net.SplitHostPort(host); err == nil {
return host
}
if parsed, err := url.Parse(host); err == nil && parsed.Host != "" {
if parsed.Port() != "" {
return parsed.Host
}
host = parsed.Host
}
trimmed := strings.TrimPrefix(host, "[")
trimmed = strings.TrimSuffix(trimmed, "]")
return net.JoinHostPort(trimmed, port)
}
// validateNodeAPI tests if a cluster node has a working Proxmox API
// This helps filter out qdevice VMs and other non-Proxmox participants.
// Returns (isValid, fingerprint) - fingerprint is captured for TOFU (Trust On First Use).
func validateNodeAPI(clusterNode proxmox.ClusterStatus, baseConfig proxmox.ClientConfig) (bool, string) {
// Determine the host to test - prefer IP if available, otherwise use node name
testHost := clusterNode.IP
if testHost == "" {
testHost = clusterNode.Name
}
// Skip empty hostnames (shouldn't happen but be safe)
if testHost == "" {
return false, ""
}
scheme, defaultPort := deriveSchemeAndPort(baseConfig.Host)
// Create a test configuration for this specific node
testConfig := baseConfig
testConfig.Host = testHost
if !strings.HasPrefix(testConfig.Host, "http") {
hostWithPort := ensureHostHasPort(testConfig.Host, defaultPort)
testConfig.Host = fmt.Sprintf("%s://%s", scheme, hostWithPort)
}
// Use a very short timeout for validation - we just need to know if the API exists
testConfig.Timeout = 2 * time.Second
log.Debug().
Str("node", clusterNode.Name).
Str("test_host", testConfig.Host).
Msg("Validating Proxmox API for cluster node")
// Capture the fingerprint for TOFU (Trust On First Use)
var capturedFingerprint string
if testHost != "" {
fp, err := tlsutil.FetchFingerprint(testConfig.Host)
if err != nil {
log.Debug().
Str("node", clusterNode.Name).
Err(err).
Msg("Could not fetch TLS fingerprint for cluster node")
} else {
capturedFingerprint = fp
log.Debug().
Str("node", clusterNode.Name).
Str("fingerprint", fp[:16]+"...").
Msg("Captured TLS fingerprint for cluster node")
}
}
// Try to create a client and make a simple API call
testClient, err := proxmox.NewClient(testConfig)
if err != nil {
// Many clusters use unique certificates per node. If the primary node
// was configured with fingerprint pinning, connecting to peers with the
// same fingerprint will fail. Fall back to a relaxed TLS check so we can
// still detect valid cluster members while keeping other errors (like
// auth) as hard failures.
errStr := err.Error()
isTLSMismatch := strings.Contains(errStr, "fingerprint") || strings.Contains(errStr, "x509") || strings.Contains(errStr, "certificate")
if isTLSMismatch {
log.Debug().
Str("node", clusterNode.Name).
Msg("Retrying cluster node validation without fingerprint pinning")
testConfig.Fingerprint = ""
testConfig.VerifySSL = false
testClient, err = proxmox.NewClient(testConfig)
}
if err != nil {
log.Debug().
Str("node", clusterNode.Name).
Err(err).
Msg("Failed to create test client for cluster node")
return false, ""
}
}
// Test with a simple API call that all Proxmox nodes should support
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Try to get the node version - this is a very lightweight API call
_, err = testClient.GetNodes(ctx)
if err != nil {
errMsg := err.Error()
// If we reached the API but were denied (common when per-node permissions
// differ), treat it as valid. We only want to filter out hosts that aren't
// actually Proxmox endpoints.
if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "403") || strings.Contains(errMsg, "permission") {
log.Debug().
Str("node", clusterNode.Name).
Err(err).
Msg("Cluster node API responded but denied access; accepting for discovery")
return true, capturedFingerprint
}
log.Debug().
Str("node", clusterNode.Name).
Err(err).
Msg("Node failed Proxmox API validation - likely not a Proxmox node")
return false, ""
}
log.Debug().
Str("node", clusterNode.Name).
Msg("Node passed Proxmox API validation")
return true, capturedFingerprint
}
// findExistingGuestURL looks up the GuestURL for a node from existing endpoints
func findExistingGuestURL(nodeName string, existingEndpoints []config.ClusterEndpoint) string {
for _, ep := range existingEndpoints {
if ep.NodeName == nodeName {
return ep.GuestURL
}
}
return ""
}
// findExistingIPOverride looks up the IPOverride for a node from existing endpoints
func findExistingIPOverride(nodeName string, existingEndpoints []config.ClusterEndpoint) string {
for _, ep := range existingEndpoints {
if ep.NodeName == nodeName {
return ep.IPOverride
}
}
return ""
}
// extractIPFromHost extracts the IP address from a host URL.
// For example, "https://10.1.1.5:8006" returns 10.1.1.5 as net.IP.
func extractIPFromHost(host string) net.IP {
// Parse the URL to get the hostname/IP
parsed, err := url.Parse(host)
if err != nil {
return nil
}
hostname := parsed.Hostname()
if hostname == "" {
hostname = host
}
// Try to parse as IP
ip := net.ParseIP(hostname)
if ip == nil {
// Try resolving hostname
ips, err := net.LookupIP(hostname)
if err != nil || len(ips) == 0 {
return nil
}
ip = ips[0]
}
return ip
}
// ipsOnSameNetwork checks if two IPs appear to be on the same network.
// It tries progressively larger subnets (/24, /20, /16 for IPv4) to handle
// various network topologies without requiring explicit subnet configuration.
func ipsOnSameNetwork(ip1, ip2 net.IP) bool {
if ip1 == nil || ip2 == nil {
return false
}
// Normalize to IPv4 if possible
ip1v4 := ip1.To4()
ip2v4 := ip2.To4()
if ip1v4 != nil && ip2v4 != nil {
// Try common IPv4 subnet sizes: /24 (most common), /20, /16
// This handles everything from small home networks to large enterprise networks
for _, bits := range []int{24, 20, 16} {
mask := net.CIDRMask(bits, 32)
if ip1v4.Mask(mask).Equal(ip2v4.Mask(mask)) {
return true
}
}
return false
}
// IPv6: try /64, /48
ip1v6 := ip1.To16()
ip2v6 := ip2.To16()
if ip1v6 != nil && ip2v6 != nil {
for _, bits := range []int{64, 48} {
mask := net.CIDRMask(bits, 128)
if ip1v6.Mask(mask).Equal(ip2v6.Mask(mask)) {
return true
}
}
}
return false
}
func interfaceNetwork(iface proxmox.NodeNetworkInterface) (*net.IPNet, net.IP) {
if strings.TrimSpace(iface.CIDR) != "" {
if ip, network, err := net.ParseCIDR(strings.TrimSpace(iface.CIDR)); err == nil {
return network, ip
}
}
address := net.ParseIP(strings.TrimSpace(iface.Address))
netmask := net.ParseIP(strings.TrimSpace(iface.Netmask))
if ipv4 := address.To4(); ipv4 != nil {
if maskIPv4 := netmask.To4(); maskIPv4 != nil {
mask := net.IPMask(maskIPv4)
return &net.IPNet{IP: ipv4.Mask(mask), Mask: mask}, ipv4
}
}
address6 := net.ParseIP(strings.TrimSpace(iface.Address6))
if strings.TrimSpace(iface.CIDR) != "" && address6 != nil {
if _, network, err := net.ParseCIDR(strings.TrimSpace(iface.CIDR)); err == nil {
return network, address6
}
}
return nil, nil
}
func likelySameSubnet(ip1, ip2 net.IP) bool {
if ip1 == nil || ip2 == nil {
return false
}
if ip1v4 := ip1.To4(); ip1v4 != nil {
ip2v4 := ip2.To4()
if ip2v4 == nil {
return false
}
mask := net.CIDRMask(24, 32)
return ip1v4.Mask(mask).Equal(ip2v4.Mask(mask))
}
ip1v6 := ip1.To16()
ip2v6 := ip2.To16()
if ip1v6 == nil || ip2v6 == nil {
return false
}
mask := net.CIDRMask(64, 128)
return ip1v6.Mask(mask).Equal(ip2v6.Mask(mask))
}
// findPreferredIP looks through a list of node network interfaces and returns
// an IP that appears to be on the same network as the reference IP.
// Returns empty string if no match found.
func findPreferredIP(interfaces []proxmox.NodeNetworkInterface, referenceIP net.IP) string {
if referenceIP == nil {
return ""
}
bestIP := ""
bestPrefix := -1
for _, iface := range interfaces {
// Skip inactive interfaces
if iface.Active != 1 {
continue
}
network, ifaceIP := interfaceNetwork(iface)
if network != nil && ifaceIP != nil && network.Contains(referenceIP) {
ones, _ := network.Mask.Size()
candidate := strings.TrimSpace(iface.Address)
if candidate == "" {
candidate = ifaceIP.String()
}
if candidate != "" && ones > bestPrefix {
bestIP = candidate
bestPrefix = ones
}
continue
}
// Fallback for older payloads that don't include CIDR/netmask details.
if iface.Address != "" {
ip := net.ParseIP(strings.TrimSpace(iface.Address))
if ip != nil && likelySameSubnet(ip, referenceIP) {
return strings.TrimSpace(iface.Address)
}
}
}
return bestIP
}
var detectPVECluster = defaultDetectPVECluster
// detectPVECluster checks if a PVE node is part of a cluster and returns cluster information
// If existingEndpoints is provided, GuestURL values will be preserved for matching nodes
func defaultDetectPVECluster(clientConfig proxmox.ClientConfig, nodeName string, existingEndpoints []config.ClusterEndpoint) (isCluster bool, clusterName string, clusterEndpoints []config.ClusterEndpoint) {
tempClient, err := proxmox.NewClient(clientConfig)
if err != nil {
log.Warn().Err(err).Msg("Failed to create client for cluster detection")
return false, "", nil
}
// Try to get cluster status with retries to handle API permission propagation delays
// This addresses issue #437 where cluster detection fails on first attempt
var clusterStatus []proxmox.ClusterStatus
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
// Wait a bit for permissions to propagate
time.Sleep(time.Duration(attempt) * time.Second)
log.Debug().Int("attempt", attempt+1).Msg("Retrying cluster detection")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Get full cluster status to find the actual cluster name
// Note: This can cause certificate lookup errors on standalone nodes, but it's only done once during configuration
clusterStatus, lastErr = tempClient.GetClusterStatus(ctx)
if lastErr == nil {
// Success!
break
}
// Check if this is definitely not a cluster (e.g., 501 not implemented)
lastErrStr := lastErr.Error()
if strings.Contains(lastErrStr, "501") || strings.Contains(lastErrStr, "not implemented") {
// This is a standalone node, no need to retry
log.Debug().Err(lastErr).Msg("Standalone node detected - cluster API not available")
return false, "", nil
}
}
if lastErr != nil {
// This is expected for standalone nodes - they will return an error when accessing cluster endpoints
log.Debug().Err(lastErr).Msg("Could not get cluster status after retries - likely a standalone node")
return false, "", nil
}
// Find the cluster name and collect nodes
var clusterNodes []proxmox.ClusterStatus
for _, status := range clusterStatus {
if status.Type == "cluster" {
// This is the actual cluster name
clusterName = status.Name
log.Info().Str("cluster_name", clusterName).Msg("Found cluster name")
} else if status.Type == "node" {
clusterNodes = append(clusterNodes, status)
}
}
log.Info().Int("cluster_nodes", len(clusterNodes)).Msg("Got cluster nodes")
if len(clusterNodes) > 1 {
isCluster = true
log.Info().
Str("cluster", clusterName).
Str("node", nodeName).
Int("nodes", len(clusterNodes)).
Msg("Detected Proxmox cluster")
scheme, defaultPort := deriveSchemeAndPort(clientConfig.Host)
schemePrefix := scheme + "://"
// Extract the connection IP to use as reference for preferred network
// This allows us to prefer management network IPs over internal cluster IPs
connectionIP := extractIPFromHost(clientConfig.Host)
if connectionIP != nil {
log.Debug().
Str("connection_ip", connectionIP.String()).
Str("from_host", clientConfig.Host).
Msg("Extracted connection IP for network preference")
}
var unvalidatedNodes []proxmox.ClusterStatus
for _, clusterNode := range clusterNodes {
// Validate that this node actually has a working Proxmox API
// This filters out qdevice VMs and other non-Proxmox participants
// Also captures the node's TLS fingerprint for TOFU
isValid, nodeFingerprint := validateNodeAPI(clusterNode, clientConfig)
if !isValid {
log.Debug().
Str("node", clusterNode.Name).
Str("ip", clusterNode.IP).
Msg("Skipping cluster node - no valid Proxmox API detected (likely qdevice or external node)")
unvalidatedNodes = append(unvalidatedNodes, clusterNode)
continue
}
// Build the host URL with proper port
// Store hostname in Host field (for TLS validation), IP separately
endpoint := config.ClusterEndpoint{
NodeID: clusterNode.ID,
NodeName: clusterNode.Name,
GuestURL: findExistingGuestURL(clusterNode.Name, existingEndpoints),
IPOverride: findExistingIPOverride(clusterNode.Name, existingEndpoints), // Preserve user override
Fingerprint: nodeFingerprint, // Store captured fingerprint for per-node TLS verification
Online: clusterNode.Online == 1,
LastSeen: time.Now(),
}
// Populate Host field with hostname (if available) for TLS certificate validation
if clusterNode.Name != "" {
nodeHost := ensureHostHasPort(clusterNode.Name, defaultPort)
endpoint.Host = schemePrefix + nodeHost
}
// Populate IP field with cluster-reported IP (may be internal network)
if clusterNode.IP != "" {
endpoint.IP = clusterNode.IP
}
// Try to find a better IP on the same network as initial connection (management network)
// Only do this if no manual override is set
if endpoint.IPOverride == "" && connectionIP != nil && clusterNode.Name != "" {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
nodeInterfaces, err := tempClient.GetNodeNetworkInterfaces(ctx, clusterNode.Name)
cancel()
if err == nil {
preferredIP := findPreferredIP(nodeInterfaces, connectionIP)
if preferredIP != "" && preferredIP != clusterNode.IP {
log.Info().
Str("node", clusterNode.Name).
Str("cluster_ip", clusterNode.IP).
Str("preferred_ip", preferredIP).
Str("connection_ip", connectionIP.String()).
Msg("Found preferred management IP for cluster node")
endpoint.IPOverride = preferredIP
}
} else {
log.Debug().
Err(err).
Str("node", clusterNode.Name).
Msg("Could not query node network interfaces for network preference")
}
}
clusterEndpoints = append(clusterEndpoints, endpoint)
}
if len(clusterEndpoints) == 0 && len(unvalidatedNodes) > 0 {
log.Warn().
Str("cluster", clusterName).
Int("total_discovered", len(unvalidatedNodes)).
Msg("All detected cluster nodes failed validation; falling back to cluster metadata")
for _, clusterNode := range unvalidatedNodes {
if clusterNode.Name == "" && clusterNode.IP == "" {
continue
}
endpoint := config.ClusterEndpoint{
NodeID: clusterNode.ID,
NodeName: clusterNode.Name,
GuestURL: findExistingGuestURL(clusterNode.Name, existingEndpoints),
Online: clusterNode.Online == 1,
LastSeen: time.Now(),
}
// Populate Host field with hostname (if available) for TLS certificate validation
if clusterNode.Name != "" {
nodeHost := ensureHostHasPort(clusterNode.Name, defaultPort)
endpoint.Host = schemePrefix + nodeHost
}
// Populate IP field separately for DNS-free connections
if clusterNode.IP != "" {
endpoint.IP = clusterNode.IP
}
// Apply subnet preference even in fallback path (refs #929)
// Node validation may have failed because cluster-reported IPs are on internal
// network, but we can still query node interfaces via the initial connection
if connectionIP != nil && clusterNode.Name != "" {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
nodeInterfaces, err := tempClient.GetNodeNetworkInterfaces(ctx, clusterNode.Name)
cancel()
if err == nil {
preferredIP := findPreferredIP(nodeInterfaces, connectionIP)
if preferredIP != "" && preferredIP != clusterNode.IP {
log.Info().
Str("node", clusterNode.Name).
Str("cluster_ip", clusterNode.IP).
Str("preferred_ip", preferredIP).
Str("connection_ip", connectionIP.String()).
Msg("Found preferred management IP for unvalidated cluster node")
endpoint.IPOverride = preferredIP
}
} else {
log.Debug().
Err(err).
Str("node", clusterNode.Name).
Msg("Could not query node network interfaces in fallback path")
}
}
clusterEndpoints = append(clusterEndpoints, endpoint)
}
}
// Log the final count of valid Proxmox nodes found
log.Info().
Str("cluster", clusterName).
Int("total_discovered", len(clusterNodes)).
Int("valid_proxmox_nodes", len(clusterEndpoints)).
Msg("Cluster node validation complete")
// Fallback if we couldn't get the cluster name
if clusterName == "" {
clusterName = "Unknown Cluster"
}
}
return isCluster, clusterName, clusterEndpoints
}
// GetAllNodesForAPI returns all configured nodes for API responses
func (h *ConfigHandlers) GetAllNodesForAPI(ctx context.Context) []NodeResponse {
nodes := []NodeResponse{}
// Add PVE nodes
for i := range h.getConfig(ctx).PVEInstances {
// Refresh cluster metadata if we previously failed to detect endpoints
h.maybeRefreshClusterInfo(ctx, &h.getConfig(ctx).PVEInstances[i])
pve := h.getConfig(ctx).PVEInstances[i]
node := NodeResponse{
ID: generateNodeID("pve", i),
Type: "pve",
Name: pve.Name,
Host: pve.Host,
GuestURL: pve.GuestURL,
User: pve.User,
HasPassword: pve.Password != "",
TokenName: pve.TokenName,
HasToken: pve.TokenValue != "",
Fingerprint: pve.Fingerprint,
VerifySSL: pve.VerifySSL,
MonitorVMs: pve.MonitorVMs,
MonitorContainers: pve.MonitorContainers,
MonitorStorage: pve.MonitorStorage,
MonitorBackups: pve.MonitorBackups,
MonitorPhysicalDisks: pve.MonitorPhysicalDisks,
PhysicalDiskPollingMinutes: pve.PhysicalDiskPollingMinutes,
TemperatureMonitoringEnabled: pve.TemperatureMonitoringEnabled,
Status: h.getNodeStatus(ctx, "pve", pve.Name),
IsCluster: pve.IsCluster,
ClusterName: pve.ClusterName,
ClusterEndpoints: pve.ClusterEndpoints,
Source: pve.Source,
}
nodes = append(nodes, node)
}
// Add PBS nodes
for i, pbs := range h.getConfig(ctx).PBSInstances {
node := NodeResponse{
ID: generateNodeID("pbs", i),
Type: "pbs",
Name: pbs.Name,
Host: pbs.Host,
GuestURL: pbs.GuestURL,
User: pbs.User,
HasPassword: pbs.Password != "",
TokenName: pbs.TokenName,
HasToken: pbs.TokenValue != "",
Fingerprint: pbs.Fingerprint,
VerifySSL: pbs.VerifySSL,
TemperatureMonitoringEnabled: pbs.TemperatureMonitoringEnabled,
MonitorDatastores: pbs.MonitorDatastores,
MonitorSyncJobs: pbs.MonitorSyncJobs,
MonitorVerifyJobs: pbs.MonitorVerifyJobs,
MonitorPruneJobs: pbs.MonitorPruneJobs,
MonitorGarbageJobs: pbs.MonitorGarbageJobs,
ExcludeDatastores: pbs.ExcludeDatastores,
Status: h.getNodeStatus(ctx, "pbs", pbs.Name),
Source: pbs.Source,
}
nodes = append(nodes, node)
}
// Add PMG nodes
for i, pmgInst := range h.getConfig(ctx).PMGInstances {
monitorMailStats := pmgInst.MonitorMailStats
if !pmgInst.MonitorMailStats && !pmgInst.MonitorQueues && !pmgInst.MonitorQuarantine && !pmgInst.MonitorDomainStats {
monitorMailStats = true
}
node := NodeResponse{
ID: generateNodeID("pmg", i),
Type: "pmg",
Name: pmgInst.Name,
Host: pmgInst.Host,
GuestURL: pmgInst.GuestURL,
User: pmgInst.User,
HasPassword: pmgInst.Password != "",
TokenName: pmgInst.TokenName,
HasToken: pmgInst.TokenValue != "",
Fingerprint: pmgInst.Fingerprint,
VerifySSL: pmgInst.VerifySSL,
TemperatureMonitoringEnabled: pmgInst.TemperatureMonitoringEnabled,
MonitorMailStats: monitorMailStats,
MonitorQueues: pmgInst.MonitorQueues,
MonitorQuarantine: pmgInst.MonitorQuarantine,
MonitorDomainStats: pmgInst.MonitorDomainStats,
Status: h.getNodeStatus(ctx, "pmg", pmgInst.Name),
}
nodes = append(nodes, node)
}
return nodes
}
// HandleGetNodes returns all configured nodes
func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) {
// Check if mock mode is enabled
if mock.IsMockEnabled() {
// Return mock nodes for settings page
mockNodes := []NodeResponse{}
// Get mock state to extract node information
state := h.getMonitor(r.Context()).GetState()
// Get all cluster nodes and standalone nodes
var clusterNodes []models.Node
var standaloneNodes []models.Node
for _, node := range state.Nodes {
if node.Instance == "mock-cluster" {
clusterNodes = append(clusterNodes, node)
} else {
standaloneNodes = append(standaloneNodes, node)
}
}
// If we have cluster nodes, create ONE config entry for the cluster
if len(clusterNodes) > 0 {
// Build cluster endpoints for cluster nodes only
var clusterEndpoints []config.ClusterEndpoint
for i, n := range clusterNodes {
clusterEndpoints = append(clusterEndpoints, config.ClusterEndpoint{
NodeName: n.Name,
Host: fmt.Sprintf("192.168.0.%d:8006", 100+i),
Online: n.Status == "online", // Set Online based on node status
})
}
// Create a single cluster entry (representing the cluster config)
clusterNode := NodeResponse{
ID: generateNodeID("pve", 0),
Type: "pve",
Name: "mock-cluster", // The cluster name
Host: "192.168.0.100:8006", // Primary entry point
User: "root@pam",
HasPassword: true,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
MonitorVMs: true,
MonitorContainers: true,
MonitorStorage: true,
MonitorBackups: true,
MonitorPhysicalDisks: nil, // nil = enabled by default
Status: "connected",
IsCluster: true,
ClusterName: "mock-cluster",
ClusterEndpoints: clusterEndpoints, // All cluster nodes
}
mockNodes = append(mockNodes, clusterNode)
}
// Add standalone nodes as individual entries
for i, node := range standaloneNodes {
standaloneNode := NodeResponse{
ID: generateNodeID("pve", len(mockNodes)+i),
Type: "pve",
Name: node.Name, // Use the actual node name
Host: fmt.Sprintf("192.168.0.%d:8006", 150+i), // Different IP range for standalone
User: "root@pam",
HasPassword: true,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
MonitorVMs: true,
MonitorContainers: true,
MonitorStorage: true,
MonitorBackups: true,
MonitorPhysicalDisks: nil, // nil = enabled by default
Status: "connected",
IsCluster: false, // Not part of a cluster
ClusterName: "",
ClusterEndpoints: []config.ClusterEndpoint{},
}
mockNodes = append(mockNodes, standaloneNode)
}
// Add mock PBS instances
for i, pbs := range state.PBSInstances {
pbsNode := NodeResponse{
ID: generateNodeID("pbs", i),
Type: "pbs",
Name: pbs.Name,
Host: pbs.Host,
User: "pulse@pbs",
HasPassword: false,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
MonitorDatastores: true,
MonitorSyncJobs: true,
MonitorVerifyJobs: true,
MonitorPruneJobs: true,
MonitorGarbageJobs: true,
Status: "connected", // Always connected in mock mode
}
mockNodes = append(mockNodes, pbsNode)
}
// Add mock PMG instances
for i, pmg := range state.PMGInstances {
pmgNode := NodeResponse{
ID: generateNodeID("pmg", i),
Type: "pmg",
Name: pmg.Name,
Host: pmg.Host,
User: "root@pam",
HasPassword: true,
TokenName: "pulse",
HasToken: true,
Fingerprint: "",
VerifySSL: false,
Status: "connected", // Always connected in mock mode
}
mockNodes = append(mockNodes, pmgNode)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(mockNodes)
return
}
nodes := h.GetAllNodesForAPI(r.Context())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(nodes)
}
// validateIPAddress validates if a string is a valid IP address
func validateIPAddress(ip string) bool {
// Parse as IP address
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return false
}
// Ensure it's IPv4 or IPv6
return parsedIP.To4() != nil || parsedIP.To16() != nil
}
// validatePort validates if a port number is in valid range
func validatePort(portStr string) bool {
port, err := strconv.Atoi(portStr)
if err != nil {
return false
}
return port > 0 && port <= 65535
}
// extractHostAndPort extracts the host and port from a URL or host:port string
func extractHostAndPort(hostStr string) (string, string, error) {
// Remove protocol if present
if strings.HasPrefix(hostStr, "http://") {
hostStr = strings.TrimPrefix(hostStr, "http://")
} else if strings.HasPrefix(hostStr, "https://") {
hostStr = strings.TrimPrefix(hostStr, "https://")
}
// Remove trailing slash and path if present
if idx := strings.Index(hostStr, "/"); idx != -1 {
hostStr = hostStr[:idx]
}
// Check if it contains a port
if strings.Contains(hostStr, ":") {
host, port, err := net.SplitHostPort(hostStr)
if err != nil {
// Might be IPv6 without port
if strings.Count(hostStr, ":") > 1 && !strings.Contains(hostStr, "[") {
return hostStr, "", nil
}
return "", "", fmt.Errorf("invalid host:port format")
}
return host, port, nil
}
return hostStr, "", nil
}
func defaultPortForNodeType(nodeType string) string {
switch nodeType {
case "pve", "pmg":
return "8006"
case "pbs":
return "8007"
default:
return ""
}
}
// normalizeNodeHost ensures hosts always include a scheme and default port when one
// isn't provided. Defaults align with Proxmox APIs (PVE/PMG: 8006, PBS: 8007) while
// preserving any explicit scheme/port the user supplies.
func normalizeNodeHost(rawHost, nodeType string) (string, error) {
host := strings.TrimSpace(rawHost)
if host == "" {
return "", fmt.Errorf("host is required")
}
scheme := "https"
if strings.HasPrefix(host, "http://") {
scheme = "http"
host = strings.TrimPrefix(host, "http://")
} else if strings.HasPrefix(host, "https://") {
host = strings.TrimPrefix(host, "https://")
}
// Strip any path/query fragments before parsing
if slash := strings.Index(host, "/"); slash != -1 {
host = host[:slash]
}
hostWithoutBrackets := strings.Trim(host, "[]")
if ip := net.ParseIP(hostWithoutBrackets); ip != nil && strings.Contains(hostWithoutBrackets, ":") && !strings.HasPrefix(host, "[") {
host = "[" + host + "]"
}
hostForParse := scheme + "://" + host
parsed, err := url.Parse(hostForParse)
if err != nil || parsed.Host == "" {
return "", fmt.Errorf("invalid host format")
}
// Drop any path/query fragments to avoid persisting unsafe values
parsed.Path = ""
parsed.RawPath = ""
parsed.RawQuery = ""
parsed.Fragment = ""
if parsed.Port() == "" {
defaultPort := defaultPortForNodeType(nodeType)
if defaultPort != "" {
parsed.Host = net.JoinHostPort(parsed.Hostname(), defaultPort)
}
}
return parsed.String(), nil
}
// extractHostIP extracts the IP address from a host URL if it's an IP-based URL.
// Returns empty string if the URL uses a hostname instead of an IP.
// isPulseAgentToken returns true if the token ID was created by the Pulse agent.
// Agent tokens follow the pattern "pulse-monitor@pam!pulse-<scope>" (PVE)
// or "pulse-monitor@pbs!pulse-<scope>" (PBS).
// Legacy agent tokens may include an additional "-<timestamp>" suffix.
func isPulseAgentToken(tokenID string) bool {
return strings.HasPrefix(tokenID, "pulse-monitor@pam!pulse-") ||
strings.HasPrefix(tokenID, "pulse-monitor@pbs!pulse-")
}
func extractHostIP(hostURL string) string {
parsed, err := url.Parse(hostURL)
if err != nil {
return ""
}
hostname := parsed.Hostname()
if hostname == "" {
return ""
}
// Check if hostname is an IP address
if ip := net.ParseIP(hostname); ip != nil {
return ip.String()
}
return ""
}
// resolveHostnameToIP attempts to resolve a hostname URL to its first IP address.
// Returns empty string if resolution fails or times out.
func resolveHostnameToIP(hostURL string) string {
parsed, err := url.Parse(hostURL)
if err != nil {
return ""
}
hostname := parsed.Hostname()
if hostname == "" {
return ""
}
// Don't try to resolve if it's already an IP
if ip := net.ParseIP(hostname); ip != nil {
return ip.String()
}
// Resolve with a short timeout to avoid blocking
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var resolver net.Resolver
addrs, err := resolver.LookupHost(ctx, hostname)
if err != nil || len(addrs) == 0 {
log.Debug().
Str("hostname", hostname).
Err(err).
Msg("Failed to resolve hostname for duplicate detection")
return ""
}
return addrs[0]
}
// disambiguateNodeName ensures a node name is unique by appending the host IP if needed.
// This handles cases where multiple Proxmox hosts have the same hostname (e.g., "px1" on different networks).
// Returns the original name if unique, or "name (ip)" if duplicates exist.
func (h *ConfigHandlers) disambiguateNodeName(ctx context.Context, name, host, nodeType string) string {
if name == "" {
return name
}
// Check if any existing node has the same name
hasDuplicate := false
if nodeType == "pve" {
for _, node := range h.getConfig(ctx).PVEInstances {
if strings.EqualFold(node.Name, name) && node.Host != host {
hasDuplicate = true
break
}
}
} else if nodeType == "pbs" {
for _, node := range h.getConfig(ctx).PBSInstances {
if strings.EqualFold(node.Name, name) && node.Host != host {
hasDuplicate = true
break
}
}
}
if !hasDuplicate {
return name
}
// Extract IP/hostname from host URL for disambiguation
parsed, err := url.Parse(host)
if err != nil || parsed.Host == "" {
// Fallback: use a short hash of the host
return fmt.Sprintf("%s (%s)", name, host[:min(15, len(host))])
}
hostname := parsed.Hostname()
return fmt.Sprintf("%s (%s)", name, hostname)
}
// HandleAddNode adds a new node
func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) {
// Prevent node modifications in mock mode
if mock.IsMockEnabled() {
http.Error(w, "Cannot modify nodes in mock mode. Please disable mock mode first: /opt/pulse/scripts/toggle-mock.sh off", http.StatusForbidden)
return
}
// Limit request body to 32KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
var req NodeConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error().Err(err).Msg("Failed to decode add node request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
log.Info().
Str("type", req.Type).
Str("name", req.Name).
Str("host", req.Host).
Str("user", req.User).
Str("tokenName", req.TokenName).
Bool("hasTokenValue", req.TokenValue != "").
Msg("Add node request received")
// Validate required fields
if req.Name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
if req.Type == "" {
http.Error(w, "Type is required", http.StatusBadRequest)
return
}
if req.Host == "" {
http.Error(w, "Host is required", http.StatusBadRequest)
return
}
// Validate host format (IP address or hostname with optional port)
host, port, err := extractHostAndPort(req.Host)
if err != nil {
http.Error(w, "Invalid host format", http.StatusBadRequest)
return
}
// If it looks like an IP address, validate it strictly
// Check if it starts with a digit (likely an IP)
if len(host) > 0 && (host[0] >= '0' && host[0] <= '9') {
// Likely an IP address, validate strictly
if !validateIPAddress(host) {
http.Error(w, "Invalid IP address", http.StatusBadRequest)
return
}
} else if strings.Contains(host, ":") && strings.Contains(host, "[") {
// IPv6 address with brackets
ipv6 := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
if !validateIPAddress(ipv6) {
http.Error(w, "Invalid IPv6 address", http.StatusBadRequest)
return
}
} else if req.Type == "pbs" {
// Validate as hostname - no spaces or special characters
if strings.ContainsAny(host, " /\\<>|\"'`;") {
http.Error(w, "Invalid hostname", http.StatusBadRequest)
return
}
}
// Validate port if provided
if port != "" && !validatePort(port) {
http.Error(w, "Invalid port number", http.StatusBadRequest)
return
}
if req.Type != "pve" && req.Type != "pbs" && req.Type != "pmg" {
http.Error(w, "Invalid node type", http.StatusBadRequest)
return
}
// Check for authentication
hasAuth := (req.User != "" && req.Password != "") || (req.TokenName != "" && req.TokenValue != "")
if !hasAuth {
http.Error(w, "Authentication credentials required", http.StatusBadRequest)
return
}
normalizedHost, err := normalizeNodeHost(req.Host, req.Type)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Check for duplicate nodes by HOST URL (not name!)
// Different physical hosts can share the same hostname (Issue #891).
// We disambiguate names later, but Host URLs must be unique.
switch req.Type {
case "pve":
for _, node := range h.getConfig(r.Context()).PVEInstances {
if node.Host == normalizedHost {
http.Error(w, "A node with this host URL already exists", http.StatusConflict)
return
}
}
case "pbs":
for _, node := range h.getConfig(r.Context()).PBSInstances {
if node.Host == normalizedHost {
http.Error(w, "A node with this host URL already exists", http.StatusConflict)
return
}
}
case "pmg":
for _, node := range h.getConfig(r.Context()).PMGInstances {
if node.Host == normalizedHost {
http.Error(w, "A node with this host URL already exists", http.StatusConflict)
return
}
}
}
// Add to appropriate list
if req.Type == "pve" {
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
req.User = normalizePVEUser(req.User)
}
host := normalizedHost
// Check if node is part of a cluster (skip for test/invalid IPs)
var isCluster bool
var clusterName string
var clusterEndpoints []config.ClusterEndpoint
// Skip cluster detection for obviously test/invalid IPs
skipClusterDetection := strings.Contains(req.Host, "192.168.77.") ||
strings.Contains(req.Host, "192.168.88.") ||
strings.Contains(req.Host, "test-") ||
strings.Contains(req.Name, "test-") ||
strings.Contains(req.Name, "persist-") ||
strings.Contains(req.Name, "concurrent-")
if !skipClusterDetection {
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
clientConfig := config.CreateProxmoxConfigFromFields(host, req.User, req.Password, req.TokenName, req.TokenValue, req.Fingerprint, verifySSL)
isCluster, clusterName, clusterEndpoints = detectPVECluster(clientConfig, req.Name, nil)
}
// CLUSTER DEDUPLICATION: If this node is part of a cluster, check if we already
// have that cluster configured. If so, this is a duplicate - we should merge
// the node as an endpoint to the existing cluster instead of creating a new instance.
// This prevents duplicate VMs/containers when users install agents on multiple cluster nodes.
if isCluster && clusterName != "" {
for i := range h.getConfig(r.Context()).PVEInstances {
existingInstance := &h.getConfig(r.Context()).PVEInstances[i]
if existingInstance.IsCluster && existingInstance.ClusterName == clusterName {
// Found existing cluster with same name - merge endpoints!
log.Info().
Str("cluster", clusterName).
Str("existingInstance", existingInstance.Name).
Str("newNode", req.Name).
Msg("New node belongs to already-configured cluster - merging as endpoint instead of creating duplicate")
// Merge any new endpoints from the detected cluster
existingEndpointMap := make(map[string]bool)
for _, ep := range existingInstance.ClusterEndpoints {
existingEndpointMap[ep.NodeName] = true
}
for _, newEp := range clusterEndpoints {
if !existingEndpointMap[newEp.NodeName] {
existingInstance.ClusterEndpoints = append(existingInstance.ClusterEndpoints, newEp)
log.Info().
Str("cluster", clusterName).
Str("endpoint", newEp.NodeName).
Msg("Added new endpoint to existing cluster")
}
}
// Save the updated configuration
if h.getPersistence(r.Context()) != nil {
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
log.Warn().Err(err).Msg("Failed to persist cluster endpoint merge")
}
}
// Reload the monitor to pick up the updated endpoints
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Warn().Err(err).Msg("Failed to reload monitor after cluster merge")
}
}
// Return success - the cluster is now updated with new endpoints
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"merged": true,
"cluster": clusterName,
"existingNode": existingInstance.Name,
"message": fmt.Sprintf("Node merged into existing cluster '%s' (already configured as '%s')", clusterName, existingInstance.Name),
"totalEndpoints": len(existingInstance.ClusterEndpoints),
})
return
}
}
}
if isCluster {
log.Info().
Str("cluster", clusterName).
Int("endpoints", len(clusterEndpoints)).
Msg("Detected new Proxmox cluster, auto-discovering all nodes")
}
// Use sensible defaults for boolean fields if not provided
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
monitorVMs := true // Default to true
if req.MonitorVMs != nil {
monitorVMs = *req.MonitorVMs
}
monitorContainers := true // Default to true
if req.MonitorContainers != nil {
monitorContainers = *req.MonitorContainers
}
monitorStorage := true // Default to true
if req.MonitorStorage != nil {
monitorStorage = *req.MonitorStorage
}
monitorBackups := true // Default to true
if req.MonitorBackups != nil {
monitorBackups = *req.MonitorBackups
}
// Disambiguate name if duplicate hostnames exist (Issue #891)
displayName := h.disambiguateNodeName(r.Context(), req.Name, host, "pve")
pve := config.PVEInstance{
Name: displayName,
Host: host, // Use normalized host
GuestURL: req.GuestURL,
User: req.User,
Password: req.Password,
TokenName: req.TokenName,
TokenValue: req.TokenValue,
Fingerprint: req.Fingerprint,
VerifySSL: verifySSL,
MonitorVMs: monitorVMs,
MonitorContainers: monitorContainers,
MonitorStorage: monitorStorage,
MonitorBackups: monitorBackups,
MonitorPhysicalDisks: req.MonitorPhysicalDisks,
PhysicalDiskPollingMinutes: 0,
TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled,
IsCluster: isCluster,
ClusterName: clusterName,
ClusterEndpoints: clusterEndpoints,
}
if req.PhysicalDiskPollingMinutes != nil {
pve.PhysicalDiskPollingMinutes = *req.PhysicalDiskPollingMinutes
}
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pve)
if isCluster {
log.Info().
Str("cluster", clusterName).
Int("endpoints", len(clusterEndpoints)).
Msg("Added Proxmox cluster with auto-discovered endpoints")
}
} else if req.Type == "pbs" {
host := normalizedHost
// Parse PBS authentication details
var pbsUser string
var pbsPassword string
var pbsTokenName string
var pbsTokenValue string
// Determine authentication method
if req.TokenName != "" && req.TokenValue != "" {
// Using token authentication - don't store user/password
pbsTokenName = req.TokenName
pbsTokenValue = req.TokenValue
// Token name might contain the full format (user@realm!tokenname)
// The backend PBS client will parse this
} else if req.Password != "" {
// Using password authentication - try to create a token via API
// This enables turnkey setup for Docker/containerized PBS
pbsUser = req.User
if pbsUser != "" && !strings.Contains(pbsUser, "@") {
pbsUser = pbsUser + "@pbs" // Default to @pbs realm if not specified
}
log.Info().
Str("host", host).
Str("user", pbsUser).
Msg("PBS: Attempting turnkey token creation via API")
// Try to create a token using the provided credentials
pbsClient, err := pbs.NewClient(pbs.ClientConfig{
Host: host,
User: pbsUser,
Password: req.Password,
VerifySSL: false, // Self-signed certs common
})
if err != nil {
log.Warn().Err(err).Str("host", host).Msg("PBS: Failed to connect for token creation, falling back to password auth")
// Fallback to password auth
pbsPassword = req.Password
} else {
hostname, _ := os.Hostname()
tokenName := buildPulseMonitorTokenName(r.Host, hostname)
tokenID, tokenSecret, err := pbsClient.SetupMonitoringAccess(context.Background(), tokenName)
if err != nil {
log.Warn().Err(err).Str("host", host).Msg("PBS: Failed to create token via API, falling back to password auth")
// Fallback to password auth
pbsPassword = req.Password
} else {
// Successfully created token - use it instead of password
pbsTokenName = tokenID
pbsTokenValue = tokenSecret
pbsUser = "" // Clear password auth fields
pbsPassword = ""
log.Info().
Str("host", host).
Str("tokenID", tokenID).
Msg("PBS: Successfully created monitoring token via API")
}
}
}
// Use sensible defaults for boolean fields if not provided
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
monitorBackups := true // Default to true for PBS
if req.MonitorBackups != nil {
monitorBackups = *req.MonitorBackups
}
monitorDatastores := false
if req.MonitorDatastores != nil {
monitorDatastores = *req.MonitorDatastores
}
monitorSyncJobs := false
if req.MonitorSyncJobs != nil {
monitorSyncJobs = *req.MonitorSyncJobs
}
monitorVerifyJobs := false
if req.MonitorVerifyJobs != nil {
monitorVerifyJobs = *req.MonitorVerifyJobs
}
monitorPruneJobs := false
if req.MonitorPruneJobs != nil {
monitorPruneJobs = *req.MonitorPruneJobs
}
monitorGarbageJobs := false
if req.MonitorGarbageJobs != nil {
monitorGarbageJobs = *req.MonitorGarbageJobs
}
// Disambiguate name if duplicate hostnames exist (Issue #891)
pbsDisplayName := h.disambiguateNodeName(r.Context(), req.Name, host, "pbs")
pbs := config.PBSInstance{
Name: pbsDisplayName,
Host: host,
GuestURL: req.GuestURL,
User: pbsUser,
Password: pbsPassword,
TokenName: pbsTokenName,
TokenValue: pbsTokenValue,
Fingerprint: req.Fingerprint,
VerifySSL: verifySSL,
MonitorBackups: monitorBackups,
MonitorDatastores: monitorDatastores,
MonitorSyncJobs: monitorSyncJobs,
MonitorVerifyJobs: monitorVerifyJobs,
MonitorPruneJobs: monitorPruneJobs,
MonitorGarbageJobs: monitorGarbageJobs,
TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled,
}
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbs)
} else if req.Type == "pmg" {
host := normalizedHost
var pmgUser string
var pmgPassword string
var pmgTokenName string
var pmgTokenValue string
if req.TokenName != "" && req.TokenValue != "" {
pmgTokenName = req.TokenName
pmgTokenValue = req.TokenValue
} else if req.Password != "" {
pmgUser = req.User
pmgPassword = req.Password
if pmgUser != "" && !strings.Contains(pmgUser, "@") {
pmgUser = pmgUser + "@pmg"
}
}
// Use sensible defaults for boolean fields if not provided
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
// Check if any monitoring flags are explicitly set to true
anyMonitoringEnabled := (req.MonitorMailStats != nil && *req.MonitorMailStats) ||
(req.MonitorQueues != nil && *req.MonitorQueues) ||
(req.MonitorQuarantine != nil && *req.MonitorQuarantine) ||
(req.MonitorDomainStats != nil && *req.MonitorDomainStats)
// Default MonitorMailStats to true if no monitoring is explicitly enabled
monitorMailStats := true // Default to true
if req.MonitorMailStats != nil {
monitorMailStats = *req.MonitorMailStats
} else if anyMonitoringEnabled {
monitorMailStats = false // Don't default to true if other monitoring is enabled
}
monitorQueues := false
if req.MonitorQueues != nil {
monitorQueues = *req.MonitorQueues
}
monitorQuarantine := false
if req.MonitorQuarantine != nil {
monitorQuarantine = *req.MonitorQuarantine
}
monitorDomainStats := false
if req.MonitorDomainStats != nil {
monitorDomainStats = *req.MonitorDomainStats
}
// Disambiguate name if duplicate hostnames exist (Issue #891)
// Note: PMG uses similar logic to PBS - we check against PMG instances
pmgDisplayName := req.Name
for _, node := range h.getConfig(r.Context()).PMGInstances {
if strings.EqualFold(node.Name, req.Name) && node.Host != host {
parsed, err := url.Parse(host)
if err == nil && parsed.Host != "" {
pmgDisplayName = fmt.Sprintf("%s (%s)", req.Name, parsed.Hostname())
}
break
}
}
pmgInstance := config.PMGInstance{
Name: pmgDisplayName,
Host: host,
GuestURL: req.GuestURL,
User: pmgUser,
Password: pmgPassword,
TokenName: pmgTokenName,
TokenValue: pmgTokenValue,
Fingerprint: req.Fingerprint,
VerifySSL: verifySSL,
MonitorMailStats: monitorMailStats,
MonitorQueues: monitorQueues,
MonitorQuarantine: monitorQuarantine,
MonitorDomainStats: monitorDomainStats,
TemperatureMonitoringEnabled: req.TemperatureMonitoringEnabled,
}
h.getConfig(r.Context()).PMGInstances = append(h.getConfig(r.Context()).PMGInstances, pmgInstance)
}
// Save configuration to disk using our persistence instance
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
log.Error().Err(err).Msg("Failed to save nodes configuration")
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
// Reload monitor with new configuration
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor")
http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// HandleTestConnection tests a node connection without saving
func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) {
// Limit request body to 32KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
var req NodeConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error().Err(err).Msg("Failed to decode test connection request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
log.Info().
Str("type", req.Type).
Str("name", req.Name).
Str("host", req.Host).
Str("user", req.User).
Str("tokenName", req.TokenName).
Bool("hasTokenValue", req.TokenValue != "").
Msg("Test connection request received")
// Parse token format if needed
user := req.User
tokenName := req.TokenName
// If tokenName contains the full format (user@realm!tokenname), parse it
if strings.Contains(req.TokenName, "!") {
parts := strings.Split(req.TokenName, "!")
if len(parts) == 2 {
user = parts[0]
tokenName = parts[1]
}
}
// If user field contains the full format, extract just the user part
if strings.Contains(user, "!") {
parts := strings.Split(user, "!")
if len(parts) >= 1 {
user = parts[0]
}
}
log.Info().
Str("parsedUser", user).
Str("parsedTokenName", tokenName).
Msg("Parsed authentication details")
// Validate request
if req.Host == "" {
http.Error(w, "Host is required", http.StatusBadRequest)
return
}
// Auto-generate name if not provided for test
if req.Name == "" {
// Extract hostname from URL
host := strings.TrimPrefix(strings.TrimPrefix(req.Host, "http://"), "https://")
// Remove port
if colonIndex := strings.Index(host, ":"); colonIndex != -1 {
host = host[:colonIndex]
}
req.Name = host
}
if req.Type != "pve" && req.Type != "pbs" && req.Type != "pmg" {
http.Error(w, "Invalid node type", http.StatusBadRequest)
return
}
// Check for authentication
hasAuth := (user != "" && req.Password != "") || (tokenName != "" && req.TokenValue != "")
if !hasAuth {
http.Error(w, "Authentication credentials required", http.StatusBadRequest)
return
}
normalizedHost, err := normalizeNodeHost(req.Host, req.Type)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Test connection based on type
if req.Type == "pve" {
host := normalizedHost
// Create a temporary client
authUser := req.User
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
authUser = normalizePVEUser(authUser)
req.User = authUser
}
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
clientConfig := proxmox.ClientConfig{
Host: host,
User: authUser,
Password: req.Password,
TokenName: req.TokenName, // Pass the full token ID
TokenValue: req.TokenValue,
VerifySSL: verifySSL,
Fingerprint: req.Fingerprint,
}
tempClient, err := proxmox.NewClient(clientConfig)
if err != nil {
http.Error(w, sanitizeErrorMessage(err, "create_client"), http.StatusBadRequest)
return
}
// Try to get nodes to test connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
nodes, err := tempClient.GetNodes(ctx)
if err != nil {
http.Error(w, sanitizeErrorMessage(err, "connection"), http.StatusBadRequest)
return
}
isCluster, _, clusterEndpoints := detectPVECluster(clientConfig, req.Name, nil)
response := map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Successfully connected to %d node(s)", len(nodes)),
"isCluster": isCluster,
"nodeCount": len(nodes),
}
if isCluster {
response["clusterNodeCount"] = len(clusterEndpoints)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else if req.Type == "pbs" {
host := normalizedHost
log.Info().
Str("processedHost", host).
Msg("PBS host after port processing")
// PBS test connection
// Parse PBS authentication details
pbsUser := user
pbsTokenName := tokenName
// Handle different token input formats
if req.TokenName != "" && req.TokenValue != "" {
// Check if token name contains the full format (user@realm!tokenname)
if strings.Contains(req.TokenName, "!") {
// Token name is in full format, leave it as is
// The PBS client will parse it
} else if pbsUser != "" && !strings.Contains(pbsUser, "@") {
// User provided separately without realm, add default realm
pbsUser = pbsUser + "@pbs"
}
} else if pbsUser != "" && !strings.Contains(pbsUser, "@") {
// Password auth: ensure user has realm
pbsUser = pbsUser + "@pbs" // Default to @pbs realm if not specified
}
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
clientConfig := pbs.ClientConfig{
Host: host,
User: pbsUser,
Password: req.Password,
TokenName: pbsTokenName,
TokenValue: req.TokenValue,
VerifySSL: verifySSL,
Fingerprint: req.Fingerprint,
}
tempClient, err := pbs.NewClient(clientConfig)
if err != nil {
http.Error(w, sanitizeErrorMessage(err, "create_client"), http.StatusBadRequest)
return
}
// Try to get datastores to test connection
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
datastores, err := tempClient.GetDatastores(ctx)
if err != nil {
http.Error(w, sanitizeErrorMessage(err, "connection"), http.StatusBadRequest)
return
}
response := map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Successfully connected. Found %d datastore(s)", len(datastores)),
"datastoreCount": len(datastores),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
} else {
host := normalizedHost
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
clientConfig := config.CreatePMGConfigFromFields(host, req.User, req.Password, req.TokenName, req.TokenValue, req.Fingerprint, verifySSL)
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
if clientConfig.User != "" && !strings.Contains(clientConfig.User, "@") {
clientConfig.User = clientConfig.User + "@pmg"
}
} else if req.TokenName != "" && req.TokenValue != "" {
if user != "" {
normalizedUser := user
if !strings.Contains(normalizedUser, "@") {
normalizedUser = normalizedUser + "@pmg"
}
clientConfig.User = normalizedUser
}
}
tempClient, err := pmg.NewClient(clientConfig)
if err != nil {
http.Error(w, sanitizeErrorMessage(err, "create_client"), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
version, err := tempClient.GetVersion(ctx)
if err != nil {
http.Error(w, sanitizeErrorMessage(err, "connection"), http.StatusBadRequest)
return
}
versionLabel := ""
if version != nil && strings.TrimSpace(version.Version) != "" {
versionLabel = strings.TrimSpace(version.Version)
if strings.TrimSpace(version.Release) != "" {
versionLabel = versionLabel + "-" + strings.TrimSpace(version.Release)
}
}
// Test actual metrics endpoints to ensure monitoring will work
warnings := []string{}
// Test mail statistics endpoint (core PMG functionality)
if _, err := tempClient.GetMailStatistics(ctx, "day"); err != nil {
warnings = append(warnings, "Mail statistics endpoint unavailable - check user permissions")
log.Warn().Err(err).Msg("PMG connection test: mail statistics check failed")
}
// Test cluster status endpoint
if _, err := tempClient.GetClusterStatus(ctx, true); err != nil {
warnings = append(warnings, "Cluster status endpoint unavailable")
log.Warn().Err(err).Msg("PMG connection test: cluster status check failed")
}
// Test quarantine endpoint
if _, err := tempClient.GetQuarantineStatus(ctx, "spam"); err != nil {
warnings = append(warnings, "Quarantine endpoint unavailable")
log.Warn().Err(err).Msg("PMG connection test: quarantine check failed")
}
message := "Connected to PMG instance"
if versionLabel != "" {
message = fmt.Sprintf("Connected to PMG instance (version %s)", versionLabel)
}
if len(warnings) > 0 {
message += " (some metrics may be unavailable - check logs for details)"
}
response := map[string]interface{}{
"status": "success",
"message": message,
}
if version != nil {
if version.Version != "" {
response["version"] = strings.TrimSpace(version.Version)
}
if version.Release != "" {
response["release"] = strings.TrimSpace(version.Release)
}
}
if len(warnings) > 0 {
response["warnings"] = warnings
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// HandleUpdateNode updates an existing node
func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request) {
// Prevent node modifications in mock mode
if mock.IsMockEnabled() {
http.Error(w, "Cannot modify nodes in mock mode", http.StatusForbidden)
return
}
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
if nodeID == "" {
http.Error(w, "Node ID required", http.StatusBadRequest)
return
}
// Limit request body to 32KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
var req NodeConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
// Debug: Log the received temperatureMonitoringEnabled value
log.Info().
Str("nodeID", nodeID).
Interface("temperatureMonitoringEnabled", req.TemperatureMonitoringEnabled).
Msg("Received node update request")
// Parse node ID
parts := strings.Split(nodeID, "-")
if len(parts) != 2 {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
nodeType := parts[0]
index := 0
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
// Update the node
if nodeType == "pve" && index < len(h.getConfig(r.Context()).PVEInstances) {
pve := &h.getConfig(r.Context()).PVEInstances[index]
// Only update name if provided
if req.Name != "" {
pve.Name = req.Name
}
if req.Host != "" {
host, err := normalizeNodeHost(req.Host, nodeType)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pve.Host = host
}
// Update GuestURL if provided
pve.GuestURL = req.GuestURL
// Handle authentication updates - only switch auth method if explicitly provided
if req.TokenName != "" || req.TokenValue != "" {
// Switching to or updating token authentication
if req.TokenName != "" {
pve.TokenName = req.TokenName
}
if req.TokenValue != "" {
pve.TokenValue = req.TokenValue
}
// Clear password to avoid conflicts
pve.Password = ""
if req.User != "" {
pve.User = req.User
}
} else if req.Password != "" {
// Explicitly switching to password authentication
if req.User != "" {
pve.User = normalizePVEUser(req.User)
} else if pve.User != "" {
pve.User = normalizePVEUser(pve.User)
}
pve.Password = req.Password
// Clear token fields when switching to password auth
pve.TokenName = ""
pve.TokenValue = ""
} else {
// No authentication changes - preserve existing auth fields
// Only normalize user if it exists
if pve.User != "" {
pve.User = normalizePVEUser(pve.User)
}
}
pve.Fingerprint = req.Fingerprint
if req.VerifySSL != nil {
pve.VerifySSL = *req.VerifySSL
}
if req.MonitorVMs != nil {
pve.MonitorVMs = *req.MonitorVMs
}
if req.MonitorContainers != nil {
pve.MonitorContainers = *req.MonitorContainers
}
if req.MonitorStorage != nil {
pve.MonitorStorage = *req.MonitorStorage
}
if req.MonitorBackups != nil {
pve.MonitorBackups = *req.MonitorBackups
}
if req.MonitorPhysicalDisks != nil {
pve.MonitorPhysicalDisks = req.MonitorPhysicalDisks
}
if req.PhysicalDiskPollingMinutes != nil {
pve.PhysicalDiskPollingMinutes = *req.PhysicalDiskPollingMinutes
}
if req.TemperatureMonitoringEnabled != nil {
pve.TemperatureMonitoringEnabled = req.TemperatureMonitoringEnabled
}
} else if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
pbs := &h.getConfig(r.Context()).PBSInstances[index]
// Only update name if provided
if req.Name != "" {
pbs.Name = req.Name
}
if req.Host != "" {
host, err := normalizeNodeHost(req.Host, nodeType)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pbs.Host = host
}
// Update GuestURL if provided
pbs.GuestURL = req.GuestURL
// Handle authentication updates - only switch auth method if explicitly provided
if req.TokenName != "" && req.TokenValue != "" {
// Switching to token authentication
pbs.TokenName = req.TokenName
pbs.TokenValue = req.TokenValue
// Clear user/password when switching to token auth
pbs.User = ""
pbs.Password = ""
} else if req.TokenName != "" {
// Token name provided without new value - keep existing token value
pbs.TokenName = req.TokenName
// Clear user/password when using token auth
pbs.User = ""
pbs.Password = ""
} else if req.Password != "" {
// Switching to password authentication
pbs.Password = req.Password
// Ensure user has realm for PBS
pbsUser := req.User
if req.User != "" && !strings.Contains(req.User, "@") {
pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified
}
pbs.User = pbsUser
// Clear token fields when switching to password auth
pbs.TokenName = ""
pbs.TokenValue = ""
} else if req.User != "" {
// User provided - assume password auth but keep existing password
// Ensure user has realm for PBS
pbsUser := req.User
if !strings.Contains(req.User, "@") {
pbsUser = req.User + "@pbs" // Default to @pbs realm if not specified
}
pbs.User = pbsUser
// Clear token fields when using password auth
pbs.TokenName = ""
pbs.TokenValue = ""
}
// else: No authentication changes - preserve existing auth fields
pbs.Fingerprint = req.Fingerprint
if req.VerifySSL != nil {
pbs.VerifySSL = *req.VerifySSL
}
if req.MonitorBackups != nil {
pbs.MonitorBackups = *req.MonitorBackups
} else {
pbs.MonitorBackups = true // Enable by default for PBS
}
if req.MonitorDatastores != nil {
pbs.MonitorDatastores = *req.MonitorDatastores
}
if req.MonitorSyncJobs != nil {
pbs.MonitorSyncJobs = *req.MonitorSyncJobs
}
if req.MonitorVerifyJobs != nil {
pbs.MonitorVerifyJobs = *req.MonitorVerifyJobs
}
if req.MonitorPruneJobs != nil {
pbs.MonitorPruneJobs = *req.MonitorPruneJobs
}
if req.MonitorGarbageJobs != nil {
pbs.MonitorGarbageJobs = *req.MonitorGarbageJobs
}
if req.TemperatureMonitoringEnabled != nil {
pbs.TemperatureMonitoringEnabled = req.TemperatureMonitoringEnabled
}
// Update datastore exclusion list
if req.ExcludeDatastores != nil {
pbs.ExcludeDatastores = req.ExcludeDatastores
}
} else if nodeType == "pmg" && index < len(h.getConfig(r.Context()).PMGInstances) {
pmgInst := &h.getConfig(r.Context()).PMGInstances[index]
pmgInst.Name = req.Name
if req.Host != "" {
host, err := normalizeNodeHost(req.Host, nodeType)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
pmgInst.Host = host
}
// Update GuestURL if provided
pmgInst.GuestURL = req.GuestURL
// Handle authentication updates - only switch auth method if explicitly provided
if req.TokenName != "" && req.TokenValue != "" {
// Switching to token authentication
pmgInst.TokenName = req.TokenName
pmgInst.TokenValue = req.TokenValue
// Clear user/password when switching to token auth
pmgInst.User = ""
pmgInst.Password = ""
} else if req.Password != "" {
// Switching to password authentication
if req.User != "" {
user := req.User
if !strings.Contains(user, "@") {
user = user + "@pmg"
}
pmgInst.User = user
}
pmgInst.Password = req.Password
// Clear token fields when switching to password auth
pmgInst.TokenName = ""
pmgInst.TokenValue = ""
} else if req.User != "" {
// User provided - assume password auth but keep existing password
user := req.User
if !strings.Contains(user, "@") {
user = user + "@pmg"
}
pmgInst.User = user
// Clear token fields when using password auth
pmgInst.TokenName = ""
pmgInst.TokenValue = ""
}
// else: No authentication changes - preserve existing auth fields
pmgInst.Fingerprint = req.Fingerprint
if req.VerifySSL != nil {
pmgInst.VerifySSL = *req.VerifySSL
}
// Special logic for MonitorMailStats: default to true if all monitor flags are false/unset
if req.MonitorMailStats != nil {
pmgInst.MonitorMailStats = *req.MonitorMailStats
} else if (req.MonitorMailStats == nil || !*req.MonitorMailStats) &&
(req.MonitorQueues == nil || !*req.MonitorQueues) &&
(req.MonitorQuarantine == nil || !*req.MonitorQuarantine) &&
(req.MonitorDomainStats == nil || !*req.MonitorDomainStats) {
pmgInst.MonitorMailStats = true
}
if req.MonitorQueues != nil {
pmgInst.MonitorQueues = *req.MonitorQueues
}
if req.MonitorQuarantine != nil {
pmgInst.MonitorQuarantine = *req.MonitorQuarantine
}
if req.MonitorDomainStats != nil {
pmgInst.MonitorDomainStats = *req.MonitorDomainStats
}
if req.TemperatureMonitoringEnabled != nil {
pmgInst.TemperatureMonitoringEnabled = req.TemperatureMonitoringEnabled
}
} else {
http.Error(w, "Node not found", http.StatusNotFound)
return
}
// Save configuration to disk using our persistence instance
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
log.Error().Err(err).Msg("Failed to save nodes configuration")
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
// IMPORTANT: Preserve alert overrides when updating nodes
// This fixes issue #440 where PBS alert thresholds were being reset
// Alert overrides are stored separately from node configuration
// and must be explicitly preserved during node updates
if h.getMonitor(r.Context()) != nil {
// Load current alert configuration to preserve overrides
alertConfig, err := h.getPersistence(r.Context()).LoadAlertConfig()
if err == nil && alertConfig != nil {
// For PBS nodes, we need to handle ID mapping
// PBS monitoring uses "pbs-<name>" but config uses "pbs-<index>"
// We need to preserve overrides by the monitoring ID
if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
pbsName := h.getConfig(r.Context()).PBSInstances[index].Name
monitoringID := "pbs-" + pbsName
// Check if there are overrides for this PBS node
if alertConfig.Overrides != nil {
if _, exists := alertConfig.Overrides[monitoringID]; exists {
log.Debug().
Str("nodeID", nodeID).
Str("monitoringID", monitoringID).
Str("pbsName", pbsName).
Msg("Preserving PBS alert overrides using monitoring ID")
}
}
}
// Apply the alert configuration to preserve all overrides
h.getMonitor(r.Context()).GetAlertManager().UpdateConfig(*alertConfig)
log.Debug().
Str("nodeID", nodeID).
Str("nodeType", nodeType).
Msg("Preserved alert overrides after node update")
}
}
// Reload monitor with new configuration
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor")
http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError)
return
}
}
// Trigger discovery refresh after adding node
if h.getMonitor(r.Context()) != nil && h.getMonitor(r.Context()).GetDiscoveryService() != nil {
log.Info().Msg("Triggering discovery refresh after adding node")
h.getMonitor(r.Context()).GetDiscoveryService().ForceRefresh()
// Broadcast discovery update via WebSocket
if h.wsHub != nil {
// Wait a moment for discovery to complete
go func() {
time.Sleep(2 * time.Second)
result, _ := h.getMonitor(r.Context()).GetDiscoveryService().GetCachedResult()
if result != nil {
h.wsHub.BroadcastMessage(websocket.Message{
Type: "discovery_update",
Data: map[string]interface{}{
"servers": result.Servers,
"errors": result.Errors,
"timestamp": time.Now().Unix(),
},
Timestamp: time.Now().Format(time.RFC3339),
})
log.Info().Msg("Broadcasted discovery update after adding node")
}
}()
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// HandleDeleteNode deletes a node
func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request) {
log.Info().Msg("HandleDeleteNode called")
// Prevent node modifications in mock mode
if mock.IsMockEnabled() {
http.Error(w, "Cannot modify nodes in mock mode", http.StatusForbidden)
return
}
nodeID := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
if nodeID == "" {
http.Error(w, "Node ID required", http.StatusBadRequest)
return
}
// Parse node ID
parts := strings.Split(nodeID, "-")
if len(parts) != 2 {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
nodeType := parts[0]
index := 0
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
log.Debug().
Str("nodeID", nodeID).
Str("nodeType", nodeType).
Int("index", index).
Int("pveCount", len(h.getConfig(r.Context()).PVEInstances)).
Int("pbsCount", len(h.getConfig(r.Context()).PBSInstances)).
Int("pmgCount", len(h.getConfig(r.Context()).PMGInstances)).
Msg("Attempting to delete node")
var deletedNodeHost string
// Delete the node
if nodeType == "pve" && index < len(h.getConfig(r.Context()).PVEInstances) {
deletedNodeHost = h.getConfig(r.Context()).PVEInstances[index].Host
log.Info().Str("nodeID", nodeID).Int("index", index).Msg("Deleting PVE node")
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances[:index], h.getConfig(r.Context()).PVEInstances[index+1:]...)
} else if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
deletedNodeHost = h.getConfig(r.Context()).PBSInstances[index].Host
log.Info().Str("nodeID", nodeID).Int("index", index).Msg("Deleting PBS node")
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances[:index], h.getConfig(r.Context()).PBSInstances[index+1:]...)
} else if nodeType == "pmg" && index < len(h.getConfig(r.Context()).PMGInstances) {
deletedNodeHost = h.getConfig(r.Context()).PMGInstances[index].Host
log.Info().Str("nodeID", nodeID).Int("index", index).Msg("Deleting PMG node")
h.getConfig(r.Context()).PMGInstances = append(h.getConfig(r.Context()).PMGInstances[:index], h.getConfig(r.Context()).PMGInstances[index+1:]...)
} else {
log.Warn().
Str("nodeID", nodeID).
Str("nodeType", nodeType).
Int("index", index).
Int("pveCount", len(h.getConfig(r.Context()).PVEInstances)).
Int("pbsCount", len(h.getConfig(r.Context()).PBSInstances)).
Int("pmgCount", len(h.getConfig(r.Context()).PMGInstances)).
Msg("Node not found for deletion")
http.Error(w, "Node not found", http.StatusNotFound)
return
}
// Save configuration to disk using our persistence instance
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 nodes configuration")
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
// Immediately trigger discovery scan BEFORE reloading monitor
// Capture node type for cleanup
var deletedNodeType string = nodeType
// deletedNodeHost already captured before removal when available
// Reload monitor with new configuration
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor")
http.Error(w, "Configuration saved but failed to apply changes", http.StatusInternalServerError)
return
}
}
// Broadcast node deletion to refresh the frontend
if h.wsHub != nil {
// Send a node_deleted message to trigger a refresh of the nodes list
h.wsHub.BroadcastMessage(websocket.Message{
Type: "node_deleted",
Data: map[string]interface{}{
"nodeType": nodeType,
},
Timestamp: time.Now().Format(time.RFC3339),
})
log.Info().Msg("Broadcasted node deletion event")
// Trigger a full discovery scan in the background to update the discovery cache
// This ensures the next time discovery modal is opened, it shows fresh results
go func() {
// Short delay to let the monitor stabilize
time.Sleep(500 * time.Millisecond)
// Trigger full discovery refresh
if h.getMonitor(r.Context()) != nil && h.getMonitor(r.Context()).GetDiscoveryService() != nil {
h.getMonitor(r.Context()).GetDiscoveryService().ForceRefresh()
log.Info().Msg("Triggered background discovery refresh after node deletion")
}
}()
}
if deletedNodeType == "pve" && deletedNodeHost != "" {
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// HandleRefreshClusterNodes re-detects cluster membership and updates endpoints
// This handles the case where nodes are added to a Proxmox cluster after initial configuration
func (h *ConfigHandlers) HandleRefreshClusterNodes(w http.ResponseWriter, r *http.Request) {
// Prevent modifications in mock mode
if mock.IsMockEnabled() {
http.Error(w, "Cannot refresh cluster in mock mode", http.StatusForbidden)
return
}
// Path format: /api/config/nodes/{id}/refresh-cluster
path := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
path = strings.TrimSuffix(path, "/refresh-cluster")
nodeID := path
if nodeID == "" {
http.Error(w, "Node ID required", http.StatusBadRequest)
return
}
// Parse node ID
parts := strings.Split(nodeID, "-")
if len(parts) != 2 {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
nodeType := parts[0]
index := 0
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
// Only PVE nodes can have clusters
if nodeType != "pve" {
http.Error(w, "Only PVE nodes can be cluster members", http.StatusBadRequest)
return
}
if index >= len(h.getConfig(r.Context()).PVEInstances) {
http.Error(w, "Node not found", http.StatusNotFound)
return
}
pve := &h.getConfig(r.Context()).PVEInstances[index]
// Create client config for cluster detection
clientConfig := config.CreateProxmoxConfig(pve)
// Force cluster re-detection (ignore existing endpoints)
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, pve.Name, pve.ClusterEndpoints)
if !isCluster {
http.Error(w, "Node is not part of a cluster", http.StatusBadRequest)
return
}
if len(clusterEndpoints) == 0 {
http.Error(w, "Could not detect cluster nodes", http.StatusInternalServerError)
return
}
oldEndpointCount := len(pve.ClusterEndpoints)
newEndpointCount := len(clusterEndpoints)
// Update cluster info
pve.IsCluster = true
if clusterName != "" && !strings.EqualFold(clusterName, "unknown cluster") {
pve.ClusterName = clusterName
}
pve.ClusterEndpoints = clusterEndpoints
// Save configuration
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
log.Error().Err(err).Msg("Failed to save nodes configuration after cluster refresh")
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
log.Info().
Str("instance", pve.Name).
Str("cluster", pve.ClusterName).
Int("old_endpoints", oldEndpointCount).
Int("new_endpoints", newEndpointCount).
Msg("Refreshed cluster membership")
// Reload monitor with new configuration
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after cluster refresh")
// Don't fail the request, config was saved successfully
}
}
// Broadcast update to refresh frontend
if h.wsHub != nil {
h.wsHub.BroadcastMessage(websocket.Message{
Type: "nodes_updated",
Data: map[string]interface{}{
"nodeType": "pve",
"action": "cluster_refresh",
},
Timestamp: time.Now().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"clusterName": pve.ClusterName,
"oldNodeCount": oldEndpointCount,
"newNodeCount": newEndpointCount,
"nodesAdded": newEndpointCount - oldEndpointCount,
"clusterNodes": clusterEndpoints,
})
}
// HandleTestNodeConfig tests a node connection from provided configuration
func (h *ConfigHandlers) HandleTestNodeConfig(w http.ResponseWriter, r *http.Request) {
// Limit request body to 32KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 32*1024)
var req NodeConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
req.normalizeTokenAliases()
var testResult map[string]interface{}
if req.Type == "pve" {
// Create a temporary client to test connection
authUser := req.User
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
authUser = normalizePVEUser(authUser)
req.User = authUser
}
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
clientConfig := proxmox.ClientConfig{
Host: req.Host,
User: authUser,
Password: req.Password,
TokenName: req.TokenName,
TokenValue: req.TokenValue,
VerifySSL: verifySSL,
Fingerprint: req.Fingerprint,
}
client, err := proxmox.NewClient(clientConfig)
if err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "create_client"),
}
} else {
// Test connection by getting nodes list
startTime := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if nodes, err := client.GetNodes(ctx); err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "connection"),
}
} else {
latency := time.Since(startTime).Milliseconds()
testResult = map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Connected to PVE cluster with %d nodes", len(nodes)),
"latency": latency,
}
}
}
} else if req.Type == "pbs" {
// Create a temporary client to test connection
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
clientConfig := pbs.ClientConfig{
Host: req.Host,
User: req.User,
Password: req.Password,
TokenName: req.TokenName,
TokenValue: req.TokenValue,
VerifySSL: verifySSL,
Fingerprint: req.Fingerprint,
}
client, err := pbs.NewClient(clientConfig)
if err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "create_client"),
}
} else {
// Test connection by getting datastores
startTime := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := client.GetDatastores(ctx); err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "connection"),
}
} else {
latency := time.Since(startTime).Milliseconds()
testResult = map[string]interface{}{
"status": "success",
"message": "Connected to PBS instance",
"latency": latency,
}
}
}
} else if req.Type == "pmg" {
verifySSL := false
if req.VerifySSL != nil {
verifySSL = *req.VerifySSL
}
clientConfig := pmg.ClientConfig{
Host: req.Host,
User: req.User,
Password: req.Password,
TokenName: req.TokenName,
TokenValue: req.TokenValue,
VerifySSL: verifySSL,
Fingerprint: req.Fingerprint,
}
client, err := pmg.NewClient(clientConfig)
if err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "create_client"),
}
} else {
startTime := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := client.GetVersion(ctx); err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "connection"),
}
} else {
latency := time.Since(startTime).Milliseconds()
testResult = map[string]interface{}{
"status": "success",
"message": "Connected to PMG instance",
"latency": latency,
}
}
}
} else {
http.Error(w, "Invalid node type", http.StatusBadRequest)
return
}
// Return appropriate HTTP status based on test result
w.Header().Set("Content-Type", "application/json")
if testResult["status"] == "error" {
w.WriteHeader(http.StatusBadRequest)
}
json.NewEncoder(w).Encode(testResult)
}
// HandleTestNode tests a node connection
func (h *ConfigHandlers) HandleTestNode(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "test" {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
nodeID := parts[0]
// Parse node ID
idParts := strings.Split(nodeID, "-")
if len(idParts) != 2 {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
nodeType := idParts[0]
index := 0
if _, err := fmt.Sscanf(idParts[1], "%d", &index); err != nil {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
// Find the node to test
var testResult map[string]interface{}
if nodeType == "pve" && index < len(h.getConfig(r.Context()).PVEInstances) {
pve := h.getConfig(r.Context()).PVEInstances[index]
// Create a temporary client to test connection
authUser := pve.User
if pve.TokenName == "" && pve.TokenValue == "" {
authUser = normalizePVEUser(authUser)
}
clientConfig := proxmox.ClientConfig{
Host: pve.Host,
User: authUser,
Password: pve.Password,
TokenName: pve.TokenName,
TokenValue: pve.TokenValue,
VerifySSL: pve.VerifySSL,
Fingerprint: pve.Fingerprint,
}
client, err := proxmox.NewClient(clientConfig)
if err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "create_client"),
}
} else {
// Test connection by getting nodes list
startTime := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if nodes, err := client.GetNodes(ctx); err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "connection"),
}
} else {
latency := time.Since(startTime).Milliseconds()
testResult = map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Connected to PVE cluster with %d nodes", len(nodes)),
"latency": latency,
}
}
}
} else if nodeType == "pbs" && index < len(h.getConfig(r.Context()).PBSInstances) {
pbsInstance := h.getConfig(r.Context()).PBSInstances[index]
// Create a temporary client to test connection
clientConfig := pbs.ClientConfig{
Host: pbsInstance.Host,
User: pbsInstance.User,
Password: pbsInstance.Password,
TokenName: pbsInstance.TokenName,
TokenValue: pbsInstance.TokenValue,
VerifySSL: pbsInstance.VerifySSL,
Fingerprint: pbsInstance.Fingerprint,
}
client, err := pbs.NewClient(clientConfig)
if err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "create_client"),
}
} else {
// Test connection by getting datastores
startTime := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := client.GetDatastores(ctx); err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "connection"),
}
} else {
latency := time.Since(startTime).Milliseconds()
testResult = map[string]interface{}{
"status": "success",
"message": "Connected to PBS",
"latency": latency,
}
}
}
} else if nodeType == "pmg" && index < len(h.getConfig(r.Context()).PMGInstances) {
pmgInstance := h.getConfig(r.Context()).PMGInstances[index]
clientConfig := config.CreatePMGConfig(&pmgInstance)
if pmgInstance.Password != "" && pmgInstance.TokenName == "" && pmgInstance.TokenValue == "" {
if clientConfig.User != "" && !strings.Contains(clientConfig.User, "@") {
clientConfig.User = clientConfig.User + "@pmg"
}
}
client, err := pmg.NewClient(clientConfig)
if err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "create_client"),
}
} else {
startTime := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if version, err := client.GetVersion(ctx); err != nil {
testResult = map[string]interface{}{
"status": "error",
"message": sanitizeErrorMessage(err, "connection"),
}
} else {
latency := time.Since(startTime).Milliseconds()
versionLabel := ""
if version != nil && strings.TrimSpace(version.Version) != "" {
versionLabel = strings.TrimSpace(version.Version)
if strings.TrimSpace(version.Release) != "" {
versionLabel = versionLabel + "-" + strings.TrimSpace(version.Release)
}
}
message := "Connected to PMG instance"
if versionLabel != "" {
message = fmt.Sprintf("Connected to PMG instance (version %s)", versionLabel)
}
testResult = map[string]interface{}{
"status": "success",
"message": message,
"latency": latency,
}
if version != nil {
if version.Version != "" {
testResult["version"] = strings.TrimSpace(version.Version)
}
if version.Release != "" {
testResult["release"] = strings.TrimSpace(version.Release)
}
}
}
}
} else {
testResult = map[string]interface{}{
"status": "error",
"message": "Node not found",
}
}
// Return appropriate HTTP status based on test result
w.Header().Set("Content-Type", "application/json")
if testResult["status"] == "error" {
w.WriteHeader(http.StatusBadRequest)
}
json.NewEncoder(w).Encode(testResult)
}
// getNodeStatus returns the connection status for a node
func (h *ConfigHandlers) getNodeStatus(ctx context.Context, nodeType, nodeName string) string {
if h.getMonitor(ctx) == nil {
if h.isRecentlyAutoRegistered(nodeType, nodeName) {
return "connected"
}
return "disconnected"
}
// Get connection statuses from monitor
connectionStatus := h.getMonitor(ctx).GetConnectionStatuses()
key := fmt.Sprintf("%s-%s", nodeType, nodeName)
if connected, ok := connectionStatus[key]; ok {
if connected {
h.clearAutoRegistered(nodeType, nodeName)
return "connected"
}
if h.isRecentlyAutoRegistered(nodeType, nodeName) {
return "connected"
}
return "disconnected"
}
if h.isRecentlyAutoRegistered(nodeType, nodeName) {
return "connected"
}
return "disconnected"
}
// HandleGetSystemSettings returns current system settings
func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
// Load settings from persistence to get all fields including theme
persistedSettings, err := h.getPersistence(r.Context()).LoadSystemSettings()
if err != nil {
log.Warn().Err(err).Msg("Failed to load persisted system settings")
persistedSettings = config.DefaultSystemSettings()
}
if persistedSettings == nil {
persistedSettings = config.DefaultSystemSettings()
}
// Get current values from running config
settings := *persistedSettings
settings.PVEPollingInterval = int(h.getConfig(r.Context()).PVEPollingInterval.Seconds())
settings.PBSPollingInterval = int(h.getConfig(r.Context()).PBSPollingInterval.Seconds())
settings.BackupPollingInterval = int(h.getConfig(r.Context()).BackupPollingInterval.Seconds())
settings.FrontendPort = h.getConfig(r.Context()).FrontendPort
settings.AllowedOrigins = h.getConfig(r.Context()).AllowedOrigins
settings.ConnectionTimeout = int(h.getConfig(r.Context()).ConnectionTimeout.Seconds())
settings.UpdateChannel = h.getConfig(r.Context()).UpdateChannel
settings.AutoUpdateEnabled = h.getConfig(r.Context()).AutoUpdateEnabled
settings.AutoUpdateCheckInterval = int(h.getConfig(r.Context()).AutoUpdateCheckInterval.Hours())
settings.AutoUpdateTime = h.getConfig(r.Context()).AutoUpdateTime
settings.LogLevel = h.getConfig(r.Context()).LogLevel
settings.DiscoveryEnabled = h.getConfig(r.Context()).DiscoveryEnabled
settings.DiscoverySubnet = h.getConfig(r.Context()).DiscoverySubnet
settings.DiscoveryConfig = config.CloneDiscoveryConfig(h.getConfig(r.Context()).Discovery)
backupEnabled := h.getConfig(r.Context()).EnableBackupPolling
settings.BackupPollingEnabled = &backupEnabled
// Create response structure that includes environment overrides
response := struct {
config.SystemSettings
EnvOverrides map[string]bool `json:"envOverrides,omitempty"`
}{
SystemSettings: settings,
EnvOverrides: make(map[string]bool),
}
// Check for environment variable overrides
if os.Getenv("PULSE_AUTH_HIDE_LOCAL_LOGIN") != "" {
response.EnvOverrides["hideLocalLogin"] = true
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleVerifyTemperatureSSH tests SSH connectivity to nodes for temperature monitoring
func (h *ConfigHandlers) HandleVerifyTemperatureSSH(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)
var req struct {
Nodes string `json:"nodes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusOK)
w.Write([]byte("⚠️ Unable to parse verification request"))
return
}
// Parse node list
nodeList := strings.Fields(req.Nodes)
if len(nodeList) == 0 {
w.WriteHeader(http.StatusOK)
w.Write([]byte("✓ No nodes to verify"))
return
}
// Test SSH connectivity using temperature collector with the correct SSH key
homeDir := os.Getenv("HOME")
if homeDir == "" {
homeDir = "/home/pulse"
}
sshKeyPath := filepath.Join(homeDir, ".ssh/id_ed25519_sensors")
tempCollector := monitoring.NewTemperatureCollectorWithPort("root", sshKeyPath, h.getConfig(r.Context()).SSHPort)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
successNodes := []string{}
failedNodes := []string{}
for _, node := range nodeList {
// Try to SSH and run sensors command
temp, err := tempCollector.CollectTemperature(ctx, node, node)
if err == nil && temp != nil && temp.Available {
successNodes = append(successNodes, node)
} else {
failedNodes = append(failedNodes, node)
}
}
// Build response message
var response strings.Builder
if len(successNodes) > 0 {
response.WriteString("✓ SSH connectivity verified for:\n")
for _, node := range successNodes {
response.WriteString(fmt.Sprintf(" • %s\n", node))
}
}
if len(failedNodes) > 0 {
if len(successNodes) > 0 {
response.WriteString("\n")
}
response.WriteString(" Temperature monitoring will be available once SSH connectivity is configured.\n")
response.WriteString("\n")
response.WriteString("Nodes pending configuration:\n")
for _, node := range failedNodes {
response.WriteString(fmt.Sprintf(" • %s\n", node))
}
response.WriteString("\n")
response.WriteString("See: https://github.com/rcourtman/Pulse/blob/main/SECURITY.md for detailed SSH configuration options.\n")
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte(response.String()))
}
// generateNodeID creates a unique ID for a node
func generateNodeID(nodeType string, index int) string {
return fmt.Sprintf("%s-%d", nodeType, index)
}
// ExportConfigRequest represents a request to export configuration
type ExportConfigRequest struct {
Passphrase string `json:"passphrase"`
}
// ImportConfigRequest represents a request to import configuration
type ImportConfigRequest struct {
Data string `json:"data"`
Passphrase string `json:"passphrase"`
}
// HandleExportConfig exports all configuration with encryption
func (h *ConfigHandlers) HandleExportConfig(w http.ResponseWriter, r *http.Request) {
// Limit request body to 8KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
// SECURITY: Validating scope for config export
if !ensureScope(w, r, config.ScopeSettingsRead) {
return
}
var req ExportConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error().Err(err).Msg("Failed to decode export request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Passphrase == "" {
log.Warn().Msg("Export rejected: passphrase is required")
http.Error(w, "Passphrase is required", http.StatusBadRequest)
return
}
// Require strong passphrase (at least 12 characters)
if len(req.Passphrase) < 12 {
log.Warn().Int("length", len(req.Passphrase)).Msg("Export rejected: passphrase too short (minimum 12 characters)")
http.Error(w, "Passphrase must be at least 12 characters long", http.StatusBadRequest)
return
}
// Export configuration
exportedData, err := h.getPersistence(r.Context()).ExportConfig(req.Passphrase)
if err != nil {
log.Error().Err(err).Msg("Failed to export configuration")
http.Error(w, "Failed to export configuration", http.StatusInternalServerError)
return
}
log.Info().Msg("Configuration exported successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"data": exportedData,
})
}
// HandleImportConfig imports configuration from encrypted export
func (h *ConfigHandlers) HandleImportConfig(w http.ResponseWriter, r *http.Request) {
// Limit request body to 1MB to prevent memory exhaustion (config imports can be large)
r.Body = http.MaxBytesReader(w, r.Body, 1024*1024)
// SECURITY: Validating scope for config import
if !ensureScope(w, r, config.ScopeSettingsWrite) {
return
}
var req ImportConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error().Err(err).Msg("Failed to decode import request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Passphrase == "" {
log.Warn().Msg("Import rejected: passphrase is required")
http.Error(w, "Passphrase is required", http.StatusBadRequest)
return
}
if req.Data == "" {
log.Warn().Msg("Import rejected: encrypted data is required (ensure backup file has 'data' field)")
http.Error(w, "Import data is required", http.StatusBadRequest)
return
}
// Import configuration
if err := h.getPersistence(r.Context()).ImportConfig(req.Data, req.Passphrase); err != nil {
log.Error().Err(err).Msg("Failed to import configuration")
http.Error(w, "Failed to import configuration: "+err.Error(), http.StatusBadRequest)
return
}
// Reload configuration from disk
newConfig, err := config.Load()
if err != nil {
log.Error().Err(err).Msg("Failed to reload configuration after import")
http.Error(w, "Configuration imported but failed to reload", http.StatusInternalServerError)
return
}
// Update the config reference
*h.getConfig(r.Context()) = *newConfig
// Reload monitor with new configuration
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after import")
http.Error(w, "Configuration imported but failed to apply changes", http.StatusInternalServerError)
return
}
}
// Also reload alert and notification configs explicitly
// (the monitor reload only reloads nodes unless it's a full reload)
if h.getMonitor(r.Context()) != nil {
// Reload alert configuration
if alertConfig, err := h.getPersistence(r.Context()).LoadAlertConfig(); err == nil {
h.getMonitor(r.Context()).GetAlertManager().UpdateConfig(*alertConfig)
log.Info().Msg("Reloaded alert configuration after import")
} else {
log.Warn().Err(err).Msg("Failed to reload alert configuration after import")
}
// Reload webhook configuration
if webhooks, err := h.getPersistence(r.Context()).LoadWebhooks(); err == nil {
// Clear existing webhooks and add new ones
notificationMgr := h.getMonitor(r.Context()).GetNotificationManager()
// Get current webhooks to clear them
for _, webhook := range notificationMgr.GetWebhooks() {
if err := notificationMgr.DeleteWebhook(webhook.ID); err != nil {
log.Warn().Err(err).Str("webhook", webhook.ID).Msg("Failed to delete existing webhook during reload")
}
}
// Add imported webhooks
for _, webhook := range webhooks {
notificationMgr.AddWebhook(webhook)
}
log.Info().Int("count", len(webhooks)).Msg("Reloaded webhook configuration after import")
} else {
log.Warn().Err(err).Msg("Failed to reload webhook configuration after import")
}
// Reload email configuration
if emailConfig, err := h.getPersistence(r.Context()).LoadEmailConfig(); err == nil {
h.getMonitor(r.Context()).GetNotificationManager().SetEmailConfig(*emailConfig)
log.Info().Msg("Reloaded email configuration after import")
} else {
log.Warn().Err(err).Msg("Failed to reload email configuration after import")
}
}
// Reload guest metadata from disk
if h.guestMetadataHandler != nil {
if err := h.guestMetadataHandler.Reload(); err != nil {
log.Warn().Err(err).Msg("Failed to reload guest metadata after import")
} else {
log.Info().Msg("Reloaded guest metadata after import")
}
}
log.Info().Msg("Configuration imported successfully")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"message": "Configuration imported successfully",
})
}
// HandleDiscoverServers handles network discovery of Proxmox/PBS servers
func (h *ConfigHandlers) HandleDiscoverServers(w http.ResponseWriter, r *http.Request) {
// Support both GET (for cached results) and POST (for manual scan)
switch r.Method {
case http.MethodGet:
// Return cached results from background discovery service
if discoveryService := h.getMonitor(r.Context()).GetDiscoveryService(); discoveryService != nil {
result, updated := discoveryService.GetCachedResult()
var updatedUnix int64
var ageSeconds float64
if !updated.IsZero() {
updatedUnix = updated.Unix()
ageSeconds = time.Since(updated).Seconds()
}
response := map[string]interface{}{
"servers": result.Servers,
"errors": result.Errors,
"environment": result.Environment,
"cached": true,
"updated": updatedUnix,
"age": ageSeconds,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"servers": []interface{}{},
"errors": []string{},
"environment": nil,
"cached": false,
"updated": int64(0),
"age": float64(0),
})
return
case http.MethodPost:
// Limit request body to 8KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
var req struct {
Subnet string `json:"subnet"`
UseCache bool `json:"use_cache"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.UseCache {
if discoveryService := h.getMonitor(r.Context()).GetDiscoveryService(); discoveryService != nil {
result, updated := discoveryService.GetCachedResult()
var updatedUnix int64
var ageSeconds float64
if !updated.IsZero() {
updatedUnix = updated.Unix()
ageSeconds = time.Since(updated).Seconds()
}
response := map[string]interface{}{
"servers": result.Servers,
"errors": result.Errors,
"environment": result.Environment,
"cached": true,
"updated": updatedUnix,
"age": ageSeconds,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
return
}
}
subnet := strings.TrimSpace(req.Subnet)
if subnet == "" {
subnet = "auto"
}
log.Info().Str("subnet", subnet).Msg("Starting manual discovery scan")
scanner, buildErr := discoveryinternal.BuildScanner(h.getConfig(r.Context()).Discovery)
if buildErr != nil {
log.Warn().Err(buildErr).Msg("Falling back to default scanner for manual discovery")
scanner = pkgdiscovery.NewScanner()
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
result, err := scanner.DiscoverServers(ctx, subnet)
if err != nil {
log.Error().Err(err).Msg("Discovery failed")
http.Error(w, fmt.Sprintf("Discovery failed: %v", err), http.StatusInternalServerError)
return
}
if result.Environment != nil {
log.Info().
Str("environment", result.Environment.Type).
Float64("confidence", result.Environment.Confidence).
Int("phases", len(result.Environment.Phases)).
Msg("Manual discovery environment summary")
}
response := map[string]interface{}{
"servers": result.Servers,
"errors": result.Errors,
"environment": result.Environment,
"cached": false,
"scanning": false,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// 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 := 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
authToken := strings.TrimSpace(query.Get("auth_token")) // Temporary auth token for auto-registration
if serverHost != "" {
safeHost, err := sanitizeInstallerURL(serverHost)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid host parameter: %v", err), http.StatusBadRequest)
return
}
serverHost = safeHost
}
if pulseURL != "" {
safeURL, err := sanitizeInstallerURL(pulseURL)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid pulse_url parameter: %v", err), http.StatusBadRequest)
return
}
pulseURL = safeURL
}
if sanitizedToken, err := sanitizeSetupAuthToken(authToken); err != nil {
http.Error(w, fmt.Sprintf("Invalid auth_token parameter: %v", err), http.StatusBadRequest)
return
} else {
authToken = sanitizedToken
}
// Validate required parameters
if serverType == "" {
http.Error(w, "Missing required parameter: type (must be 'pve' or 'pbs')", http.StatusBadRequest)
return
}
// If host is not provided, try to use a sensible default
if serverHost == "" {
if serverType == "pve" {
serverHost = "https://YOUR_PROXMOX_HOST:8006"
} else {
serverHost = "https://YOUR_PBS_HOST:8007"
}
log.Warn().
Str("type", serverType).
Msg("No host parameter provided, using placeholder. Auto-registration will fail.")
}
// If pulseURL is not provided, use the current request host
if pulseURL == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
pulseURL = fmt.Sprintf("%s://%s", scheme, r.Host)
} else {
// Ensure derived pulseURL is still sanitized (should already be, but double check)
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 code
// No need to check auth here since the script will prompt for a code
// Default to PVE if not specified
if serverType == "" {
serverType = "pve"
}
// If pulse URL not provided, try to construct from request
if pulseURL == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
pulseURL = fmt.Sprintf("%s://%s", scheme, r.Host)
}
// Extract hostname/IP from the host URL if provided
serverName := "your-server"
if serverHost != "" {
// Extract hostname/IP from URL
if match := strings.Contains(serverHost, "://"); match {
parts := strings.Split(serverHost, "://")
if len(parts) > 1 {
hostPart := strings.Split(parts[1], ":")[0]
serverName = hostPart
}
} else {
// Just a hostname/IP
serverName = strings.Split(serverHost, ":")[0]
}
}
pulseTokenScope := pulseTokenSuffix(pulseURL, r.Host)
tokenName := buildPulseMonitorTokenName(pulseURL, r.Host)
// 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")
// Get or generate SSH public key for temperature monitoring
sshKeys := h.getOrGenerateSSHKeys()
var script string
if serverType == "pve" {
// Build storage permissions command if needed
storagePerms := ""
if backupPerms {
storagePerms = "\npveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin"
}
script = fmt.Sprintf(`#!/bin/bash
# Pulse Monitoring Setup Script for %s
# Generated: %s
echo "============================================"
echo " Pulse Monitoring Setup for Proxmox VE"
echo "============================================"
echo ""
PULSE_URL="%s"
SERVER_HOST="%s"
TOKEN_NAME="%s"
PULSE_TOKEN_ID="pulse-monitor@pam!${TOKEN_NAME}"
SETUP_SCRIPT_URL="$PULSE_URL/api/setup-script?type=pve&host=$SERVER_HOST&pulse_url=$PULSE_URL"
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run this script as root"
exit 1
fi
# Detect environment (Proxmox host vs LXC guest)
detect_environment() {
if command -v pveum >/dev/null 2>&1 && command -v pveversion >/dev/null 2>&1; then
echo "pve_host"
return
fi
if [ -f /proc/1/cgroup ] && grep -qE '/(lxc|machine\.slice/machine-lxc)' /proc/1/cgroup 2>/dev/null; then
echo "lxc_guest"
return
fi
if command -v systemd-detect-virt >/dev/null 2>&1; then
if systemd-detect-virt -q -c 2>/dev/null; then
local virt_type
virt_type=$(systemd-detect-virt -c 2>/dev/null | tr '[:upper:]' '[:lower:]')
if echo "$virt_type" | grep -q "lxc"; then
echo "lxc_guest"
return
fi
fi
fi
echo "unknown"
}
detect_lxc_ctid() {
local ctid=""
if [ -f /proc/1/cgroup ]; then
ctid=$(sed 's/\\x2d/-/g' /proc/1/cgroup 2>/dev/null | grep -Eo '(lxc|machine-lxc)-[0-9]+' | tail -n1 | grep -Eo '[0-9]+' | tail -n1)
if [ -n "$ctid" ]; then
echo "$ctid"
return
fi
fi
if command -v hostname >/dev/null 2>&1; then
ctid=$(hostname 2>/dev/null)
if echo "$ctid" | grep -qE '^[0-9]+$'; then
echo "$ctid"
return
fi
fi
echo ""
}
resolve_authorized_keys_path() {
local auth_keys="/root/.ssh/authorized_keys"
if [ -L "$auth_keys" ]; then
local link_target=""
link_target=$(readlink "$auth_keys" 2>/dev/null || true)
if [ -n "$link_target" ]; then
case "$link_target" in
/*)
auth_keys="$link_target"
;;
*)
auth_keys="$(cd "$(dirname "$auth_keys")" && pwd)/$link_target"
;;
esac
fi
fi
printf '%%s\n' "$auth_keys"
}
ENVIRONMENT=$(detect_environment)
case "$ENVIRONMENT" in
pve_host)
echo "Detected Proxmox VE host environment."
echo ""
;;
lxc_guest)
echo "Detected Proxmox LXC container environment."
echo ""
echo "Run this script on the Proxmox host:"
echo " curl -sSL \"$SETUP_SCRIPT_URL\" | bash"
echo ""
exit 1
;;
*)
echo "This script requires Proxmox host tooling (pveum)."
echo ""
echo "Run on your Proxmox host:"
echo " curl -sSL \"$SETUP_SCRIPT_URL\" | bash"
echo ""
echo "Manual setup steps:"
echo " 1. On Proxmox host, create API token:"
echo " pveum user add pulse-monitor@pam --comment \"Pulse monitoring service\""
echo " pveum aclmod / -user pulse-monitor@pam -role PVEAuditor"
echo " pveum user token add pulse-monitor@pam "$TOKEN_NAME" --privsep 0"
echo ""
echo " 2. In Pulse: Settings → Nodes → Add Node (enter token from above)"
echo ""
exit 1
;;
esac
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Main Menu
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
echo ""
echo "What would you like to do?"
echo ""
echo " [1] Install/Configure - Set up Pulse monitoring"
echo " [2] Remove All - Uninstall everything Pulse has configured"
echo " [3] Cancel - Exit without changes"
echo ""
echo -n "Your choice [1/2/3]: "
MAIN_ACTION=""
if [ -t 0 ]; then
read -n 1 -r MAIN_ACTION
else
if read -n 1 -r MAIN_ACTION </dev/tty 2>/dev/null; then
:
else
echo "(No terminal available - defaulting to Install)"
MAIN_ACTION="1"
fi
fi
echo ""
echo ""
# Handle Cancel
if [[ $MAIN_ACTION =~ ^[3Cc]$ ]]; then
echo "Cancelled. No changes made."
exit 0
fi
# Handle Remove All
if [[ $MAIN_ACTION =~ ^[2Rr]$ ]]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🗑️ Complete Removal"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "This will remove:"
echo " • SSH keys from authorized_keys (Pulse-managed entries)"
echo " • Pulse monitoring API tokens and user"
echo ""
echo "⚠️ WARNING: This is a destructive operation!"
echo ""
echo -n "Are you sure? [y/N]: "
CONFIRM_REMOVE=""
if [ -t 0 ]; then
read -n 1 -r CONFIRM_REMOVE
else
if read -n 1 -r CONFIRM_REMOVE </dev/tty 2>/dev/null; then
:
else
echo "(No terminal available - cancelling removal)"
CONFIRM_REMOVE="n"
fi
fi
echo ""
echo ""
if [[ ! $CONFIRM_REMOVE =~ ^[Yy]$ ]]; then
echo "Removal cancelled. No changes made."
exit 0
fi
echo "Removing Pulse monitoring components..."
echo ""
# Always run manual removal for local services and files
if true; then
# Remove SSH keys from authorized_keys (only Pulse-managed entries)
# Resolve symlink first (Proxmox symlinks authorized_keys to /etc/pve/priv/)
UNINSTALL_AUTH_KEYS="$(resolve_authorized_keys_path)"
if [ -f "$UNINSTALL_AUTH_KEYS" ]; then
echo " • Removing SSH keys from authorized_keys..."
TMP_AUTH_KEYS="$(mktemp /tmp/.pulse-authorized-keys.XXXXXX)"
if [ -f "$TMP_AUTH_KEYS" ]; then
grep -vF '# pulse-' "$UNINSTALL_AUTH_KEYS" > "$TMP_AUTH_KEYS" 2>/dev/null
GREP_EXIT=$?
if [ $GREP_EXIT -eq 0 ] || [ $GREP_EXIT -eq 1 ]; then
chmod --reference="$UNINSTALL_AUTH_KEYS" "$TMP_AUTH_KEYS" 2>/dev/null || chmod 600 "$TMP_AUTH_KEYS"
chown --reference="$UNINSTALL_AUTH_KEYS" "$TMP_AUTH_KEYS" 2>/dev/null || true
if ! mv -f "$TMP_AUTH_KEYS" "$UNINSTALL_AUTH_KEYS" 2>/dev/null; then
# Cross-device move (e.g. /tmp → pmxcfs): fall back to copy
cp -f "$TMP_AUTH_KEYS" "$UNINSTALL_AUTH_KEYS" && rm -f "$TMP_AUTH_KEYS" || rm -f "$TMP_AUTH_KEYS"
fi
else
rm -f "$TMP_AUTH_KEYS"
fi
fi
fi
# Remove Pulse monitoring API tokens and user
echo " • Removing Pulse monitoring API tokens and user..."
if command -v pveum &> /dev/null; then
TOKEN_LIST=$(pveum user token list pulse-monitor@pam 2>/dev/null | awk 'NR>3 {print $2}' | grep -v '^$' || printf '')
if [ -n "$TOKEN_LIST" ]; then
while IFS= read -r TOKEN; do
if [ -n "$TOKEN" ]; then
pveum user token remove pulse-monitor@pam "$TOKEN" 2>/dev/null || true
fi
done <<< "$TOKEN_LIST"
fi
pveum user delete pulse-monitor@pam 2>/dev/null || true
pveum role delete PulseMonitor 2>/dev/null || true
fi
if command -v proxmox-backup-manager &> /dev/null; then
proxmox-backup-manager user delete pulse-monitor@pbs 2>/dev/null || true
fi
fi
echo ""
echo "✓ Complete removal finished"
echo ""
echo "All Pulse monitoring components have been removed from this host."
exit 0
fi
# If we get here, user chose Install (or default)
echo "Proceeding with installation..."
echo ""
# Extract Pulse server IP from the URL for token matching
PULSE_IP_PATTERN=$(echo "%s" | sed 's/\./\-/g')
# Check for old Pulse tokens from the same Pulse server and offer to clean them up
OLD_TOKENS=$(pveum user token list pulse-monitor@pam 2>/dev/null | grep -E "│ pulse-${PULSE_IP_PATTERN}-[0-9]+" | awk -F'│' '{print $2}' | sed 's/^ *//;s/ *$//' || true)
if [ ! -z "$OLD_TOKENS" ]; then
echo "Checking for existing Pulse monitoring tokens from this Pulse server..."
TOKEN_COUNT=$(echo "$OLD_TOKENS" | wc -l)
echo ""
echo "⚠️ Found $TOKEN_COUNT old Pulse monitoring token(s) from this Pulse server (${PULSE_IP_PATTERN}):"
echo "$OLD_TOKENS" | sed 's/^/ - /'
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🗑️ CLEANUP OPTION"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Would you like to remove these old tokens? Type 'y' for yes, 'n' for no: "
# Read from terminal, not from stdin (which is the piped script)
if [ -t 0 ]; then
# Running interactively
read -p "> " -n 1 -r REPLY
else
# Being piped - try to read from terminal if available
if read -p "> " -n 1 -r REPLY </dev/tty 2>/dev/null; then
# Successfully read from terminal
:
else
# No terminal available (e.g., in Docker without -t flag)
echo "(No terminal available for input - keeping existing tokens)"
REPLY="n"
fi
fi
echo ""
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Removing old tokens..."
while IFS= read -r TOKEN; do
if [ ! -z "$TOKEN" ]; then
pveum user token remove pulse-monitor@pam "$TOKEN" 2>/dev/null && echo " ✓ Removed token: $TOKEN" || echo " ✗ Failed to remove: $TOKEN"
fi
done <<< "$OLD_TOKENS"
echo ""
else
echo "Keeping existing tokens."
fi
echo ""
fi
# Create monitoring user
echo "Creating monitoring user..."
pveum user add pulse-monitor@pam --comment "Pulse monitoring service" 2>/dev/null || true
SETUP_AUTH_TOKEN="%s"
AUTO_REG_SUCCESS=false
TOKEN_ROTATION_SKIPPED=false
resolve_setup_auth_token() {
if [ -z "$SETUP_AUTH_TOKEN" ] && [ -n "$PULSE_SETUP_TOKEN" ]; then
SETUP_AUTH_TOKEN="$PULSE_SETUP_TOKEN"
fi
if [ -z "$SETUP_AUTH_TOKEN" ]; then
if [ -t 0 ]; then
printf "Pulse setup token: "
if command -v stty >/dev/null 2>&1; then stty -echo; fi
IFS= read -r SETUP_AUTH_TOKEN
if command -v stty >/dev/null 2>&1; then stty echo; fi
printf "\n"
elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf "Pulse setup token: " >/dev/tty
if command -v stty >/dev/null 2>&1; then stty -echo </dev/tty 2>/dev/null || true; fi
IFS= read -r SETUP_AUTH_TOKEN </dev/tty || true
if command -v stty >/dev/null 2>&1; then stty echo </dev/tty 2>/dev/null || true; fi
printf "\n" >/dev/tty
fi
fi
}
extract_json_string_field() {
local json_input="$1"
local field_name="$2"
printf '%%s\n' "$json_input" | sed -n 's/.*"'$field_name'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1
}
extract_pve_token_value() {
local token_output="$1"
local token_value=""
token_value=$(extract_json_string_field "$token_output" "value")
if [ -n "$token_value" ]; then
printf '%%s\n' "$token_value"
return 0
fi
printf '%%s\n' "$token_output" | awk -F'[|│]' '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
{
key = trim($2)
value = trim($3)
if (key == "value" && value != "") {
print value
exit
}
}
'
}
create_pve_token() {
if TOKEN_OUTPUT=$(pveum user token add pulse-monitor@pam "$TOKEN_NAME" --privsep 0 --output-format json 2>&1); then
TOKEN_CREATE_RC=0
else
TOKEN_CREATE_RC=$?
fi
if [ "$TOKEN_CREATE_RC" -eq 0 ]; then
TOKEN_VALUE=$(extract_pve_token_value "$TOKEN_OUTPUT")
return 0
fi
if echo "$TOKEN_OUTPUT" | grep -Eqi 'unknown option|unknown command|no such option|unable to parse option|output-format'; then
if TOKEN_OUTPUT=$(pveum user token add pulse-monitor@pam "$TOKEN_NAME" --privsep 0 2>&1); then
TOKEN_CREATE_RC=0
else
TOKEN_CREATE_RC=$?
fi
if [ "$TOKEN_CREATE_RC" -eq 0 ]; then
TOKEN_VALUE=$(extract_pve_token_value "$TOKEN_OUTPUT")
fi
fi
return "$TOKEN_CREATE_RC"
}
attempt_auto_registration() {
resolve_setup_auth_token
if [ -z "$TOKEN_VALUE" ]; then
if [ "$TOKEN_ROTATION_SKIPPED" = true ]; then
echo "⚠️ Auto-registration skipped: existing token preserved to avoid credential drift"
echo " Set PULSE_FORCE_TOKEN_ROTATE=1 if you need to rotate and register a new token."
else
echo "⚠️ Auto-registration skipped: token value unavailable"
fi
AUTO_REG_SUCCESS=false
REGISTER_RESPONSE=""
return
fi
if [ -z "$SETUP_AUTH_TOKEN" ]; then
echo "⚠️ Auto-registration skipped: no setup token provided"
AUTO_REG_SUCCESS=false
REGISTER_RESPONSE=""
return
fi
SERVER_HOSTNAME=$(hostname -s 2>/dev/null || hostname)
SERVER_IP=$(hostname -I | awk '{print $1}')
HOST_URL="$SERVER_HOST"
if [ "$HOST_URL" = "https://YOUR_PROXMOX_HOST:8006" ] || [ -z "$HOST_URL" ]; then
echo ""
echo "❌ ERROR: No Proxmox host URL provided!"
echo " The setup script URL is missing the 'host' parameter."
echo ""
echo " Please use the correct URL format:"
echo " curl -sSL \"$PULSE_URL/api/setup-script?type=pve&host=YOUR_PVE_URL&pulse_url=$PULSE_URL\" | bash"
echo ""
echo " Example:"
echo " curl -sSL \"$PULSE_URL/api/setup-script?type=pve&host=https://192.168.0.5:8006&pulse_url=$PULSE_URL\" | bash"
echo ""
echo "📝 For manual setup, use the token created above with:"
echo " Token ID: $PULSE_TOKEN_ID"
echo " Token Value: [See above]"
echo ""
exit 1
fi
REGISTER_JSON='{"type":"pve","host":"'"$HOST_URL"'","serverName":"'"$SERVER_HOSTNAME"'","tokenId":"'"$PULSE_TOKEN_ID"'","tokenValue":"'"$TOKEN_VALUE"'","authToken":"'"$SETUP_AUTH_TOKEN"'"}'
REGISTER_RESPONSE=$(echo "$REGISTER_JSON" | curl -s -X POST "$PULSE_URL/api/auto-register" \
-H "Content-Type: application/json" \
-d @- 2>&1)
AUTO_REG_SUCCESS=false
if echo "$REGISTER_RESPONSE" | grep -q "success"; then
AUTO_REG_SUCCESS=true
echo "Node registered successfully"
echo ""
else
if echo "$REGISTER_RESPONSE" | grep -q "Authentication required"; then
echo "Error: Auto-registration failed - authentication required"
echo ""
if [ -z "$PULSE_API_TOKEN" ]; then
echo "To enable auto-registration, add your API token to the setup URL"
echo "You can find your API token in Pulse Settings → Security"
else
echo "The provided API token was invalid"
fi
else
echo "⚠️ Auto-registration failed. Manual configuration may be needed."
echo " Response: $REGISTER_RESPONSE"
fi
echo ""
echo "📝 For manual setup:"
echo " 1. Copy the token value shown above"
echo " 2. Add this node manually in Pulse Settings"
fi
}
# Generate API token
echo "Generating API token..."
# Keep existing tokens by default so reruns cannot silently desynchronize Pulse credentials.
TOKEN_EXISTED=false
TOKEN_OUTPUT=""
TOKEN_VALUE=""
if pveum user token list pulse-monitor@pam 2>/dev/null | grep -q "$TOKEN_NAME"; then
TOKEN_EXISTED=true
if [ "${PULSE_FORCE_TOKEN_ROTATE:-}" = "1" ]; then
echo "Existing token '$TOKEN_NAME' found. Rotating in place (PULSE_FORCE_TOKEN_ROTATE=1)."
if ! pveum user token remove pulse-monitor@pam "$TOKEN_NAME" >/dev/null 2>&1; then
echo "⚠️ Failed to remove existing token '$TOKEN_NAME'. Attempting create anyway..."
fi
else
TOKEN_ROTATION_SKIPPED=true
echo "Existing token '$TOKEN_NAME' found. Keeping it unchanged to avoid breaking existing Pulse credentials."
echo "Set PULSE_FORCE_TOKEN_ROTATE=1 to rotate this token and issue a new secret."
fi
fi
if [ "$TOKEN_ROTATION_SKIPPED" != true ]; then
# Create token and capture value (shown once by Proxmox)
create_pve_token
if [ "$TOKEN_CREATE_RC" -ne 0 ]; then
echo "❌ Failed to create token '$TOKEN_NAME'"
echo "$TOKEN_OUTPUT"
echo ""
echo "Manual registration may be required."
echo ""
else
if [ -z "$TOKEN_VALUE" ]; then
echo ""
echo "================================================================"
echo "IMPORTANT: Copy the token value below - it's only shown once!"
echo "================================================================"
echo "$TOKEN_OUTPUT"
echo "================================================================"
echo ""
echo "⚠️ Failed to extract token value from output."
echo " Manual registration may be required."
echo ""
else
if [ "$TOKEN_EXISTED" = true ]; then
echo "API token rotated successfully"
else
echo "API token generated successfully"
fi
echo ""
fi
fi
fi
# Set up permissions
echo "Setting up permissions..."
pveum aclmod / -user pulse-monitor@pam -role PVEAuditor%s
# Detect Proxmox version and apply appropriate permissions
# Method 1: Try to check if VM.Monitor exists (reliable for PVE 8 and below)
HAS_VM_MONITOR=false
if pveum role list 2>/dev/null | grep -q "VM.Monitor" ||
pveum role add TestMonitor -privs VM.Monitor 2>/dev/null; then
HAS_VM_MONITOR=true
pveum role delete TestMonitor 2>/dev/null || true
fi
# Detect availability of newer guest agent privileges (PVE 9+)
HAS_VM_GUEST_AGENT_AUDIT=false
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.Audit"; then
HAS_VM_GUEST_AGENT_AUDIT=true
else
if pveum role add TestGuestAgentAudit -privs VM.GuestAgent.Audit 2>/dev/null; then
HAS_VM_GUEST_AGENT_AUDIT=true
pveum role delete TestGuestAgentAudit 2>/dev/null || true
fi
fi
# VM.GuestAgent.FileRead (PVE 9+): needed for reading /proc/meminfo
# via the guest agent for accurate memory reporting
HAS_VM_GUEST_AGENT_FILE_READ=false
if pveum role list 2>/dev/null | grep -q "VM.GuestAgent.FileRead"; then
HAS_VM_GUEST_AGENT_FILE_READ=true
else
if pveum role add TestGuestAgentFileRead -privs VM.GuestAgent.FileRead 2>/dev/null; then
HAS_VM_GUEST_AGENT_FILE_READ=true
pveum role delete TestGuestAgentFileRead 2>/dev/null || true
fi
fi
# Detect availability of Sys.Audit (needed for Ceph metrics)
HAS_SYS_AUDIT=false
if pveum role list 2>/dev/null | grep -q "Sys.Audit"; then
HAS_SYS_AUDIT=true
else
if pveum role add TestSysAudit -privs Sys.Audit 2>/dev/null; then
HAS_SYS_AUDIT=true
pveum role delete TestSysAudit 2>/dev/null || true
fi
fi
# Method 2: Try to detect PVE version directly
PVE_VERSION=""
if command -v pveversion >/dev/null 2>&1; then
# Extract major version (e.g., "9" from "pve-manager/9.0.5/...")
PVE_VERSION=$(pveversion --verbose 2>/dev/null | grep "pve-manager" | awk -F'/' '{print $2}' | cut -d'.' -f1)
fi
EXTRA_PRIVS=()
if [ "$HAS_SYS_AUDIT" = true ]; then
EXTRA_PRIVS+=("Sys.Audit")
fi
if [ "$HAS_VM_MONITOR" = true ]; then
# PVE 8 or below - VM.Monitor exists
EXTRA_PRIVS+=("VM.Monitor")
elif [ "$HAS_VM_GUEST_AGENT_AUDIT" = true ]; then
# PVE 9+ - VM.Monitor removed, prefer VM.GuestAgent.Audit for guest data
EXTRA_PRIVS+=("VM.GuestAgent.Audit")
if [ "$HAS_VM_GUEST_AGENT_FILE_READ" = true ]; then
EXTRA_PRIVS+=("VM.GuestAgent.FileRead")
fi
fi
if [ ${#EXTRA_PRIVS[@]} -gt 0 ]; then
# Join as comma-separated list (pveum expects comma-separated privilege names).
PRIV_STRING="$(IFS=,; echo "${EXTRA_PRIVS[*]}")"
# Prefer modify (non-destructive) in case PulseMonitor already exists.
if pveum role modify PulseMonitor -privs "$PRIV_STRING" 2>/dev/null || pveum role add PulseMonitor -privs "$PRIV_STRING" 2>/dev/null; then
pveum aclmod / -user pulse-monitor@pam -role PulseMonitor
echo " • Applied privileges: $PRIV_STRING"
else
echo " • Failed to configure PulseMonitor role with: $PRIV_STRING"
echo " Assign these privileges manually if Pulse reports permission errors."
fi
else
echo " • No additional privileges detected. Pulse may show limited VM metrics."
fi
attempt_auto_registration
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Temperature Monitoring Setup (Optional)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
SSH_SENSORS_PUBLIC_KEY=%s
SSH_SENSORS_KEY_OPTIONS='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty'
SSH_SENSORS_KEY_ENTRY="${SSH_SENSORS_KEY_OPTIONS} ${SSH_SENSORS_PUBLIC_KEY} # pulse-sensors"
TEMPERATURE_ENABLED=false
if [ -n "$SSH_SENSORS_PUBLIC_KEY" ]; then
echo "📊 Enable Temperature Monitoring?"
echo ""
echo "Collect CPU and drive temperatures via secure SSH connection."
echo ""
echo "Security:"
echo " • SSH key authentication with forced command (sensors -j only)"
echo " • No shell access, port forwarding, or other SSH features"
echo " • Keys stored in Pulse service user's home directory"
echo ""
echo "Enable temperature monitoring? [y/N]"
echo -n "> "
if [ -t 0 ]; then
read -n 1 -r SSH_REPLY
else
# When stdin is not a terminal (e.g., curl | bash), try /dev/tty first, then stdin for piped input
if read -n 1 -r SSH_REPLY </dev/tty 2>/dev/null; then
:
elif read -t 2 -n 1 -r SSH_REPLY 2>/dev/null && [ -n "$SSH_REPLY" ]; then
echo "$SSH_REPLY"
else
echo "(No terminal available - skipping temperature monitoring)"
SSH_REPLY="n"
fi
fi
echo ""
echo ""
if [[ $SSH_REPLY =~ ^[Yy]$ ]]; then
echo "Configuring temperature monitoring..."
# Add key to root's authorized_keys
# Resolve symlink first (Proxmox symlinks authorized_keys to /etc/pve/priv/)
AUTH_KEYS="$(resolve_authorized_keys_path)"
mkdir -p "$(dirname "$AUTH_KEYS")"
chmod 700 /root/.ssh 2>/dev/null || true
# Remove any old pulse keys and add the new one
SENSORS_KEY_OK=false
if [ -f "$AUTH_KEYS" ]; then
TMP_AUTH_KEYS="$(mktemp /tmp/.pulse-authorized-keys.XXXXXX 2>/dev/null)" || TMP_AUTH_KEYS=""
if [ -n "$TMP_AUTH_KEYS" ] && [ -f "$TMP_AUTH_KEYS" ]; then
grep -vF "# pulse-" "$AUTH_KEYS" > "$TMP_AUTH_KEYS" 2>/dev/null || true
printf '%%s\n' "$SSH_SENSORS_KEY_ENTRY" >> "$TMP_AUTH_KEYS"
chmod 600 "$TMP_AUTH_KEYS"
if mv -f "$TMP_AUTH_KEYS" "$AUTH_KEYS" 2>/dev/null || cp -f "$TMP_AUTH_KEYS" "$AUTH_KEYS" 2>/dev/null; then
rm -f "$TMP_AUTH_KEYS" 2>/dev/null
SENSORS_KEY_OK=true
else
echo " ⚠️ Failed to update $AUTH_KEYS"
rm -f "$TMP_AUTH_KEYS" 2>/dev/null
fi
else
echo " ⚠️ Failed to create temp file — cannot update authorized_keys"
fi
else
if printf '%%s\n' "$SSH_SENSORS_KEY_ENTRY" >> "$AUTH_KEYS" 2>/dev/null; then
chmod 600 "$AUTH_KEYS"
SENSORS_KEY_OK=true
else
echo " ⚠️ Failed to write to $AUTH_KEYS"
fi
fi
if [ "$SENSORS_KEY_OK" = true ]; then
echo " ✓ Sensors key configured (restricted to sensors -j)"
fi
# Check if this is a Raspberry Pi
IS_RPI=false
if [ -f /proc/device-tree/model ] && grep -qi "raspberry pi" /proc/device-tree/model 2>/dev/null; then
IS_RPI=true
fi
TEMPERATURE_SETUP_SUCCESS=false
# Install lm-sensors if not present (skip on Raspberry Pi)
if ! command -v sensors &> /dev/null; then
if [ "$IS_RPI" = true ]; then
echo " Raspberry Pi detected - using native RPi temperature interface"
echo " Pulse will read temperature from /sys/class/thermal/thermal_zone0/temp"
TEMPERATURE_SETUP_SUCCESS=true
else
echo " ✓ Installing lm-sensors..."
# Try to update and install, but provide helpful errors if it fails
UPDATE_OUTPUT=$(apt-get update -qq 2>&1)
if echo "$UPDATE_OUTPUT" | grep -q "Could not create temporary file\|/tmp"; then
echo ""
echo " ⚠️ APT cannot write to /tmp directory"
echo " This may be a permissions issue. To fix:"
echo " sudo chown root:root /tmp"
echo " sudo chmod 1777 /tmp"
echo ""
echo " Attempting installation anyway..."
elif echo "$UPDATE_OUTPUT" | grep -q "Failed to fetch\|GPG error\|no longer has a Release file"; then
echo " ⚠️ Some repository errors detected, attempting installation anyway..."
fi
if apt-get install -y lm-sensors > /dev/null 2>&1; then
sensors-detect --auto > /dev/null 2>&1 || true
echo " ✓ lm-sensors installed successfully"
TEMPERATURE_SETUP_SUCCESS=true
else
echo ""
echo " ⚠️ Could not install lm-sensors"
echo " Possible causes:"
echo " - Repository configuration errors"
echo " - /tmp directory permission issues"
echo " - Network connectivity problems"
echo ""
echo " To fix manually:"
echo " 1. Check /tmp permissions: ls -ld /tmp"
echo " (should be: drwxrwxrwt owned by root:root)"
echo " 2. Fix if needed: sudo chown root:root /tmp && sudo chmod 1777 /tmp"
echo " 3. Install: sudo apt-get update && sudo apt-get install -y lm-sensors"
echo ""
fi
fi
else
echo " ✓ lm-sensors package verified"
TEMPERATURE_SETUP_SUCCESS=true
fi
echo ""
if [ "$TEMPERATURE_SETUP_SUCCESS" = true ]; then
echo "✓ Temperature monitoring enabled"
if [ "$IS_RPI" = true ]; then
echo " Using Raspberry Pi native temperature interface"
fi
echo " Temperature data will appear in the dashboard within 10 seconds"
TEMPERATURE_ENABLED=true
else
echo "⚠️ Temperature monitoring setup incomplete"
echo " You can re-run this script after installing lm-sensors"
fi
else
echo "Skipping temperature monitoring."
fi
else
echo "Temperature monitoring keys are not available from Pulse."
fi
echo ""
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Setup Complete"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Node successfully registered with Pulse monitoring."
echo "Data will appear in your dashboard within 10 seconds."
echo ""
# Only show manual setup instructions if auto-registration failed
if [ "$AUTO_REG_SUCCESS" != true ]; then
echo "Manual setup instructions:"
echo " Token ID: $PULSE_TOKEN_ID"
if [ "$TOKEN_ROTATION_SKIPPED" = true ]; then
echo " Token Value: [unchanged - existing token secret preserved]"
elif [ -n "$TOKEN_VALUE" ]; then
echo " Token Value: $TOKEN_VALUE"
else
echo " Token Value: [See token output above]"
fi
echo " Host URL: YOUR_PROXMOX_HOST:8006"
echo ""
fi
`, serverName, time.Now().Format("2006-01-02 15:04:05"),
pulseURL, serverHost, tokenName,
pulseTokenScope,
authToken,
storagePerms,
shellSingleQuoteLiteral(sshKeys.SensorsPublicKey))
} else { // PBS
script = fmt.Sprintf(`#!/bin/bash
# Pulse Monitoring Setup Script for PBS %s
# Generated: %s
echo "============================================"
echo " Pulse Monitoring Setup for PBS"
echo "============================================"
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run this script as root"
exit 1
fi
# Check if proxmox-backup-manager command exists
if ! command -v proxmox-backup-manager &> /dev/null; then
echo ""
echo "❌ ERROR: 'proxmox-backup-manager' command not found!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "This script must be run on a Proxmox Backup Server."
echo "The 'proxmox-backup-manager' command is required to create users and tokens."
echo ""
echo "If you're seeing this error, you might be:"
echo " • Running on a non-PBS system"
echo " • On a PVE server (use the PVE setup script instead)"
echo " • Missing PBS installation or in wrong environment"
echo ""
echo "If PBS is running in Docker, ensure you're inside the PBS container."
echo ""
exit 1
fi
# Extract Pulse server IP from the URL for token matching
PULSE_IP_PATTERN=$(echo "%s" | sed 's/\./\-/g')
TOKEN_NAME="%s"
PULSE_TOKEN_ID="pulse-monitor@pbs!${TOKEN_NAME}"
# Check for old Pulse tokens from the same Pulse server and offer to clean them up
echo "Checking for existing Pulse monitoring tokens from this Pulse server..."
# PBS outputs tokens differently than PVE - extract just the token names matching this Pulse server
OLD_TOKENS=$(proxmox-backup-manager user list-tokens pulse-monitor@pbs 2>/dev/null | grep -oE "pulse-${PULSE_IP_PATTERN}-[0-9]+" | sort -u || true)
if [ ! -z "$OLD_TOKENS" ]; then
TOKEN_COUNT=$(echo "$OLD_TOKENS" | wc -l)
echo ""
echo "⚠️ Found $TOKEN_COUNT old Pulse monitoring token(s) from this Pulse server (${PULSE_IP_PATTERN}):"
echo "$OLD_TOKENS" | sed 's/^/ - /'
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🗑️ CLEANUP OPTION"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Would you like to remove these old tokens? Type 'y' for yes, 'n' for no: "
# Read from terminal, not from stdin (which is the piped script)
if [ -t 0 ]; then
# Running interactively
read -p "> " -n 1 -r REPLY
else
# Being piped - try to read from terminal if available
if read -p "> " -n 1 -r REPLY </dev/tty 2>/dev/null; then
# Successfully read from terminal
:
else
# No terminal available (e.g., in Docker without -t flag)
echo "(No terminal available for input - keeping existing tokens)"
REPLY="n"
fi
fi
echo ""
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Removing old tokens..."
while IFS= read -r TOKEN; do
if [ ! -z "$TOKEN" ]; then
proxmox-backup-manager user delete-token pulse-monitor@pbs "$TOKEN" 2>/dev/null && echo " ✓ Removed token: $TOKEN" || echo " ✗ Failed to remove: $TOKEN"
fi
done <<< "$OLD_TOKENS"
echo ""
else
echo "Keeping existing tokens."
fi
echo ""
fi
# Create monitoring user
echo "Creating monitoring user..."
proxmox-backup-manager user create pulse-monitor@pbs 2>/dev/null || echo "User already exists"
# Generate API token
echo "Generating API token..."
# Keep existing tokens by default so reruns cannot silently desynchronize Pulse credentials.
TOKEN_EXISTED=false
TOKEN_OUTPUT=""
TOKEN_VALUE=""
TOKEN_ROTATION_SKIPPED=false
AUTO_REG_SUCCESS=false
if proxmox-backup-manager user list-tokens pulse-monitor@pbs 2>/dev/null | grep -q "$TOKEN_NAME"; then
TOKEN_EXISTED=true
if [ "${PULSE_FORCE_TOKEN_ROTATE:-}" = "1" ]; then
echo "Existing token '$TOKEN_NAME' found. Rotating in place (PULSE_FORCE_TOKEN_ROTATE=1)."
if ! proxmox-backup-manager user delete-token pulse-monitor@pbs "$TOKEN_NAME" >/dev/null 2>&1; then
echo "⚠️ Failed to remove existing token '$TOKEN_NAME'. Attempting create anyway..."
fi
else
TOKEN_ROTATION_SKIPPED=true
echo "Existing token '$TOKEN_NAME' found. Keeping it unchanged to avoid breaking existing Pulse credentials."
echo "Set PULSE_FORCE_TOKEN_ROTATE=1 to rotate this token and issue a new secret."
fi
fi
if [ "$TOKEN_ROTATION_SKIPPED" != true ]; then
echo ""
echo "================================================================"
echo "IMPORTANT: Copy the token value below - it's only shown once!"
echo "================================================================"
TOKEN_OUTPUT=$(proxmox-backup-manager user generate-token pulse-monitor@pbs "$TOKEN_NAME" 2>&1)
TOKEN_CREATE_RC=$?
if [ "$TOKEN_CREATE_RC" -ne 0 ]; then
echo "❌ Failed to create token '$TOKEN_NAME'"
echo "$TOKEN_OUTPUT"
echo ""
echo "Manual registration may be required."
echo ""
else
echo "$TOKEN_OUTPUT"
# Extract the token value for auto-registration
TOKEN_VALUE=$(echo "$TOKEN_OUTPUT" | grep '"value"' | sed 's/.*"value"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
if [ -z "$TOKEN_VALUE" ]; then
echo "⚠️ Failed to extract token value from output."
echo " Manual registration may be required."
echo ""
else
if [ "$TOKEN_EXISTED" = true ]; then
echo "✅ Token rotated for Pulse monitoring"
else
echo "✅ Token created for Pulse monitoring"
fi
echo ""
fi
# Try auto-registration
echo "🔄 Attempting auto-registration with Pulse..."
echo ""
# Use auth token from URL parameter when provided (automation workflows)
AUTH_TOKEN="%s"
# Allow non-interactive override via environment variable
if [ -z "$AUTH_TOKEN" ] && [ -n "$PULSE_SETUP_TOKEN" ]; then
AUTH_TOKEN="$PULSE_SETUP_TOKEN"
fi
# Prompt the operator if we still don't have a token and a TTY is available
if [ -z "$AUTH_TOKEN" ]; then
if [ -t 0 ]; then
printf "Pulse setup token: "
if command -v stty >/dev/null 2>&1; then stty -echo; fi
IFS= read -r AUTH_TOKEN
if command -v stty >/dev/null 2>&1; then stty echo; fi
printf "\n"
elif [ -c /dev/tty ] && [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf "Pulse setup token: " >/dev/tty
if command -v stty >/dev/null 2>&1; then stty -echo </dev/tty 2>/dev/null || true; fi
IFS= read -r AUTH_TOKEN </dev/tty || true
if command -v stty >/dev/null 2>&1; then stty echo </dev/tty 2>/dev/null || true; fi
printf "\n" >/dev/tty
fi
fi
# Only proceed with auto-registration if we have an auth token
if [ -n "$AUTH_TOKEN" ]; then
# Get the server's hostname (short form to match Pulse node names)
SERVER_HOSTNAME=$(hostname -s 2>/dev/null || hostname)
SERVER_IP=$(hostname -I | awk '{print $1}')
# Send registration to Pulse
PULSE_URL="%s"
# Check if host URL was provided
HOST_URL="%s"
if [ "$HOST_URL" = "https://YOUR_PBS_HOST:8007" ] || [ -z "$HOST_URL" ]; then
echo ""
echo "❌ ERROR: No PBS host URL provided!"
echo " The setup script URL is missing the 'host' parameter."
echo ""
echo " Please use the correct URL format:"
echo " curl -sSL \"$PULSE_URL/api/setup-script?type=pbs&host=YOUR_PBS_URL&pulse_url=$PULSE_URL\" | bash"
echo ""
echo " Example:"
echo " curl -sSL \"$PULSE_URL/api/setup-script?type=pbs&host=https://192.168.0.8:8007&pulse_url=$PULSE_URL\" | bash"
echo ""
echo "📝 For manual setup, use the token created above with:"
echo " Token ID: $PULSE_TOKEN_ID"
echo " Token Value: [See above]"
echo ""
exit 1
fi
# Construct registration request with setup code
REGISTER_JSON=$(cat <<EOF
{
"type": "pbs",
"host": "$HOST_URL",
"serverName": "$SERVER_HOSTNAME",
"tokenId": "$PULSE_TOKEN_ID",
"tokenValue": "$TOKEN_VALUE",
"authToken": "$AUTH_TOKEN"
}
EOF
)
# Remove newlines from JSON
REGISTER_JSON=$(echo "$REGISTER_JSON" | tr -d '\n')
# Send registration with setup code
REGISTER_RESPONSE=$(curl -s -X POST "$PULSE_URL/api/auto-register" \
-H "Content-Type: application/json" \
-d "$REGISTER_JSON" 2>&1)
else
echo "⚠️ Auto-registration skipped: no setup token provided"
AUTO_REG_SUCCESS=false
REGISTER_RESPONSE=""
fi
AUTO_REG_SUCCESS=false
if echo "$REGISTER_RESPONSE" | grep -q "success"; then
AUTO_REG_SUCCESS=true
echo "✅ Successfully registered with Pulse!"
else
if echo "$REGISTER_RESPONSE" | grep -q "Authentication required"; then
echo "Error: Auto-registration failed - authentication required"
echo ""
if [ -z "$PULSE_API_TOKEN" ]; then
echo "To enable auto-registration, add your API token to the setup URL"
echo "You can find your API token in Pulse Settings → Security"
else
echo "The provided API token was invalid"
fi
else
echo "⚠️ Auto-registration failed. Manual configuration may be needed."
echo " Response: $REGISTER_RESPONSE"
fi
echo ""
echo "📝 For manual setup:"
echo " 1. Copy the token value shown above"
echo " 2. Add this node manually in Pulse Settings"
fi
fi
fi
echo "================================================================"
echo ""
# Set up permissions
echo "Setting up permissions..."
proxmox-backup-manager acl update / Audit --auth-id pulse-monitor@pbs
proxmox-backup-manager acl update / Audit --auth-id "$PULSE_TOKEN_ID"
echo ""
echo "✅ Setup complete!"
echo ""
# Only show manual setup instructions if auto-registration failed
if [ "$AUTO_REG_SUCCESS" != true ]; then
echo "Add this server to Pulse with:"
echo " Token ID: $PULSE_TOKEN_ID"
if [ "$TOKEN_ROTATION_SKIPPED" = true ]; then
echo " Token Value: [unchanged - existing token secret preserved]"
elif [ -n "$TOKEN_VALUE" ]; then
echo " Token Value: $TOKEN_VALUE"
else
echo " Token Value: [Check the output above for the token or instructions]"
fi
echo " Host URL: https://$SERVER_IP:8007"
echo ""
echo "If auto-registration is enabled but requires a token:"
echo " 1. Generate a registration token in Pulse Settings → Security"
echo " 2. Re-run this script with: PULSE_REG_TOKEN=your-token ./setup.sh"
echo ""
fi
`, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseTokenScope,
tokenName, authToken, pulseURL, serverHost)
}
// Set headers for script download
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=pulse-setup-%s.sh", serverType))
w.Write([]byte(script))
}
// generateSetupCode generates a secure hex token that satisfies sanitizeSetupAuthToken.
func (h *ConfigHandlers) generateSetupCode() 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 code 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 struct {
Type string `json:"type"`
Host string `json:"host"`
BackupPerms bool `json:"backupPerms"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Generate a temporary auth token (simpler than setup codes)
token := h.generateSetupCode() // Reuse the generation function
tokenHash := internalauth.HashAPIToken(token)
// Store the token with expiry (5 minutes)
expiry := time.Now().Add(5 * time.Minute)
h.codeMutex.Lock()
h.setupCodes[tokenHash] = &SetupCode{
ExpiresAt: expiry,
Used: false,
NodeType: req.Type,
Host: req.Host,
OrgID: "default",
}
h.codeMutex.Unlock()
log.Info().
Str("token_hash", safePrefixForLog(tokenHash, 8)+"...").
Time("expiry", expiry).
Str("type", req.Type).
Msg("Generated temporary auth token")
// Build the URL with the token included
host := r.Host
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
if (parsedHost == "127.0.0.1" || parsedHost == "localhost") && parsedPort == strconv.Itoa(h.getConfig(r.Context()).FrontendPort) {
// Prefer a user-configured public URL when we're running on loopback.
if publicURL := strings.TrimSpace(h.getConfig(r.Context()).PublicURL); publicURL != "" {
if parsedURL, err := url.Parse(publicURL); err == nil && parsedURL.Host != "" {
host = parsedURL.Host
}
}
}
}
// Detect protocol - check both TLS and proxy headers
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
pulseURL := fmt.Sprintf("%s://%s", scheme, host)
encodedHost := ""
if req.Host != "" {
encodedHost = "&host=" + url.QueryEscape(req.Host)
}
backupPerms := ""
if req.BackupPerms {
backupPerms = "&backup_perms=true"
}
// Build script URL (setup token is passed via environment variable).
scriptURL := fmt.Sprintf("%s/api/setup-script?type=%s%s&pulse_url=%s%s",
pulseURL, req.Type, encodedHost, pulseURL, backupPerms)
// Return a curl command; the setup token is passed via environment variable.
// The setup token is returned separately so the script can prompt the user.
tokenHint := token
if len(token) > 6 {
tokenHint = fmt.Sprintf("%s…%s", token[:3], token[len(token)-3:])
}
command := fmt.Sprintf(`curl -sSL "%s" | PULSE_SETUP_TOKEN=%s bash`, scriptURL, token)
response := map[string]interface{}{
"url": scriptURL,
"command": command,
"expires": expiry.Unix(),
"setupToken": token,
"tokenHint": tokenHint,
"commandWithEnv": command,
"commandWithoutEnv": fmt.Sprintf(`curl -sSL "%s" | bash`, scriptURL),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleGetMockMode returns the current mock mode state and configuration.
func (h *ConfigHandlers) HandleGetMockMode(w http.ResponseWriter, r *http.Request) {
status := struct {
Enabled bool `json:"enabled"`
Config mock.MockConfig `json:"config"`
}{
Enabled: mock.IsMockEnabled(),
Config: mock.GetConfig(),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(status); err != nil {
log.Error().Err(err).Msg("Failed to encode mock mode status")
}
}
type mockModeRequest struct {
Enabled *bool `json:"enabled"`
Config struct {
NodeCount *int `json:"nodeCount"`
VMsPerNode *int `json:"vmsPerNode"`
LXCsPerNode *int `json:"lxcsPerNode"`
RandomMetrics *bool `json:"randomMetrics"`
HighLoadNodes []string `json:"highLoadNodes"`
StoppedPercent *float64 `json:"stoppedPercent"`
} `json:"config"`
}
// HandleUpdateMockMode updates mock mode and optionally its configuration.
func (h *ConfigHandlers) HandleUpdateMockMode(w http.ResponseWriter, r *http.Request) {
// Limit request body to 16KB to prevent memory exhaustion
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
var req mockModeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Error().Err(err).Msg("Failed to decode mock mode request")
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Update configuration first if provided.
currentCfg := mock.GetConfig()
if req.Config.NodeCount != nil {
if *req.Config.NodeCount <= 0 {
http.Error(w, "nodeCount must be greater than zero", http.StatusBadRequest)
return
}
currentCfg.NodeCount = *req.Config.NodeCount
}
if req.Config.VMsPerNode != nil {
if *req.Config.VMsPerNode < 0 {
http.Error(w, "vmsPerNode cannot be negative", http.StatusBadRequest)
return
}
currentCfg.VMsPerNode = *req.Config.VMsPerNode
}
if req.Config.LXCsPerNode != nil {
if *req.Config.LXCsPerNode < 0 {
http.Error(w, "lxcsPerNode cannot be negative", http.StatusBadRequest)
return
}
currentCfg.LXCsPerNode = *req.Config.LXCsPerNode
}
if req.Config.RandomMetrics != nil {
currentCfg.RandomMetrics = *req.Config.RandomMetrics
}
if req.Config.HighLoadNodes != nil {
currentCfg.HighLoadNodes = req.Config.HighLoadNodes
}
if req.Config.StoppedPercent != nil {
if *req.Config.StoppedPercent < 0 || *req.Config.StoppedPercent > 1 {
http.Error(w, "stoppedPercent must be between 0 and 1", http.StatusBadRequest)
return
}
currentCfg.StoppedPercent = *req.Config.StoppedPercent
}
mock.SetMockConfig(currentCfg)
if req.Enabled != nil {
if h.getMonitor(r.Context()) != nil {
h.getMonitor(r.Context()).SetMockMode(*req.Enabled)
} else {
mock.SetEnabled(*req.Enabled)
}
}
w.Header().Set("Content-Type", "application/json")
status := struct {
Enabled bool `json:"enabled"`
Config mock.MockConfig `json:"config"`
}{
Enabled: mock.IsMockEnabled(),
Config: mock.GetConfig(),
}
if err := json.NewEncoder(w).Encode(status); err != nil {
log.Error().Err(err).Msg("Failed to encode mock mode response")
}
}
// 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 host URL
TokenID string `json:"tokenId"` // Full token ID like pulse-monitor@pam!pulse-token
TokenValue string `json:"tokenValue,omitempty"` // The token value for the node
ServerName string `json:"serverName"` // Hostname or IP
SetupCode string `json:"setupCode,omitempty"` // One-time setup code for authentication (deprecated)
AuthToken string `json:"authToken,omitempty"` // Direct auth token from URL (new approach)
Source string `json:"source,omitempty"` // "agent" or "script" - indicates how the node was registered
// CheckRegistration asks Pulse whether this Proxmox type already exists in config.
// Used by agents to validate stale local registration marker files after server reinstalls.
CheckRegistration bool `json:"checkRegistration,omitempty"`
// New secure fields
RequestToken bool `json:"requestToken,omitempty"` // If true, Pulse will generate and return a token
Username string `json:"username,omitempty"` // Username for creating token (e.g., "root@pam")
Password string `json:"password,omitempty"` // Password for authentication (never stored)
}
func autoRegisterNodeMatchesHost(nodeHost, requestedHost string) bool {
nodeHost = strings.TrimSpace(nodeHost)
requestedHost = strings.TrimSpace(requestedHost)
if nodeHost == "" || requestedHost == "" {
return false
}
if nodeHost == requestedHost {
return true
}
requestedIP := extractHostIP(requestedHost)
if requestedIP == "" {
requestedIP = resolveHostnameToIP(requestedHost)
}
if requestedIP == "" {
return false
}
nodeIP := extractHostIP(nodeHost)
if nodeIP == "" {
nodeIP = resolveHostnameToIP(nodeHost)
}
return nodeIP != "" && nodeIP == requestedIP
}
func (h *ConfigHandlers) autoRegisteredNodeExists(ctx context.Context, req *AutoRegisterRequest) bool {
if req == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(req.Type)) {
case "pve":
for _, node := range h.getConfig(ctx).PVEInstances {
if autoRegisterNodeMatchesHost(node.Host, req.Host) {
return true
}
}
case "pbs":
for _, node := range h.getConfig(ctx).PBSInstances {
if autoRegisterNodeMatchesHost(node.Host, req.Host) {
return true
}
}
}
return false
}
func refreshClusterCredentialsFromAutoRegister(instance *config.PVEInstance, nodeConfig NodeConfigRequest, req *AutoRegisterRequest) {
if instance == nil {
return
}
tokenName := strings.TrimSpace(nodeConfig.TokenName)
tokenValue := strings.TrimSpace(nodeConfig.TokenValue)
if tokenName == "" || tokenValue == "" {
return
}
instance.User = ""
instance.Password = ""
instance.TokenName = tokenName
instance.TokenValue = tokenValue
if req != nil && req.Source != "" {
instance.Source = req.Source
}
}
// 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 to get the setup code
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 - require either setup code or API token if auth is enabled
authenticated := false
authFailureReason := ""
// Support both setupCode (old) and authToken (new) fields
authCode := req.SetupCode
if req.AuthToken != "" {
authCode = req.AuthToken
}
log.Debug().
Bool("hasAuthToken", strings.TrimSpace(req.AuthToken) != "").
Bool("hasSetupCode", strings.TrimSpace(authCode) != "").
Bool("hasConfigToken", h.getConfig(r.Context()).HasAPITokens()).
Msg("Checking authentication for auto-register")
validateAPIToken := func(rawToken string) (*config.APITokenRecord, bool) {
token := strings.TrimSpace(rawToken)
if token == "" {
return nil, false
}
// Mirror the main auth path: avoid taking the write lock for obviously invalid tokens,
// then update usage metadata only after a positive read-only check.
config.Mu.RLock()
valid := h.getConfig(r.Context()).IsValidAPIToken(token)
config.Mu.RUnlock()
if !valid {
return nil, false
}
config.Mu.Lock()
record, ok := h.getConfig(r.Context()).ValidateAPIToken(token)
config.Mu.Unlock()
return record, ok
}
requestAPIToken := func() string {
if token := strings.TrimSpace(r.Header.Get("X-API-Token")); token != "" {
return token
}
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
return strings.TrimSpace(authHeader[7:])
}
return ""
}
setAuthFailure := func(reason string) {
if reason == "" || authFailureReason != "" {
return
}
authFailureReason = reason
}
// First check for setup code/auth token in the request
if authCode != "" {
authCode = strings.TrimSpace(authCode)
authCodeLooksLikeSetupToken := setupAuthTokenPattern.MatchString(authCode)
matchedAPIToken := false
if h.getConfig(r.Context()).HasAPITokens() {
if record, ok := validateAPIToken(authCode); ok {
matchedAPIToken = true
// Accept settings:write (admin tokens) or host-agent:report (agent tokens)
if record.HasScope(config.ScopeSettingsWrite) || record.HasScope(config.ScopeHostReport) {
authenticated = true
log.Info().
Str("type", req.Type).
Str("host", req.Host).
Msg("Auto-register authenticated via direct API token")
} else {
setAuthFailure(autoRegisterAuthMissingScope)
log.Warn().
Str("type", req.Type).
Str("host", req.Host).
Msg("Auto-register rejected: API token missing required scope")
}
} else if !authCodeLooksLikeSetupToken {
setAuthFailure(autoRegisterAuthInvalidAPI)
}
} else if !authCodeLooksLikeSetupToken {
setAuthFailure(autoRegisterAuthInvalidAPI)
}
if !authenticated && !matchedAPIToken && (authCodeLooksLikeSetupToken || !h.getConfig(r.Context()).HasAPITokens()) {
codeHash := internalauth.HashAPIToken(authCode)
log.Debug().
Bool("hasAuthCode", true).
Str("codeHash", safePrefixForLog(codeHash, 8)+"...").
Msg("Checking auth token as setup code")
if ok, reason := h.validateAutoRegisterSetupToken(authCode, req.Type); ok {
authenticated = true
log.Info().
Str("type", req.Type).
Str("host", req.Host).
Bool("via_authToken", req.AuthToken != "").
Msg("Auto-register authenticated via setup code/token")
} else {
setAuthFailure(reason)
log.Warn().
Str("type", req.Type).
Str("host", req.Host).
Str("reason", reason).
Msg("Auto-register rejected: setup token validation failed")
}
}
}
// If not authenticated via setup code, check API token if configured
if !authenticated && h.getConfig(r.Context()).HasAPITokens() {
apiToken := requestAPIToken()
if apiToken != "" {
if record, ok := validateAPIToken(apiToken); ok {
// Accept settings:write (admin tokens) or host-agent:report (agent tokens)
if record.HasScope(config.ScopeSettingsWrite) || record.HasScope(config.ScopeHostReport) {
authenticated = true
log.Info().Msg("Auto-register authenticated via API token")
} else {
setAuthFailure(autoRegisterAuthMissingScope)
log.Warn().Msg("Auto-register rejected: API token missing required scope")
}
} else {
setAuthFailure(autoRegisterAuthInvalidAPI)
}
}
} else if !authenticated {
if apiToken := requestAPIToken(); apiToken != "" {
setAuthFailure(autoRegisterAuthInvalidAPI)
}
}
// 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_auth_code", authCode != "").
Str("reason", authFailureReason).
Msg("Unauthorized auto-register attempt rejected")
if authFailureReason == "" {
authFailureReason = autoRegisterAuthMissing
}
http.Error(w, authFailureReason, http.StatusUnauthorized)
return
}
// Log source IP for security auditing
clientIP := r.RemoteAddr
// Only trust X-Forwarded-For if request comes from a trusted proxy
peerIP := extractRemoteIP(clientIP)
if isTrustedProxyIP(peerIP) {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
clientIP = forwarded
}
}
log.Info().Str("clientIP", clientIP).Msg("Auto-register request from")
// Registration token validation removed - feature deprecated
log.Info().
Str("type", req.Type).
Str("host", req.Host).
Str("tokenId", req.TokenID).
Bool("hasTokenValue", req.TokenValue != "").
Str("serverName", req.ServerName).
Msg("Processing auto-register request")
if req.CheckRegistration {
if req.Type == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
host := strings.TrimSpace(req.Host)
if host != "" {
if normalizedHost, err := normalizeNodeHost(host, req.Type); err == nil {
host = normalizedHost
}
}
req.Host = host
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"registered": h.autoRegisteredNodeExists(r.Context(), &req),
})
return
}
// Check if this is a new secure registration request
if req.RequestToken {
// New secure mode - generate token on Pulse side
if req.Type == "" || req.Host == "" || req.Username == "" || req.Password == "" {
log.Error().
Str("type", req.Type).
Str("host", req.Host).
Bool("hasUsername", req.Username != "").
Bool("hasPassword", req.Password != "").
Msg("Missing required fields for secure registration")
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// Handle secure registration
h.handleSecureAutoRegister(w, r, &req, clientIP)
return
}
// Legacy mode - validate old required fields
if req.Type == "" || req.Host == "" || req.TokenID == "" || req.TokenValue == "" {
log.Error().
Str("type", req.Type).
Str("host", req.Host).
Str("tokenId", req.TokenID).
Bool("hasToken", req.TokenValue != "").
Msg("Missing required fields")
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
host, err := normalizeNodeHost(req.Host, req.Type)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fingerprint := ""
if fp, err := tlsutil.FetchFingerprint(host); err != nil {
log.Warn().Err(err).Str("host", host).Msg("Failed to fetch TLS fingerprint for auto-register")
} else {
fingerprint = fp
}
// Create a node configuration
boolFalse := false
boolTrue := true
verifySSL := fingerprint != "" // Only enforce strict TLS when we have a fingerprint to verify against
nodeConfig := NodeConfigRequest{
Type: req.Type,
Name: req.ServerName,
Host: host, // Use normalized host
TokenName: req.TokenID,
TokenValue: req.TokenValue,
Fingerprint: fingerprint,
VerifySSL: &verifySSL,
MonitorVMs: &boolTrue,
MonitorContainers: &boolTrue,
MonitorStorage: &boolTrue,
MonitorBackups: &boolTrue,
MonitorDatastores: &boolTrue,
MonitorSyncJobs: &boolTrue,
MonitorVerifyJobs: &boolTrue,
MonitorPruneJobs: &boolTrue,
MonitorGarbageJobs: &boolFalse,
}
// Check if a node with this host already exists
// IMPORTANT: Match by Host URL primarily.
// Also match by name+tokenID for DHCP scenarios where IP changed but it's the same host.
// Different physical hosts can have the same hostname (e.g., "px1" on different networks)
// but they'll have different tokens, so we only merge if BOTH name AND token match.
// See: Issue #891, #104, #924, #940 and multiple fix attempts in Dec 2025.
existingIndex := -1
preserveHost := false // When true, keep user's configured hostname instead of overwriting with agent's IP
// Extract IP from the new host URL for DNS comparison
newHostIP := extractHostIP(host)
if req.Type == "pve" {
for i, node := range h.getConfig(r.Context()).PVEInstances {
if node.Host == host {
existingIndex = i
break
}
// DHCP case: same hostname AND same token = same physical host with new IP
// This allows IP changes to update existing nodes without creating duplicates
if req.ServerName != "" && strings.EqualFold(node.Name, req.ServerName) && node.TokenName == req.TokenID {
existingIndex = i
// When an agent re-registers, preserve the existing host. The user may
// have edited it to a public URL/IP that differs from the agent's local
// IP. Only allow non-agent sources to update the host (true DHCP). (#1283)
if req.Source == "agent" && node.Host != host {
preserveHost = true
log.Info().
Str("existingHost", node.Host).
Str("agentHost", host).
Str("node", req.ServerName).
Msg("Agent re-registration with same token - preserving configured host URL")
} else if node.Host != host {
log.Info().
Str("oldHost", node.Host).
Str("newHost", host).
Str("node", req.ServerName).
Msg("Detected IP change for existing node - updating host")
}
break
}
// Agent re-registration: same server name + both tokens created by Pulse agent.
// On reinstall/update the agent creates a new token (different timestamp), so the
// exact token match above fails. Match by server name when both entries are
// agent-created Pulse tokens to prevent duplicates. (#1245)
if req.Source == "agent" && node.Source == "agent" && req.ServerName != "" &&
strings.EqualFold(node.Name, req.ServerName) &&
isPulseAgentToken(node.TokenName) && isPulseAgentToken(req.TokenID) {
existingIndex = i
preserveHost = true
log.Info().
Str("oldHost", node.Host).
Str("newHost", host).
Str("node", req.ServerName).
Str("oldToken", node.TokenName).
Str("newToken", req.TokenID).
Msg("Agent re-registration detected by server name + Pulse token pattern - merging")
break
}
// Agent registration: check if existing hostname resolves to the new IP
// This catches the case where a node was manually added by hostname and
// then the agent registers using the IP address. (Issue #924)
// We preserve the user's configured hostname instead of overwriting with IP. (Issue #940)
if req.Source == "agent" && newHostIP != "" {
existingHostIP := extractHostIP(node.Host)
if existingHostIP == "" {
// Existing config uses hostname, try to resolve it
existingHostIP = resolveHostnameToIP(node.Host)
}
if existingHostIP == newHostIP {
existingIndex = i
preserveHost = true // Keep user's configured hostname
log.Info().
Str("existingHost", node.Host).
Str("newHost", host).
Str("resolvedIP", newHostIP).
Str("node", node.Name).
Msg("Agent registration detected existing node by IP resolution - preserving configured hostname")
break
}
}
}
} else {
for i, node := range h.getConfig(r.Context()).PBSInstances {
if node.Host == host {
existingIndex = i
break
}
// DHCP case: same hostname AND same token = same physical host with new IP
if req.ServerName != "" && strings.EqualFold(node.Name, req.ServerName) && node.TokenName == req.TokenID {
existingIndex = i
// When an agent re-registers, preserve the existing host (#1283)
if req.Source == "agent" && node.Host != host {
preserveHost = true
log.Info().
Str("existingHost", node.Host).
Str("agentHost", host).
Str("node", req.ServerName).
Msg("Agent re-registration with same token - preserving configured host URL")
} else if node.Host != host {
log.Info().
Str("oldHost", node.Host).
Str("newHost", host).
Str("node", req.ServerName).
Msg("Detected IP change for existing node - updating host")
}
break
}
// Agent re-registration: same server name + both Pulse agent tokens (#1245)
if req.Source == "agent" && node.Source == "agent" && req.ServerName != "" &&
strings.EqualFold(node.Name, req.ServerName) &&
isPulseAgentToken(node.TokenName) && isPulseAgentToken(req.TokenID) {
existingIndex = i
preserveHost = true
log.Info().
Str("oldHost", node.Host).
Str("newHost", host).
Str("node", req.ServerName).
Str("oldToken", node.TokenName).
Str("newToken", req.TokenID).
Msg("Agent re-registration detected by server name + Pulse token pattern - merging")
break
}
// Agent registration: check if existing hostname resolves to the new IP
// We preserve the user's configured hostname instead of overwriting with IP. (Issue #940)
if req.Source == "agent" && newHostIP != "" {
existingHostIP := extractHostIP(node.Host)
if existingHostIP == "" {
existingHostIP = resolveHostnameToIP(node.Host)
}
if existingHostIP == newHostIP {
existingIndex = i
preserveHost = true // Keep user's configured hostname
log.Info().
Str("existingHost", node.Host).
Str("newHost", host).
Str("resolvedIP", newHostIP).
Str("node", node.Name).
Msg("Agent registration detected existing node by IP resolution - preserving configured hostname")
break
}
}
}
}
// If node exists, update it; otherwise add new
if existingIndex >= 0 {
// Update existing node
if req.Type == "pve" {
instance := &h.getConfig(r.Context()).PVEInstances[existingIndex]
// Update host in case IP changed (DHCP scenario)
// But preserve user's configured hostname when matched by IP resolution (Issue #940)
if !preserveHost {
instance.Host = host
}
// Clear password auth when switching to token auth
instance.User = ""
instance.Password = ""
instance.TokenName = nodeConfig.TokenName
instance.TokenValue = nodeConfig.TokenValue
// Update TLS fingerprint only when one was captured; a failed
// FetchFingerprint must not erase a previously valid pin. Refs: #1303
if nodeConfig.Fingerprint != "" {
instance.Fingerprint = nodeConfig.Fingerprint
}
// Fix broken state: verifySSL=true with no fingerprint can never connect
// to self-signed Proxmox certs. Downgrade to insecure if no fingerprint. Refs: #1303
if instance.VerifySSL && instance.Fingerprint == "" {
instance.VerifySSL = false
}
// Update source if provided (allows upgrade from script to agent)
if req.Source != "" {
instance.Source = req.Source
}
// Check for cluster if not already detected
if !instance.IsCluster {
clientConfig := proxmox.ClientConfig{
Host: instance.Host,
TokenName: nodeConfig.TokenName,
TokenValue: nodeConfig.TokenValue,
VerifySSL: instance.VerifySSL,
Fingerprint: instance.Fingerprint,
}
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, instance.Name, instance.ClusterEndpoints)
if isCluster {
instance.IsCluster = true
instance.ClusterName = clusterName
instance.ClusterEndpoints = clusterEndpoints
log.Info().
Str("cluster", clusterName).
Int("endpoints", len(clusterEndpoints)).
Msg("Detected Proxmox cluster during auto-registration update")
}
}
// Keep other settings as they were
} else {
instance := &h.getConfig(r.Context()).PBSInstances[existingIndex]
// Update host in case IP changed (DHCP scenario)
// But preserve user's configured hostname when matched by IP resolution (Issue #940)
if !preserveHost {
instance.Host = host
}
// Clear password auth when switching to token auth
instance.User = ""
instance.Password = ""
instance.TokenName = nodeConfig.TokenName
instance.TokenValue = nodeConfig.TokenValue
// Update TLS fingerprint only when one was captured; a failed
// FetchFingerprint must not erase a previously valid pin. Refs: #1303
if nodeConfig.Fingerprint != "" {
instance.Fingerprint = nodeConfig.Fingerprint
}
// Fix broken state: verifySSL=true with no fingerprint can never connect
// to self-signed Proxmox certs. Downgrade to insecure if no fingerprint. Refs: #1303
if instance.VerifySSL && instance.Fingerprint == "" {
instance.VerifySSL = false
}
// Update source if provided (allows upgrade from script to agent)
if req.Source != "" {
instance.Source = req.Source
}
// Keep other settings as they were
}
log.Info().
Str("host", req.Host).
Str("type", req.Type).
Str("tokenName", nodeConfig.TokenName).
Bool("hasTokenValue", nodeConfig.TokenValue != "").
Msg("Updated existing node with new token")
} else {
// Add new node
if req.Type == "pve" {
// Check for cluster detection using helper
verifySSL := false
if nodeConfig.VerifySSL != nil {
verifySSL = *nodeConfig.VerifySSL
}
clientConfig := proxmox.ClientConfig{
Host: nodeConfig.Host,
TokenName: nodeConfig.TokenName,
TokenValue: nodeConfig.TokenValue,
VerifySSL: verifySSL,
Fingerprint: nodeConfig.Fingerprint,
}
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, nodeConfig.Name, nil)
// CLUSTER DEDUPLICATION: Check if we already have this cluster configured
// If so, merge this node as an endpoint instead of creating a duplicate instance
if isCluster && clusterName != "" {
for i := range h.getConfig(r.Context()).PVEInstances {
existingInstance := &h.getConfig(r.Context()).PVEInstances[i]
if existingInstance.IsCluster && existingInstance.ClusterName == clusterName {
// Found existing cluster with same name - merge endpoints!
log.Info().
Str("cluster", clusterName).
Str("existingInstance", existingInstance.Name).
Str("newNode", nodeConfig.Name).
Msg("Auto-registered node belongs to already-configured cluster - merging endpoints")
// Merge any new endpoints from the detected cluster
existingEndpointMap := make(map[string]bool)
for _, ep := range existingInstance.ClusterEndpoints {
existingEndpointMap[ep.NodeName] = true
}
for _, newEp := range clusterEndpoints {
if !existingEndpointMap[newEp.NodeName] {
existingInstance.ClusterEndpoints = append(existingInstance.ClusterEndpoints, newEp)
log.Info().
Str("cluster", clusterName).
Str("endpoint", newEp.NodeName).
Msg("Added new endpoint to existing cluster via auto-registration")
}
}
refreshClusterCredentialsFromAutoRegister(existingInstance, nodeConfig, &req)
log.Info().
Str("cluster", clusterName).
Str("tokenName", existingInstance.TokenName).
Msg("Refreshed existing cluster credentials from auto-registration")
// Save and reload
if h.getPersistence(r.Context()) != nil {
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
log.Warn().Err(err).Msg("Failed to persist cluster endpoint merge during auto-registration")
}
}
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Warn().Err(err).Msg("Failed to reload monitor after cluster merge during auto-registration")
}
}
// Return success - merged into existing cluster
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"merged": true,
"cluster": clusterName,
"existingNode": existingInstance.Name,
"message": fmt.Sprintf("Agent merged into existing cluster '%s'", clusterName),
"totalEndpoints": len(existingInstance.ClusterEndpoints),
})
return
}
}
}
monitorVMs := true
if nodeConfig.MonitorVMs != nil {
monitorVMs = *nodeConfig.MonitorVMs
}
monitorContainers := true
if nodeConfig.MonitorContainers != nil {
monitorContainers = *nodeConfig.MonitorContainers
}
monitorStorage := true
if nodeConfig.MonitorStorage != nil {
monitorStorage = *nodeConfig.MonitorStorage
}
monitorBackups := true
if nodeConfig.MonitorBackups != nil {
monitorBackups = *nodeConfig.MonitorBackups
}
// Disambiguate node name if duplicate hostnames exist
displayName := h.disambiguateNodeName(r.Context(), nodeConfig.Name, nodeConfig.Host, "pve")
newInstance := config.PVEInstance{
Name: displayName,
Host: nodeConfig.Host,
TokenName: nodeConfig.TokenName,
TokenValue: nodeConfig.TokenValue,
Fingerprint: nodeConfig.Fingerprint,
VerifySSL: verifySSL,
MonitorVMs: monitorVMs,
MonitorContainers: monitorContainers,
MonitorStorage: monitorStorage,
MonitorBackups: monitorBackups,
IsCluster: isCluster,
ClusterName: clusterName,
ClusterEndpoints: clusterEndpoints,
Source: req.Source, // Track how this node was registered
}
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, newInstance)
if isCluster {
log.Info().
Str("cluster", clusterName).
Int("endpoints", len(clusterEndpoints)).
Msg("Added new Proxmox cluster via auto-registration")
}
} else {
verifySSL := false
if nodeConfig.VerifySSL != nil {
verifySSL = *nodeConfig.VerifySSL
}
monitorDatastores := false
if nodeConfig.MonitorDatastores != nil {
monitorDatastores = *nodeConfig.MonitorDatastores
}
monitorSyncJobs := false
if nodeConfig.MonitorSyncJobs != nil {
monitorSyncJobs = *nodeConfig.MonitorSyncJobs
}
monitorVerifyJobs := false
if nodeConfig.MonitorVerifyJobs != nil {
monitorVerifyJobs = *nodeConfig.MonitorVerifyJobs
}
monitorPruneJobs := false
if nodeConfig.MonitorPruneJobs != nil {
monitorPruneJobs = *nodeConfig.MonitorPruneJobs
}
monitorGarbageJobs := false
if nodeConfig.MonitorGarbageJobs != nil {
monitorGarbageJobs = *nodeConfig.MonitorGarbageJobs
}
// Disambiguate node name if duplicate hostnames exist
pbsDisplayName := h.disambiguateNodeName(r.Context(), nodeConfig.Name, nodeConfig.Host, "pbs")
newInstance := config.PBSInstance{
Name: pbsDisplayName,
Host: nodeConfig.Host,
TokenName: nodeConfig.TokenName,
TokenValue: nodeConfig.TokenValue,
Fingerprint: nodeConfig.Fingerprint,
VerifySSL: verifySSL,
MonitorBackups: true, // Enable by default for PBS
MonitorDatastores: monitorDatastores,
MonitorSyncJobs: monitorSyncJobs,
MonitorVerifyJobs: monitorVerifyJobs,
MonitorPruneJobs: monitorPruneJobs,
MonitorGarbageJobs: monitorGarbageJobs,
Source: req.Source, // Track how this node was registered
}
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, newInstance)
}
log.Info().Str("host", req.Host).Str("type", req.Type).Msg("Added new node via auto-registration")
}
// Log what we're about to save
if req.Type == "pve" && len(h.getConfig(r.Context()).PVEInstances) > 0 {
lastNode := h.getConfig(r.Context()).PVEInstances[len(h.getConfig(r.Context()).PVEInstances)-1]
log.Info().
Str("name", lastNode.Name).
Str("host", lastNode.Host).
Str("tokenName", lastNode.TokenName).
Bool("hasTokenValue", lastNode.TokenValue != "").
Msg("About to save PVE node")
}
// Save configuration
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
log.Error().Err(err).Msg("Failed to save auto-registered node")
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
log.Info().Msg("Configuration saved successfully")
actualName := h.findInstanceNameByHost(r.Context(), req.Type, host)
if actualName == "" {
actualName = strings.TrimSpace(req.ServerName)
}
if actualName == "" {
actualName = strings.TrimSpace(nodeConfig.Name)
}
if actualName == "" {
actualName = host
}
h.markAutoRegistered(req.Type, actualName)
// Reload monitor to pick up new configuration (synchronous, same as manual add path).
// Running this async previously caused a bug where the node config was saved but the
// poller never picked it up if reload failed silently.
if h.reloadFunc != nil {
log.Info().Msg("Reloading monitor after auto-registration")
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after auto-registration")
} else {
log.Info().Msg("Monitor reloaded successfully after auto-registration")
}
}
// Trigger a discovery refresh to remove the node from discovered list
if h.getMonitor(r.Context()) != nil && h.getMonitor(r.Context()).GetDiscoveryService() != nil {
log.Info().Msg("Triggering discovery refresh after auto-registration")
h.getMonitor(r.Context()).GetDiscoveryService().ForceRefresh()
}
// Broadcast auto-registration success via WebSocket
if h.wsHub != nil {
nodeInfo := map[string]interface{}{
"type": req.Type,
"host": req.Host,
"name": req.ServerName,
"tokenId": req.TokenID,
"hasToken": true,
"verifySSL": false,
"status": "connected",
}
// Broadcast the auto-registration success
h.wsHub.BroadcastMessage(websocket.Message{
Type: "node_auto_registered",
Data: nodeInfo,
Timestamp: time.Now().Format(time.RFC3339),
})
// Also broadcast a discovery update to refresh the UI
if h.getMonitor(r.Context()) != nil && h.getMonitor(r.Context()).GetDiscoveryService() != nil {
result, _ := h.getMonitor(r.Context()).GetDiscoveryService().GetCachedResult()
if result != nil {
h.wsHub.BroadcastMessage(websocket.Message{
Type: "discovery_update",
Data: map[string]interface{}{
"servers": result.Servers,
"errors": result.Errors,
"timestamp": time.Now().Unix(),
},
Timestamp: time.Now().Format(time.RFC3339),
})
log.Info().Msg("Broadcasted discovery update after auto-registration")
}
}
log.Info().
Str("host", req.Host).
Str("name", req.ServerName).
Str("type", "node_auto_registered").
Msg("Broadcasted auto-registration success via WebSocket")
} else {
log.Warn().Msg("WebSocket hub is nil, cannot broadcast auto-registration")
}
// Send success response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Node %s auto-registered successfully", req.Host),
"nodeId": req.Host,
})
}
// handleSecureAutoRegister handles the new secure registration flow where Pulse generates the token
func (h *ConfigHandlers) handleSecureAutoRegister(w http.ResponseWriter, r *http.Request, req *AutoRegisterRequest, clientIP string) {
log.Info().
Str("type", req.Type).
Str("host", req.Host).
Str("username", req.Username).
Msg("Processing secure auto-register request")
host, err := normalizeNodeHost(req.Host, req.Type)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
hostname, _ := os.Hostname()
tokenName := buildPulseMonitorTokenName(r.Host, hostname, clientIP)
// Generate a secure random token value
tokenBytes := make([]byte, 16)
if _, err := rand.Read(tokenBytes); err != nil {
log.Error().Err(err).Msg("Failed to generate secure token")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
tokenValue := fmt.Sprintf("%x-%x-%x-%x-%x",
tokenBytes[0:4], tokenBytes[4:6], tokenBytes[6:8], tokenBytes[8:10], tokenBytes[10:16])
fingerprint := ""
if fp, err := tlsutil.FetchFingerprint(host); err != nil {
log.Warn().Err(err).Str("host", host).Msg("Failed to fetch TLS fingerprint for auto-register")
} else {
fingerprint = fp
}
verifySSL := fingerprint != "" // Only enforce strict TLS when we have a fingerprint to verify against
existingTokenID := ""
existingTokenValue := ""
if req.Type == "pve" {
for _, node := range h.getConfig(r.Context()).PVEInstances {
if node.Host == host && isPulseAgentToken(node.TokenName) && strings.TrimSpace(node.TokenValue) != "" {
existingTokenID = node.TokenName
existingTokenValue = node.TokenValue
break
}
}
} else if req.Type == "pbs" {
for _, node := range h.getConfig(r.Context()).PBSInstances {
if node.Host == host && isPulseAgentToken(node.TokenName) && strings.TrimSpace(node.TokenValue) != "" {
existingTokenID = node.TokenName
existingTokenValue = node.TokenValue
break
}
}
}
var fullTokenID string
if existingTokenID != "" {
fullTokenID = existingTokenID
tokenValue = existingTokenValue
log.Info().
Str("host", host).
Str("tokenID", fullTokenID).
Msg("Reusing existing Pulse-managed token for secure auto-register")
} else if req.Type == "pve" {
// For PVE, create token via API
fullTokenID = fmt.Sprintf("pulse-monitor@pam!%s", tokenName)
// Note: This would require implementing token creation in the proxmox package
// For now, we'll return the token for the script to create
// TODO: Implement PVE token creation via API
} else if req.Type == "pbs" {
// For PBS, create token via API using the new client methods
log.Info().
Str("host", host).
Str("username", req.Username).
Msg("Creating PBS token via API")
pbsClient, err := pbs.NewClient(pbs.ClientConfig{
Host: host,
User: req.Username,
Password: req.Password,
Fingerprint: fingerprint,
VerifySSL: verifySSL,
})
if err != nil {
log.Error().Err(err).Str("host", host).Msg("Failed to create PBS client")
http.Error(w, fmt.Sprintf("Failed to connect to PBS: %v", err), http.StatusBadRequest)
return
}
// Use the turnkey method to create user + token
tokenID, tokenSecret, err := pbsClient.SetupMonitoringAccess(context.Background(), tokenName)
if err != nil {
log.Error().Err(err).Str("host", host).Msg("Failed to create PBS monitoring access")
http.Error(w, fmt.Sprintf("Failed to create token: %v", err), http.StatusInternalServerError)
return
}
fullTokenID = tokenID
tokenValue = tokenSecret
log.Info().
Str("host", host).
Str("tokenID", fullTokenID).
Msg("Successfully created PBS token via API")
}
// Determine server name
serverName := req.ServerName
if serverName == "" {
// Extract from host
serverName = host
serverName = strings.TrimPrefix(serverName, "https://")
serverName = strings.TrimPrefix(serverName, "http://")
if idx := strings.Index(serverName, ":"); idx > 0 {
serverName = serverName[:idx]
}
}
// Add the node to configuration
if req.Type == "pve" {
pveNode := config.PVEInstance{
Name: serverName,
Host: host,
TokenName: fullTokenID,
TokenValue: tokenValue,
Fingerprint: fingerprint,
VerifySSL: verifySSL,
MonitorVMs: true,
MonitorContainers: true,
MonitorStorage: true,
MonitorBackups: true,
}
// Deduplicate by host to keep secure auto-registration idempotent on reruns.
existingIndex := -1
for i, node := range h.getConfig(r.Context()).PVEInstances {
if node.Host == host {
existingIndex = i
break
}
}
if existingIndex >= 0 {
instance := &h.getConfig(r.Context()).PVEInstances[existingIndex]
instance.Host = host
instance.User = ""
instance.Password = ""
instance.TokenName = pveNode.TokenName
instance.TokenValue = pveNode.TokenValue
// Update TLS fingerprint only when one was captured; a failed
// FetchFingerprint must not erase a previously valid pin. Refs: #1303
if pveNode.Fingerprint != "" {
instance.Fingerprint = pveNode.Fingerprint
}
instance.VerifySSL = pveNode.VerifySSL
log.Info().Str("host", host).Str("type", "pve").Msg("Secure auto-register matched existing node by host; updated token in-place")
} else {
h.getConfig(r.Context()).PVEInstances = append(h.getConfig(r.Context()).PVEInstances, pveNode)
}
} else if req.Type == "pbs" {
pbsNode := config.PBSInstance{
Name: serverName,
Host: host,
TokenName: fullTokenID,
TokenValue: tokenValue,
Fingerprint: fingerprint,
VerifySSL: verifySSL,
MonitorBackups: true,
MonitorDatastores: true,
MonitorSyncJobs: true,
MonitorVerifyJobs: true,
MonitorPruneJobs: true,
}
// Deduplicate by host to keep secure auto-registration idempotent on reruns.
existingIndex := -1
for i, node := range h.getConfig(r.Context()).PBSInstances {
if node.Host == host {
existingIndex = i
break
}
}
if existingIndex >= 0 {
instance := &h.getConfig(r.Context()).PBSInstances[existingIndex]
instance.Host = host
instance.User = ""
instance.Password = ""
instance.TokenName = pbsNode.TokenName
instance.TokenValue = pbsNode.TokenValue
// Update TLS fingerprint only when one was captured; a failed
// FetchFingerprint must not erase a previously valid pin. Refs: #1303
if pbsNode.Fingerprint != "" {
instance.Fingerprint = pbsNode.Fingerprint
}
instance.VerifySSL = pbsNode.VerifySSL
log.Info().Str("host", host).Str("type", "pbs").Msg("Secure auto-register matched existing node by host; updated token in-place")
} else {
h.getConfig(r.Context()).PBSInstances = append(h.getConfig(r.Context()).PBSInstances, pbsNode)
}
}
// Save configuration
if err := h.getPersistence(r.Context()).SaveNodesConfig(h.getConfig(r.Context()).PVEInstances, h.getConfig(r.Context()).PBSInstances, h.getConfig(r.Context()).PMGInstances); err != nil {
log.Error().Err(err).Msg("Failed to save auto-registered node")
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
actualName := h.findInstanceNameByHost(r.Context(), req.Type, host)
if actualName == "" {
actualName = serverName
}
h.markAutoRegistered(req.Type, actualName)
// Reload monitor (synchronous, same as manual add path)
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after auto-registration")
}
}
// Send success response with token details for script to create
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"message": fmt.Sprintf("Node %s registered successfully", req.Host),
"nodeId": serverName,
"tokenId": fullTokenID,
"tokenValue": tokenValue,
"action": "create_token", // Tells the script to create this token
})
}
// 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: https://github.com/rcourtman/Pulse/blob/main/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,
}
}
// generateOrLoadSSHKey generates or loads a single SSH keypair
func (h *ConfigHandlers) generateOrLoadSSHKey(sshDir, privateKeyPath, publicKeyPath, keyType string) string {
// Check if public key already exists
if pubKeyBytes, err := os.ReadFile(publicKeyPath); err == nil {
publicKey := strings.TrimSpace(string(pubKeyBytes))
log.Info().Str("keyPath", publicKeyPath).Str("type", keyType).Msg("Using existing SSH public key")
return publicKey
}
// Key doesn't exist - generate one
log.Info().Str("sshDir", sshDir).Str("type", keyType).Msg("Generating new SSH keypair for temperature monitoring")
// Create .ssh directory if it doesn't exist
if err := os.MkdirAll(sshDir, 0700); err != nil {
log.Error().Err(err).Str("sshDir", sshDir).Msg("Failed to create .ssh directory")
return ""
}
// Generate Ed25519 key pair (more secure and faster than RSA)
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Error().Err(err).Msg("Failed to generate Ed25519 key")
return ""
}
// Save private key in OpenSSH format
privateKeyFile, err := os.OpenFile(privateKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
log.Error().Err(err).Str("path", privateKeyPath).Msg("Failed to create private key file")
return ""
}
defer privateKeyFile.Close()
// Marshal Ed25519 private key to OpenSSH format
privKeyBytes, err := ssh.MarshalPrivateKey(privateKey, "")
if err != nil {
log.Error().Err(err).Msg("Failed to marshal private key")
return ""
}
if err := pem.Encode(privateKeyFile, privKeyBytes); err != nil {
log.Error().Err(err).Msg("Failed to write private key")
return ""
}
// Generate public key in OpenSSH format
sshPublicKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
log.Error().Err(err).Msg("Failed to generate public key")
return ""
}
publicKeyBytes := ssh.MarshalAuthorizedKey(sshPublicKey)
publicKeyString := strings.TrimSpace(string(publicKeyBytes))
// Save public key
if err := os.WriteFile(publicKeyPath, publicKeyBytes, 0644); err != nil {
log.Error().Err(err).Str("path", publicKeyPath).Msg("Failed to write public key")
return ""
}
log.Info().
Str("privateKey", privateKeyPath).
Str("publicKey", publicKeyPath).
Msg("Successfully generated SSH keypair")
return publicKeyString
}
// AgentInstallCommandRequest represents a request for an agent install command
type AgentInstallCommandRequest struct {
Type string `json:"type"` // "pve" or "pbs"
}
// AgentInstallCommandResponse contains the generated install command
type AgentInstallCommandResponse struct {
Command string `json:"command"`
Token string `json:"token"`
}
// HandleAgentInstallCommand generates an API token and install command for agent-based Proxmox setup
func (h *ConfigHandlers) HandleAgentInstallCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req AgentInstallCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate type
if req.Type != "pve" && req.Type != "pbs" {
http.Error(w, "Type must be 'pve' or 'pbs'", http.StatusBadRequest)
return
}
defaultCtx := context.WithValue(r.Context(), OrgIDContextKey, "default")
// Generate a new API token with host report and host manage scopes
rawToken, err := internalauth.GenerateAPIToken()
if err != nil {
log.Error().Err(err).Msg("Failed to generate API token for agent install")
http.Error(w, "Failed to generate API token", http.StatusInternalServerError)
return
}
tokenName := fmt.Sprintf("proxmox-agent-%s-%d", req.Type, time.Now().Unix())
scopes := []string{
config.ScopeHostReport,
config.ScopeHostConfigRead,
config.ScopeHostManage,
config.ScopeAgentExec,
}
record, err := config.NewAPITokenRecord(rawToken, tokenName, scopes)
if err != nil {
log.Error().Err(err).Str("token_name", tokenName).Msg("Failed to construct API token record")
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
// Persist the token
config.Mu.Lock()
h.getConfig(defaultCtx).APITokens = append(h.getConfig(defaultCtx).APITokens, *record)
h.getConfig(defaultCtx).SortAPITokens()
if h.getPersistence(defaultCtx) != nil {
if err := h.getPersistence(defaultCtx).SaveAPITokens(h.getConfig(defaultCtx).APITokens); err != nil {
// Rollback the in-memory addition
h.getConfig(defaultCtx).APITokens = h.getConfig(defaultCtx).APITokens[:len(h.getConfig(defaultCtx).APITokens)-1]
config.Mu.Unlock()
log.Error().Err(err).Msg("Failed to persist API tokens after creation")
http.Error(w, "Failed to save token to disk: "+err.Error(), http.StatusInternalServerError)
return
}
}
config.Mu.Unlock()
// Derive Pulse URL from the request
host := r.Host
if parsedHost, parsedPort, err := net.SplitHostPort(host); err == nil {
if (parsedHost == "127.0.0.1" || parsedHost == "localhost") && parsedPort == strconv.Itoa(h.getConfig(defaultCtx).FrontendPort) {
// Prefer a user-configured public URL when we're running on loopback
if publicURL := strings.TrimSpace(h.getConfig(defaultCtx).PublicURL); publicURL != "" {
if parsedURL, err := url.Parse(publicURL); err == nil && parsedURL.Host != "" {
host = parsedURL.Host
}
}
}
}
// Detect protocol - check both TLS and proxy headers
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
pulseURL := fmt.Sprintf("%s://%s", scheme, host)
// Generate the install command
command := fmt.Sprintf(`curl -fsSL %s/install.sh | bash -s -- \
--url %s \
--token %s \
--enable-proxmox`,
pulseURL, pulseURL, rawToken)
log.Info().
Str("token_name", tokenName).
Str("type", req.Type).
Msg("Generated agent install command with API token")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AgentInstallCommandResponse{
Command: command,
Token: rawToken,
})
}