package api import ( "context" "fmt" "net" "net/http" "net/url" "os" "regexp" "strconv" "strings" "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "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" "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}$`) installerURLHostPattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`) pulseTokenSlugPattern = regexp.MustCompile(`[^a-z0-9]+`) ) 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") } // Installer URLs are embedded into shell scripts; reject credentials and // query/fragment components to reduce injection and ambiguity risk. if parsed.User != nil { return "", fmt.Errorf("userinfo is not allowed") } if parsed.RawQuery != "" { return "", fmt.Errorf("query parameters are not allowed") } if parsed.Fragment != "" { return "", fmt.Errorf("fragment is not allowed") } host := strings.TrimSpace(parsed.Hostname()) if host == "" { return "", fmt.Errorf("host component is required") } if strings.ContainsAny(host, "$`\\\"' \t\r\n") { return "", fmt.Errorf("host contains invalid characters") } if ip := net.ParseIP(host); ip == nil { if !installerURLHostPattern.MatchString(host) { return "", fmt.Errorf("host contains invalid characters") } } if port := strings.TrimSpace(parsed.Port()); port != "" { parsedPort, err := strconv.Atoi(port) if err != nil || parsedPort < 1 || parsedPort > 65535 { return "", fmt.Errorf("port must be between 1 and 65535") } } sanitized := parsed.String() if strings.ContainsAny(sanitized, "$`\\") { return "", fmt.Errorf("URL contains unsupported shell-expansion characters") } return sanitized, 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 } // 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 } // isPulseAgentToken returns true if the token ID was created by the Pulse agent. // Agent tokens follow "pulse-monitor@pve!pulse-" (PVE) or // "pulse-monitor@pbs!pulse-" (PBS). Legacy tokens may include a timestamp suffix. func isPulseAgentToken(tokenID string) bool { return strings.HasPrefix(tokenID, "pulse-monitor@pve!pulse-") || strings.HasPrefix(tokenID, "pulse-monitor@pbs!pulse-") } // SetupTokenRecord represents a one-time setup token for secure node registration. type SetupTokenRecord 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 } // RecentSetupTokenRecord preserves minimal identity metadata for the short // follow-up grace window after a setup token has been consumed. type RecentSetupTokenRecord struct { ExpiresAt time.Time NodeType string Host string OrgID string } // ConfigHandlers handles configuration-related API endpoints type ConfigHandlers struct { stateMu sync.RWMutex mtPersistence *config.MultiTenantPersistence mtMonitor *monitoring.MultiTenantMonitor // Default-org runtime fields used when tenant-specific state is unavailable. defaultConfig *config.Config defaultPersistence *config.ConfigPersistence defaultMonitor *monitoring.Monitor reloadFunc func() error reloadSystemSettingsFunc func() // Function to reload cached system settings mockModeChanged func(bool) wsHub *websocket.Hub guestMetadataHandler *GuestMetadataHandler setupTokens map[string]*SetupTokenRecord // Map of token hash -> setup token details recentSetupTokens map[string]RecentSetupTokenRecord // 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-org values from multi-tenant managers when available. 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, defaultConfig: defaultConfig, defaultMonitor: defaultMonitor, defaultPersistence: defaultPersistence, reloadFunc: reloadFunc, reloadSystemSettingsFunc: reloadSystemSettingsFunc, wsHub: wsHub, guestMetadataHandler: guestMetadataHandler, setupTokens: make(map[string]*SetupTokenRecord), recentSetupTokens: make(map[string]RecentSetupTokenRecord), lastClusterDetection: make(map[string]time.Time), recentAutoRegistered: make(map[string]time.Time), } // Clean up expired setup tokens periodically. go h.cleanupExpiredSetupTokens() return h } // SetMultiTenantMonitor updates the monitor reference used by the config handlers. func (h *ConfigHandlers) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) { var defaultMonitor *monitoring.Monitor var defaultConfig *config.Config if mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil { defaultMonitor = m if m != nil { defaultConfig = m.GetConfig() } } } h.stateMu.Lock() defer h.stateMu.Unlock() h.mtMonitor = mtm if defaultMonitor != nil { h.defaultMonitor = defaultMonitor h.defaultConfig = defaultConfig } } // SetMonitor updates the default-org monitor reference used by the config handlers. func (h *ConfigHandlers) SetMonitor(m *monitoring.Monitor) { h.stateMu.Lock() defer h.stateMu.Unlock() h.defaultMonitor = m if m != nil { h.defaultConfig = m.GetConfig() } } // SetConfig updates the configuration reference used by the handlers. func (h *ConfigHandlers) SetConfig(cfg *config.Config) { if cfg == nil { return } h.stateMu.Lock() defer h.stateMu.Unlock() h.defaultConfig = cfg } // SetMockModeChangeHook registers a callback invoked after mock mode changes. func (h *ConfigHandlers) SetMockModeChangeHook(hook func(bool)) { h.stateMu.Lock() defer h.stateMu.Unlock() h.mockModeChanged = hook } // getContextState helper to retrieve tenant-specific state func (h *ConfigHandlers) getContextState(ctx context.Context) (*config.Config, *config.ConfigPersistence, *monitoring.Monitor) { h.stateMu.RLock() mtMonitor := h.mtMonitor mtPersistence := h.mtPersistence defaultConfig := h.defaultConfig defaultPersistence := h.defaultPersistence defaultMonitor := h.defaultMonitor h.stateMu.RUnlock() orgID := "default" if ctx != nil { if requestOrgID := GetOrgID(ctx); requestOrgID != "" { orgID = requestOrgID } } // Resolve from multi-tenant managers first. if mtMonitor != nil { if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil { cfg := m.GetConfig() var p *config.ConfigPersistence if mtPersistence != nil { p, _ = mtPersistence.GetPersistence(orgID) } return cfg, p, m } else { if orgID != "default" { log.Warn().Str("orgID", orgID).Err(err).Msg("Tenant config resolution failed for non-default org") return nil, nil, nil } if err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Falling back to default-org config after default tenant monitor lookup failure") } } } // Fallback to default-org state (primarily for "default" org or initialization). return defaultConfig, defaultPersistence, defaultMonitor } 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 } func (h *ConfigHandlers) normalizePVEConfigState(ctx context.Context) { cfg := h.getConfig(ctx) if cfg == nil { return } if normalized, changed := config.ConsolidatePVEInstances(cfg.PVEInstances); changed { cfg.PVEInstances = normalized } } // cleanupExpiredSetupTokens removes expired or used setup tokens periodically. func (h *ConfigHandlers) cleanupExpiredSetupTokens() { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for range ticker.C { h.codeMutex.Lock() now := time.Now() for tokenHash, tokenRecord := range h.setupTokens { if now.After(tokenRecord.ExpiresAt) || tokenRecord.Used { delete(h.setupTokens, tokenHash) log.Debug().Bool("was_used", tokenRecord.Used).Msg("Cleaned up setup token") } } for tokenHash, recentRecord := range h.recentSetupTokens { if now.After(recentRecord.ExpiresAt) { delete(h.recentSetupTokens, tokenHash) } } h.codeMutex.Unlock() } } func recentSetupTokenGraceExpiry(record *SetupTokenRecord, now time.Time) time.Time { graceExpiry := now.Add(1 * time.Minute) if record != nil && !record.ExpiresAt.IsZero() && record.ExpiresAt.Before(graceExpiry) { graceExpiry = record.ExpiresAt } return graceExpiry } func buildRecentSetupTokenRecord(record *SetupTokenRecord, expiresAt time.Time) RecentSetupTokenRecord { recentRecord := RecentSetupTokenRecord{ ExpiresAt: expiresAt, } if record == nil { return recentRecord } recentRecord.NodeType = strings.TrimSpace(record.NodeType) recentRecord.Host = strings.TrimSpace(record.Host) recentRecord.OrgID = strings.TrimSpace(record.OrgID) return recentRecord } func normalizeSetupTokenOrgID(orgID string) string { trimmed := strings.TrimSpace(orgID) if trimmed == "" { return "default" } return trimmed } // 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 tokenRecord, exists := h.setupTokens[tokenHash]; exists { if !tokenRecord.Used && now.Before(tokenRecord.ExpiresAt) { return true } } if recentRecord, ok := h.recentSetupTokens[tokenHash]; ok && now.Before(recentRecord.ExpiresAt) { return true } return false } // ValidateSetupTokenForOrg checks whether the provided setup token is valid for // the requested organization context. Tokens without explicit org binding are // treated as default-org tokens for backward compatibility. func (h *ConfigHandlers) ValidateSetupTokenForOrg(token, orgID string) bool { if token == "" { return false } requestOrgID := normalizeSetupTokenOrgID(orgID) tokenHash := internalauth.HashAPIToken(token) now := time.Now() h.codeMutex.RLock() defer h.codeMutex.RUnlock() if tokenRecord, exists := h.setupTokens[tokenHash]; exists { tokenOrgID := normalizeSetupTokenOrgID(tokenRecord.OrgID) if tokenRecord.Used { if recentRecord, ok := h.recentSetupTokens[tokenHash]; ok && now.Before(recentRecord.ExpiresAt) { return normalizeSetupTokenOrgID(recentRecord.OrgID) == requestOrgID } return false } if !now.Before(tokenRecord.ExpiresAt) { return false } return tokenOrgID == requestOrgID } if recentRecord, ok := h.recentSetupTokens[tokenHash]; ok && now.Before(recentRecord.ExpiresAt) { return normalizeSetupTokenOrgID(recentRecord.OrgID) == requestOrgID } return false } 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 { h.normalizePVEConfigState(ctx) 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"` 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 Enabled *bool `json:"enabled,omitempty"` // Lifecycle toggle; nil on update preserves current } // 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"` // PBS only MonitorMailStats bool `json:"monitorMailStats,omitempty"` MonitorQueues bool `json:"monitorQueues,omitempty"` MonitorQuarantine bool `json:"monitorQuarantine,omitempty"` MonitorDomainStats bool `json:"monitorDomainStats,omitempty"` Enabled bool `json:"enabled"` // Lifecycle; false = paused Status string `json:"status"` // "connected", "disconnected", "error" IsCluster bool `json:"isCluster,omitempty"` ClusterName string `json:"clusterName,omitempty"` ClusterEndpoints []ClusterEndpointResponse `json:"clusterEndpoints"` Source string `json:"source,omitempty"` // "agent" or "script" - how this node was registered } func EmptyNodeResponse() NodeResponse { return NodeResponse{}.NormalizeCollections() } func (r NodeResponse) NormalizeCollections() NodeResponse { if r.ExcludeDatastores == nil { r.ExcludeDatastores = []string{} } if r.ClusterEndpoints == nil { r.ClusterEndpoints = []ClusterEndpointResponse{} } return r } // ClusterEndpointResponse is the v6 API shape for cluster endpoint payloads. // It keeps config storage structs internal while returning canonical JSON keys. type ClusterEndpointResponse struct { NodeID string `json:"nodeId,omitempty"` NodeName string `json:"nodeName,omitempty"` Host string `json:"host,omitempty"` GuestURL string `json:"guestURL,omitempty"` IP string `json:"ip,omitempty"` IPOverride string `json:"ipOverride,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` Online bool `json:"online"` LastSeen time.Time `json:"lastSeen,omitempty"` PulseReachable *bool `json:"pulseReachable,omitempty"` LastPulseCheck *time.Time `json:"lastPulseCheck,omitempty"` PulseError string `json:"pulseError,omitempty"` } func toClusterEndpointResponse(endpoint config.ClusterEndpoint) ClusterEndpointResponse { return ClusterEndpointResponse{ NodeID: endpoint.NodeID, NodeName: endpoint.NodeName, Host: endpoint.Host, GuestURL: endpoint.GuestURL, IP: endpoint.IP, IPOverride: endpoint.IPOverride, Fingerprint: endpoint.Fingerprint, Online: endpoint.Online, LastSeen: endpoint.LastSeen, PulseReachable: endpoint.PulseReachable, LastPulseCheck: endpoint.LastPulseCheck, PulseError: endpoint.PulseError, } } func toClusterEndpointResponses(endpoints []config.ClusterEndpoint) []ClusterEndpointResponse { if len(endpoints) == 0 { return []ClusterEndpointResponse{} } out := make([]ClusterEndpointResponse, 0, len(endpoints)) for _, endpoint := range endpoints { out = append(out, toClusterEndpointResponse(endpoint)) } return out } 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 := fetchTLSFingerprint(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://198.51.100.5:8006" returns 198.51.100.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 } // 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 "" } for _, iface := range interfaces { // Skip inactive interfaces if iface.Active != 1 { continue } // Check IPv4 address if iface.Address != "" { ip := net.ParseIP(iface.Address) if ip != nil && ipsOnSameNetwork(ip, referenceIP) { return iface.Address } } } return "" } var ( detectPVECluster = defaultDetectPVECluster fetchTLSFingerprint = tlsutil.FetchFingerprint ) // 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.IP != "" { // Check if cluster-reported IP is already on the same network as our connection clusterIP := net.ParseIP(clusterNode.IP) if clusterIP != nil && !ipsOnSameNetwork(clusterIP, connectionIP) { // Cluster IP is on a different network, try to find one on the same network 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.IP != "" && clusterNode.Name != "" { clusterIP := net.ParseIP(clusterNode.IP) if clusterIP != nil && !ipsOnSameNetwork(clusterIP, connectionIP) { // Cluster IP is on a different network, try to find one on the same network 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, Enabled: !pve.Disabled, Status: h.getNodeStatus(ctx, "pve", pve.Name), IsCluster: pve.IsCluster, ClusterName: pve.ClusterName, ClusterEndpoints: toClusterEndpointResponses(pve.ClusterEndpoints), Source: pve.Source, }.NormalizeCollections() 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, Enabled: !pbs.Disabled, Status: h.getNodeStatus(ctx, "pbs", pbs.Name), Source: pbs.Source, }.NormalizeCollections() 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, Enabled: !pmgInst.Disabled, Status: h.getNodeStatus(ctx, "pmg", pmgInst.Name), }.NormalizeCollections() nodes = append(nodes, node) } return nodes } // HandleGetNodes returns all configured nodes func (h *ConfigHandlers) HandleGetNodes(w http.ResponseWriter, r *http.Request) { h.handleGetNodes(w, r) } 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. 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] } // hostsShareResolvedIdentity reports whether two node hosts resolve to the // same concrete IP identity, even when one side is a hostname URL and the // other is stored as an IP URL. func hostsShareResolvedIdentity(existingHost, candidateHost string) bool { if strings.TrimSpace(existingHost) == "" || strings.TrimSpace(candidateHost) == "" { return false } existingIP := extractHostIP(existingHost) if existingIP == "" { existingIP = resolveHostnameToIP(existingHost) } candidateIP := extractHostIP(candidateHost) if candidateIP == "" { candidateIP = resolveHostnameToIP(candidateHost) } if existingIP == "" || candidateIP == "" { return false } if existingIP == candidateIP { return true } existingParsed := net.ParseIP(existingIP) candidateParsed := net.ParseIP(candidateIP) return existingParsed != nil && candidateParsed != nil && existingParsed.IsLoopback() && candidateParsed.IsLoopback() } // 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) { h.handleAddNode(w, r) } // HandleTestConnection tests a node connection without saving func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Request) { h.handleTestConnection(w, r) } // HandleUpdateNode updates an existing node func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request) { h.handleUpdateNode(w, r) } // HandleDeleteNode deletes a node func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request) { h.handleDeleteNode(w, r) } // 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) { h.handleRefreshClusterNodes(w, r) } // HandleTestNodeConfig tests a node connection from provided configuration func (h *ConfigHandlers) HandleTestNodeConfig(w http.ResponseWriter, r *http.Request) { h.handleTestNodeConfig(w, r) } // HandleTestNode tests a node connection func (h *ConfigHandlers) HandleTestNode(w http.ResponseWriter, r *http.Request) { h.handleTestNode(w, r) } 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) { h.handleGetSystemSettings(w, r) } // HandleVerifyTemperatureSSH tests SSH connectivity to nodes for temperature monitoring func (h *ConfigHandlers) HandleVerifyTemperatureSSH(w http.ResponseWriter, r *http.Request) { h.handleVerifyTemperatureSSH(w, r) } // generateNodeID creates a unique ID for a node func generateNodeID(nodeType string, index int) string { return fmt.Sprintf("%s-%d", nodeType, index) } // HandleExportConfig exports all configuration with encryption func (h *ConfigHandlers) HandleExportConfig(w http.ResponseWriter, r *http.Request) { h.handleExportConfig(w, r) } // HandleImportConfig imports configuration from encrypted export func (h *ConfigHandlers) HandleImportConfig(w http.ResponseWriter, r *http.Request) { h.handleImportConfig(w, r) } // HandleDiscoverServers handles network discovery of Proxmox/PBS servers func (h *ConfigHandlers) HandleDiscoverServers(w http.ResponseWriter, r *http.Request) { h.handleDiscoverServers(w, r) } // HandleSetupScript serves the setup script for Proxmox/PBS nodes. func (h *ConfigHandlers) HandleSetupScript(w http.ResponseWriter, r *http.Request) { h.handleSetupScript(w, r) } // HandleSetupScriptURL generates a one-time setup token and URL for the setup script. func (h *ConfigHandlers) HandleSetupScriptURL(w http.ResponseWriter, r *http.Request) { h.handleSetupScriptURL(w, r) } // HandleGetMockMode returns the current mock mode state and configuration. func (h *ConfigHandlers) HandleGetMockMode(w http.ResponseWriter, r *http.Request) { h.handleGetMockMode(w, r) } // HandleUpdateMockMode updates mock mode and optionally its configuration. func (h *ConfigHandlers) HandleUpdateMockMode(w http.ResponseWriter, r *http.Request) { h.handleUpdateMockMode(w, r) } // HandleAutoRegister receives token details from the setup script and auto-configures the node. func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Request) { h.handleAutoRegister(w, r) } // HandleAutoUnregister receives teardown requests from the setup script and removes matching nodes. func (h *ConfigHandlers) HandleAutoUnregister(w http.ResponseWriter, r *http.Request) { h.handleAutoUnregister(w, r) } // HandleAgentInstallCommand generates an API token and install command for agent-based Proxmox setup. func (h *ConfigHandlers) HandleAgentInstallCommand(w http.ResponseWriter, r *http.Request) { h.handleAgentInstallCommand(w, r) }