pve-exporter/collector/client.go
Davíð Steinn Geirsson 01dbc7cee4 Strip trailing slash from PVE host URLs
A trailing slash in --pve.host (e.g. https://host:8006/) caused API
requests to fail with status 500 due to double slashes in the path.
2026-03-23 11:34:26 +00:00

120 lines
3 KiB
Go

package collector
import (
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Client is an HTTP client for the Proxmox VE API.
// It supports multi-host failover: hosts are tried in order, and the last
// successful host is remembered for subsequent requests.
type Client struct {
hosts []string
token string
maxConc int
httpClient *http.Client
lastGoodHost int
mu sync.Mutex
}
// NewClient creates a new PVE API client.
// hosts is a list of base URLs (e.g. "https://pve1.example.com:8006").
// token is the PVE API token in the form "USER@REALM!TOKENID=SECRET".
// tlsInsecure disables TLS certificate verification when true.
// maxConcurrent controls the maximum number of concurrent API requests.
func NewClient(hosts []string, token string, tlsInsecure bool, maxConcurrent int) *Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 1 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: tlsInsecure,
},
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
}
// Normalize hosts: strip trailing slashes
for i, h := range hosts {
hosts[i] = strings.TrimRight(h, "/")
}
// Normalize token: ensure it has the PVEAPIToken= prefix
if !strings.HasPrefix(token, "PVEAPIToken=") {
token = "PVEAPIToken=" + token
}
return &Client{
hosts: hosts,
token: token,
maxConc: maxConcurrent,
httpClient: &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
},
}
}
// MaxConcurrent returns the maximum number of concurrent API requests.
func (c *Client) MaxConcurrent() int {
return c.maxConc
}
// Get performs an HTTP GET against the PVE API. The path is prepended with
// /api2/json (e.g. "/cluster/resources" becomes "/api2/json/cluster/resources").
// Hosts are tried in order starting from the last known good host. If a request
// fails or returns a non-2xx status, the next host is tried.
func (c *Client) Get(path string) ([]byte, error) {
c.mu.Lock()
startIdx := c.lastGoodHost
c.mu.Unlock()
fullPath := "/api2/json" + path
var lastErr error
for i := 0; i < len(c.hosts); i++ {
idx := (startIdx + i) % len(c.hosts)
host := c.hosts[idx]
url := host + fullPath
req, err := http.NewRequest("GET", url, nil)
if err != nil {
lastErr = fmt.Errorf("creating request for %s: %w", url, err)
continue
}
req.Header.Set("Authorization", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("request to %s: %w", host, err)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("reading response from %s: %w", host, err)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
lastErr = fmt.Errorf("request to %s returned status %d", host, resp.StatusCode)
continue
}
c.mu.Lock()
c.lastGoodHost = idx
c.mu.Unlock()
return body, nil
}
return nil, fmt.Errorf("all PVE hosts failed: %w", lastErr)
}