Files
felhom-agent/internal/proxmox/client.go
T
admin ede49b610d
gates / gates (push) Successful in 7s
R-344: restore the idle-connection timeout our hand-rolled transports lost
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.
2026-08-20 11:09:39 +02:00

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
}