Handle PVE portless fallback when default port fails

This commit is contained in:
rcourtman 2025-11-22 17:01:16 +00:00
parent 5dbaf7c596
commit 8606bb47cf
2 changed files with 126 additions and 9 deletions

View file

@ -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, "/")
}

View file

@ -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