diff --git a/internal/config/client_helpers.go b/internal/config/client_helpers.go index d0f9ddb..95dec62 100644 --- a/internal/config/client_helpers.go +++ b/internal/config/client_helpers.go @@ -15,6 +15,15 @@ const ( defaultPBSPort = "8007" ) +const ( + // DefaultPVEPort is the standard API port for Proxmox VE. + DefaultPVEPort = defaultPVEPort + // DefaultPBSPort is the standard API port for Proxmox Backup Server. + DefaultPBSPort = defaultPBSPort + // DefaultPMGPort reuses the PVE API port. + DefaultPMGPort = defaultPVEPort +) + // normalizeHostPort ensures we always have a scheme and explicit port when talking to // Proxmox APIs. It preserves existing ports/schemes and strips any path/query segments. func normalizeHostPort(host, defaultPort string) string { @@ -48,13 +57,28 @@ func normalizeHostPort(host, defaultPort string) string { // CreateProxmoxConfig creates a proxmox.ClientConfig from a PVEInstance func CreateProxmoxConfig(node *PVEInstance) proxmox.ClientConfig { + return createProxmoxConfigWithHost(node, node.Host, true) +} + +// CreateProxmoxConfigWithHost builds a proxmox.ClientConfig using an explicit host. +// When normalizeHost is true, the host will be normalized (scheme + default port); +// when false, the host is used verbatim (useful for fallback attempts). +func CreateProxmoxConfigWithHost(node *PVEInstance, host string, normalizeHost bool) proxmox.ClientConfig { + return createProxmoxConfigWithHost(node, host, normalizeHost) +} + +func createProxmoxConfigWithHost(node *PVEInstance, host string, normalizeHost bool) proxmox.ClientConfig { user := node.User if node.TokenName == "" && node.TokenValue == "" && user != "" && !strings.Contains(user, "@") { user = user + "@pam" } + if normalizeHost { + host = normalizeHostPort(host, defaultPVEPort) + } + return proxmox.ClientConfig{ - Host: normalizeHostPort(node.Host, defaultPVEPort), + Host: host, User: user, Password: node.Password, TokenName: node.TokenName, @@ -132,3 +156,32 @@ func CreatePMGConfigFromFields(host, user, password, tokenName, tokenValue, fing Fingerprint: fingerprint, } } + +// StripDefaultPort removes the supplied defaultPort from a host URL if it matches. +// Useful for retrying portless endpoints (e.g., reverse proxies on 443) when the +// normalized :8006/:8007 host is unreachable. +func StripDefaultPort(host, defaultPort string) string { + trimmed := strings.TrimSpace(host) + if trimmed == "" || defaultPort == "" { + return trimmed + } + + candidate := trimmed + if !strings.HasPrefix(candidate, "http://") && !strings.HasPrefix(candidate, "https://") { + candidate = "https://" + candidate + } + + parsed, err := url.Parse(candidate) + if err != nil || parsed.Host == "" { + return trimmed + } + + if parsed.Port() != defaultPort { + return trimmed + } + + parsed.Host = parsed.Hostname() + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.Scheme + "://" + parsed.Host + strings.TrimSuffix(parsed.Path, "/") +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 1d82c2f..c0cd442 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -5478,6 +5478,65 @@ func isTransientError(err error) bool { return false } +func shouldTryPortlessFallback(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "connection refused") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "client.timeout exceeded") || + strings.Contains(msg, "i/o timeout") || + strings.Contains(msg, "context deadline exceeded") { + return true + } + return false +} + +// retryPVEPortFallback handles the case where a normalized :8006 host is unreachable +// because the actual endpoint is fronted by a reverse proxy on 443. If the initial +// GetNodes call fails with a connection error and the host has the default PVE port, +// retry without the default port to hit the proxy. On success, swap the client so +// subsequent polls reuse the working endpoint. +func (m *Monitor) retryPVEPortFallback(ctx context.Context, instanceName string, instanceCfg *config.PVEInstance, currentClient PVEClientInterface, cause error) ([]proxmox.Node, PVEClientInterface, error) { + if instanceCfg == nil || !shouldTryPortlessFallback(cause) { + return nil, currentClient, cause + } + + fallbackHost := config.StripDefaultPort(instanceCfg.Host, config.DefaultPVEPort) + if fallbackHost == "" || fallbackHost == instanceCfg.Host { + return nil, currentClient, cause + } + + clientCfg := config.CreateProxmoxConfigWithHost(instanceCfg, fallbackHost, false) + if clientCfg.Timeout <= 0 { + clientCfg.Timeout = m.config.ConnectionTimeout + } + + fallbackClient, err := proxmox.NewClient(clientCfg) + if err != nil { + return nil, currentClient, cause + } + + fallbackNodes, err := fallbackClient.GetNodes(ctx) + if err != nil { + return nil, currentClient, cause + } + + // Switch to the working host for the remainder of the poll (and future polls) + primaryHost := instanceCfg.Host + instanceCfg.Host = fallbackHost + m.pveClients[instanceName] = fallbackClient + log.Warn(). + Str("instance", instanceName). + Str("primary", primaryHost). + Str("fallback", fallbackHost). + Msg("Primary PVE host failed; using fallback without default port") + + return fallbackNodes, fallbackClient, nil +} + // pollPVEInstance polls a single PVE instance func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, client PVEClientInterface) { defer recoverFromPanic(fmt.Sprintf("pollPVEInstance-%s", instanceName)) @@ -5541,16 +5600,21 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie // Poll nodes nodes, err := client.GetNodes(ctx) if err != nil { - monErr := errors.WrapConnectionError("poll_nodes", instanceName, err) - pollErr = monErr - log.Error().Err(monErr).Str("instance", instanceName).Msg("Failed to get nodes") - m.state.SetConnectionHealth(instanceName, false) + if fallbackNodes, fallbackClient, fallbackErr := m.retryPVEPortFallback(ctx, instanceName, instanceCfg, client, err); fallbackErr == nil { + client = fallbackClient + nodes = fallbackNodes + } else { + monErr := errors.WrapConnectionError("poll_nodes", instanceName, err) + pollErr = monErr + log.Error().Err(monErr).Str("instance", instanceName).Msg("Failed to get nodes") + m.state.SetConnectionHealth(instanceName, false) - // Track auth failure if it's an authentication error - if errors.IsAuthError(err) { - m.recordAuthFailure(instanceName, "pve") + // Track auth failure if it's an authentication error + if errors.IsAuthError(err) { + m.recordAuthFailure(instanceName, "pve") + } + return } - return } // Reset auth failures on successful connection