ede49b610d
gates / gates (push) Successful in 7s
Every client here pins TLS, so none can use http.DefaultTransport and each hand-rolls its own. A composite literal takes IdleConnTimeout ZERO, which means retain idle connections forever -- not "use a sane default". pbsTargetsFromPVE builds a fresh pbs.Client every cycle and drops the previous one, and an abandoned http.Transport does not close its connections. One stranded socket per cycle, on both sides, forever. Measured: 388 established connections on ep0 over 46 h, 194 per box, zero closed in a 31-minute window. pvestatd and proxmox-backup-client made 162,404 requests in the same window and leaked none. New leaf package internal/httpx owns the default (90s, http.DefaultTransport's own value) and NewTransport, which returns a FRESH transport per call and treats <=0 as "use the default", never "no timeout". pbs.Config gains IdleConnTimeout for tests only. hub and proxmox carried the same missing default and are corrected here for consistency. Neither contributed to the ep0 leak -- both are built once per process and neither talks to ep0:8007. Tests count connections SERVER-side and model the abandonment, so they pin the consequence rather than the field. Two red-proofs, both seen failing: removing the timeout -> "still holds 5 open connection(s), want 0"; DisableKeepAlives -> "3 sequential requests over 3 connection(s), want 1" (the leak test PASSES under that one -- it is the worse-fix guard that catches it). Not released: hand-installed on demo-hp only so demo-felhom stays the control. CHANGELOG heading stays UNRELEASED until the publish is authorised.
159 lines
4.9 KiB
Go
159 lines
4.9 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/httpx"
|
|
)
|
|
|
|
// doer is the minimal HTTP surface the client needs; *http.Client satisfies it.
|
|
// Tests inject a mock to exercise decoding/error paths without a live host.
|
|
type doer interface {
|
|
Do(*http.Request) (*http.Response, error)
|
|
}
|
|
|
|
// Config configures a Client (the API backend).
|
|
type Config struct {
|
|
// Endpoint is the API base, e.g. "https://127.0.0.1:8006". The "/api2/json"
|
|
// suffix is added by the client.
|
|
Endpoint string
|
|
// Node is the Proxmox node name (e.g. "demo-felhom"). Confirm on the box
|
|
// (GET /nodes), never hard-code — see proxmox-platform.md §1.
|
|
Node string
|
|
// Token is the full API token "USER@REALM!TOKENID=SECRET". Never logged.
|
|
Token string
|
|
// TLS selects how the host cert is trusted.
|
|
TLS TLSConfig
|
|
// HTTPTimeout bounds a single HTTP round-trip (not a whole task wait).
|
|
// Defaults to 30s.
|
|
HTTPTimeout time.Duration
|
|
}
|
|
|
|
// Client is the API backend: a typed REST client for one Proxmox host. It is the
|
|
// default path for everything the scoped token can do. It never shells out.
|
|
type Client struct {
|
|
base string // "<endpoint>/api2/json"
|
|
node string
|
|
token string
|
|
http doer
|
|
}
|
|
|
|
// NewClient builds an API client. It validates required config and constructs the
|
|
// TLS-pinned transport.
|
|
func NewClient(cfg Config) (*Client, error) {
|
|
if cfg.Endpoint == "" {
|
|
return nil, fmt.Errorf("proxmox: endpoint is required")
|
|
}
|
|
if cfg.Node == "" {
|
|
return nil, fmt.Errorf("proxmox: node is required")
|
|
}
|
|
if cfg.Token == "" {
|
|
return nil, fmt.Errorf("proxmox: API token is required")
|
|
}
|
|
tlsCfg, err := cfg.TLS.build()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
timeout := cfg.HTTPTimeout
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
hc := &http.Client{
|
|
Timeout: timeout,
|
|
// R-344, consistency only: built ONCE per process, so it never accumulated and contributed
|
|
// nothing to the ep0 leak. Same missing default, corrected for the same reason.
|
|
Transport: httpx.NewTransport(tlsCfg, 0),
|
|
}
|
|
return &Client{
|
|
base: strings.TrimRight(cfg.Endpoint, "/") + "/api2/json",
|
|
node: cfg.Node,
|
|
token: cfg.Token,
|
|
http: hc,
|
|
}, nil
|
|
}
|
|
|
|
// Node returns the configured node name.
|
|
func (c *Client) Node() string { return c.node }
|
|
|
|
// get performs GET <path> and decodes the {"data": ...} envelope into out.
|
|
func (c *Client) get(ctx context.Context, path string, out any) error {
|
|
return c.do(ctx, http.MethodGet, path, nil, out)
|
|
}
|
|
|
|
// postForm performs a form-encoded POST/PUT and decodes the envelope into out.
|
|
// out may be nil when the caller does not need the body.
|
|
func (c *Client) postForm(ctx context.Context, method, path string, params url.Values, out any) error {
|
|
var body io.Reader
|
|
if params != nil {
|
|
body = strings.NewReader(params.Encode())
|
|
}
|
|
return c.doBody(ctx, method, path, body, "application/x-www-form-urlencoded", out)
|
|
}
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body io.Reader, out any) error {
|
|
return c.doBody(ctx, method, path, body, "", out)
|
|
}
|
|
|
|
// doBody is the single HTTP chokepoint: builds the request, sets auth, executes,
|
|
// maps non-2xx to APIError, and decodes the data envelope.
|
|
func (c *Client) doBody(ctx context.Context, method, path string, body io.Reader, contentType string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
|
|
if err != nil {
|
|
return fmt.Errorf("proxmox: building request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "PVEAPIToken="+c.token)
|
|
req.Header.Set("Accept", "application/json")
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("proxmox: %s %s: %w", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("proxmox: reading %s %s response: %w", method, path, err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return newAPIError(resp.StatusCode, method, path, string(raw))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
var env struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return fmt.Errorf("proxmox: decoding %s %s envelope: %w", method, path, err)
|
|
}
|
|
if len(env.Data) == 0 || bytes.Equal(env.Data, []byte("null")) {
|
|
return nil // no data (e.g. a sync PUT /config)
|
|
}
|
|
if err := json.Unmarshal(env.Data, out); err != nil {
|
|
return fmt.Errorf("proxmox: decoding %s %s data: %w", method, path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dataString runs a request expecting the "data" field to be a bare string
|
|
// (the UPID returned by async mutating ops). Returns "" with no error when the
|
|
// response carries no data (some sync ops).
|
|
func (c *Client) dataString(ctx context.Context, method, path string, params url.Values) (string, error) {
|
|
var s string
|
|
if err := c.postForm(ctx, method, path, params, &s); err != nil {
|
|
return "", err
|
|
}
|
|
return s, nil
|
|
}
|