/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ package api import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/google/go-querystring/query" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" "github.com/bpg/terraform-provider-proxmox/utils" ) // ErrNoDataObjectInResponse is returned when the server does not include a data object in the response. var ErrNoDataObjectInResponse = errors.New("the server did not include a data object in the response") const ( // the large timeout is to allow for large file uploads. httpClientTimeout = 5 * time.Minute basePathJSONAPI = "api2/json" ) // Client is an interface for performing requests against the Proxmox API. type Client interface { // DoRequest performs a request against the Proxmox API. DoRequest( ctx context.Context, method, path string, requestBody, responseBody interface{}, ) error // ExpandPath expands a path relative to the client's base path. // For example, if the client is configured for a VM and the // path is "firewall/options", the returned path will be // "/nodes//qemu//firewall/options". ExpandPath(path string) string // IsRoot returns true if the client is configured with the root user. IsRoot() bool // IsRootTicket returns true if the authenticator is configured to use the root directly using a login ticket. // (root using token is weaker, cannot change VM arch) IsRootTicket() bool } // Connection represents a connection to the Proxmox Virtual Environment API. type Connection struct { endpoint string httpClient *http.Client } // NewConnection creates and initializes a Connection instance. func NewConnection(endpoint string, insecure bool) (*Connection, error) { u, err := url.ParseRequestURI(endpoint) if err != nil { return nil, errors.New( "you must specify a valid endpoint for the Proxmox Virtual Environment API (valid: https://host:port/)", ) } if u.Scheme != "https" { return nil, errors.New( "you must specify a secure endpoint for the Proxmox Virtual Environment API (valid: https://host:port/)", ) } var transport http.RoundTripper = &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: insecure, //nolint:gosec }, } if logging.IsDebugOrHigher() { transport = logging.NewLoggingHTTPTransport(transport) } return &Connection{ endpoint: strings.TrimRight(u.String(), "/"), httpClient: &http.Client{ Transport: transport, Timeout: httpClientTimeout, }, }, nil } // VirtualEnvironmentClient implements an API client for the Proxmox Virtual Environment API. type client struct { conn *Connection auth Authenticator } // NewClient creates and initializes a VirtualEnvironmentClient instance. func NewClient(creds *Credentials, conn *Connection) (Client, error) { if creds == nil { return nil, errors.New("credentials must not be nil") } if conn == nil { return nil, errors.New("connection must not be nil") } var auth Authenticator var err error if creds.APIToken != nil { auth, err = NewTokenAuthenticator(*creds.APIToken) } else { auth, err = NewTicketAuthenticator(conn, creds) } if err != nil { return nil, err } return &client{ conn: conn, auth: auth, }, nil } // DoRequest performs a HTTP request against a JSON API endpoint. func (c *client) DoRequest( ctx context.Context, method, path string, requestBody, responseBody interface{}, ) error { var reqBodyReader io.Reader var reqContentLength *int64 modifiedPath := path reqBodyType := "" //nolint:nestif if requestBody != nil { multipartData, multipart := requestBody.(*MultiPartData) pipedBodyReader, pipedBody := requestBody.(*io.PipeReader) switch { case multipart: reqBodyReader = multipartData.Reader reqBodyType = fmt.Sprintf("multipart/form-data; boundary=%s", multipartData.Boundary) reqContentLength = multipartData.Size case pipedBody: reqBodyReader = pipedBodyReader default: v, err := query.Values(requestBody) if err != nil { return fmt.Errorf("failed to encode HTTP %s request (path: %s) - Reason: %w", method, modifiedPath, err, ) } encodedValues := v.Encode() if encodedValues != "" { if method == http.MethodDelete || method == http.MethodGet || method == http.MethodHead { if !strings.Contains(modifiedPath, "?") { modifiedPath = fmt.Sprintf("%s?%s", modifiedPath, encodedValues) } else { modifiedPath = fmt.Sprintf("%s&%s", modifiedPath, encodedValues) } } else { reqBodyReader = bytes.NewBufferString(encodedValues) reqBodyType = "application/x-www-form-urlencoded" } } } } else { reqBodyReader = new(bytes.Buffer) } req, err := http.NewRequestWithContext( ctx, method, fmt.Sprintf("%s/%s/%s", c.conn.endpoint, basePathJSONAPI, modifiedPath), reqBodyReader, ) if err != nil { return fmt.Errorf( "failed to create HTTP %s request (path: %s) - Reason: %w", method, modifiedPath, err, ) } req.Header.Add("Accept", "application/json") if reqContentLength != nil { req.ContentLength = *reqContentLength } if reqBodyType != "" { req.Header.Add("Content-Type", reqBodyType) } err = c.auth.AuthenticateRequest(ctx, req) if err != nil { return fmt.Errorf("failed to authenticate HTTP %s request (path: %s) - Reason: %w", method, modifiedPath, err, ) } //nolint:bodyclose res, err := c.conn.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to perform HTTP %s request (path: %s) - Reason: %w", method, modifiedPath, err, ) } defer utils.CloseOrLogError(ctx)(res.Body) err = validateResponseCode(res) if err != nil { return err } //nolint:nestif if responseBody != nil { err = json.NewDecoder(res.Body).Decode(responseBody) if err != nil { return fmt.Errorf( "failed to decode HTTP %s response (path: %s) - Reason: %w", method, modifiedPath, err, ) } } else { data, err := io.ReadAll(res.Body) if err != nil { return fmt.Errorf( "failed to read HTTP %s response body (path: %s) - Reason: %w", method, modifiedPath, err, ) } if len(data) > 0 { dr := dataResponse{} if err2 := json.NewDecoder(bytes.NewReader(data)).Decode(&dr); err2 == nil { if dr.Data == nil { return nil } } tflog.Warn(ctx, "unhandled HTTP response body", map[string]interface{}{ "data": dr.Data, }) } } return nil } type dataResponse struct { Data interface{} `json:"data"` } // ExpandPath expands the given path to an absolute path. func (c *client) ExpandPath(path string) string { return path } func (c *client) IsRoot() bool { return c.auth.IsRoot() } func (c *client) IsRootTicket() bool { return c.auth.IsRootTicket() } // validateResponseCode ensures that a response is valid. func validateResponseCode(res *http.Response) error { if res.StatusCode < 200 || res.StatusCode >= 300 { status := strings.TrimPrefix(res.Status, fmt.Sprintf("%d ", res.StatusCode)) errRes := &ErrorResponseBody{} err := json.NewDecoder(res.Body).Decode(errRes) if err == nil && errRes.Errors != nil { var errList []string for k, v := range *errRes.Errors { errList = append(errList, fmt.Sprintf("%s: %s", k, strings.TrimRight(v, "\n\r"))) } status = fmt.Sprintf("%s (%s)", status, strings.Join(errList, " - ")) } return fmt.Errorf("received an HTTP %d response - Reason: %s", res.StatusCode, status) } return nil }