package proxmox import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // 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 // "/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, Transport: &http.Transport{TLSClientConfig: tlsCfg}, } 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 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 }