// // Copyright 2019 Joyent, Inc. // // 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 http://mozilla.org/MPL/2.0/. // package client import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "regexp" "strings" "time" triton "github.com/joyent/triton-go" "github.com/joyent/triton-go/authentication" "github.com/joyent/triton-go/errors" pkgerrors "github.com/pkg/errors" ) var ( ErrDefaultAuth = pkgerrors.New("default SSH agent authentication requires SDC_KEY_ID / TRITON_KEY_ID and SSH_AUTH_SOCK") ErrAccountName = pkgerrors.New("missing account name") ErrMissingURL = pkgerrors.New("missing API URL") InvalidTritonURL = "invalid format of Triton URL" InvalidMantaURL = "invalid format of Manta URL" InvalidServicesURL = "invalid format of Triton Service Groups URL" InvalidDCInURL = "invalid data center in URL" knownDCFormats = []string{ `https?://(.*).api.joyent.com`, `https?://(.*).api.joyentcloud.com`, `https?://(.*).api.samsungcloud.io`, } jpcFormatURL = "https://tsg.%s.svc.joyent.zone" spcFormatURL = "https://tsg.%s.svc.samsungcloud.zone" ) // Client represents a connection to the Triton Compute or Object Storage APIs. type Client struct { HTTPClient *http.Client RequestHeader *http.Header Authorizers []authentication.Signer TritonURL url.URL MantaURL url.URL ServicesURL url.URL AccountName string Username string } func isPrivateInstall(url string) bool { for _, pattern := range knownDCFormats { re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(url) if len(matches) > 1 { return false } } return true } // parseDC parses out the data center commonly found in Triton URLs. Returns an // error if the Triton URL does not include a known data center name, in which // case a URL override (TRITON_TSG_URL) must be provided. func parseDC(url string) (string, bool, error) { isSamsung := false if strings.Contains(url, "samsung") { isSamsung = true } for _, pattern := range knownDCFormats { re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(url) if len(matches) > 1 { return matches[1], isSamsung, nil } } return "", isSamsung, fmt.Errorf("failed to parse datacenter from '%s'", url) } // New is used to construct a Client in order to make API // requests to the Triton API. // // At least one signer must be provided - example signers include // authentication.PrivateKeySigner and authentication.SSHAgentSigner. func New(tritonURL string, mantaURL string, accountName string, signers ...authentication.Signer) (*Client, error) { if accountName == "" { return nil, ErrAccountName } if tritonURL == "" && mantaURL == "" { return nil, ErrMissingURL } cloudURL, err := url.Parse(tritonURL) if err != nil { return nil, pkgerrors.Wrapf(err, InvalidTritonURL) } storageURL, err := url.Parse(mantaURL) if err != nil { return nil, pkgerrors.Wrapf(err, InvalidMantaURL) } // Generate the Services URL (TSG) based on the current datacenter used in // the Triton URL (if TritonURL is available). If TRITON_TSG_URL environment // variable is available than override using that value instead. tsgURL := triton.GetEnv("TSG_URL") if tsgURL == "" && tritonURL != "" && !isPrivateInstall(tritonURL) { currentDC, isSamsung, err := parseDC(tritonURL) if err != nil { return nil, pkgerrors.Wrapf(err, InvalidDCInURL) } tsgURL = fmt.Sprintf(jpcFormatURL, currentDC) if isSamsung { tsgURL = fmt.Sprintf(spcFormatURL, currentDC) } } servicesURL, err := url.Parse(tsgURL) if err != nil { return nil, pkgerrors.Wrapf(err, InvalidServicesURL) } authorizers := make([]authentication.Signer, 0) for _, key := range signers { if key != nil { authorizers = append(authorizers, key) } } newClient := &Client{ HTTPClient: &http.Client{ Transport: httpTransport(false), CheckRedirect: doNotFollowRedirects, }, Authorizers: authorizers, TritonURL: *cloudURL, MantaURL: *storageURL, ServicesURL: *servicesURL, AccountName: accountName, } // Default to constructing an SSHAgentSigner if there are no other signers // passed into NewClient and there's an TRITON_KEY_ID and SSH_AUTH_SOCK // available in the user's environ(7). if len(newClient.Authorizers) == 0 { if err := newClient.DefaultAuth(); err != nil { return nil, err } } return newClient, nil } // initDefaultAuth provides a default key signer for a client. This should only // be used internally if the client has no other key signer for authenticating // with Triton. We first look for both `SDC_KEY_ID` and `SSH_AUTH_SOCK` in the // user's environ(7). If so we default to the SSH agent key signer. func (c *Client) DefaultAuth() error { tritonKeyId := triton.GetEnv("KEY_ID") if tritonKeyId != "" { input := authentication.SSHAgentSignerInput{ KeyID: tritonKeyId, AccountName: c.AccountName, Username: c.Username, } defaultSigner, err := authentication.NewSSHAgentSigner(input) if err != nil { return pkgerrors.Wrapf(err, "unable to initialize NewSSHAgentSigner") } c.Authorizers = append(c.Authorizers, defaultSigner) } return ErrDefaultAuth } // InsecureSkipTLSVerify turns off TLS verification for the client connection. This // allows connection to an endpoint with a certificate which was signed by a non- // trusted CA, such as self-signed certificates. This can be useful when connecting // to temporary Triton installations such as Triton Cloud-On-A-Laptop. func (c *Client) InsecureSkipTLSVerify() { if c.HTTPClient == nil { return } c.HTTPClient.Transport = httpTransport(true) } // httpTransport is responsible for setting up our HTTP client's transport // settings func httpTransport(insecureSkipTLSVerify bool) *http.Transport { return &http.Transport{ Proxy: http.ProxyFromEnvironment, Dial: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, }).Dial, TLSHandshakeTimeout: 10 * time.Second, MaxIdleConns: 10, IdleConnTimeout: 15 * time.Second, TLSClientConfig: &tls.Config{ InsecureSkipVerify: insecureSkipTLSVerify, }, } } func doNotFollowRedirects(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } // DecodeError decodes a backend Triton error into a more usable Go error type func (c *Client) DecodeError(resp *http.Response, requestMethod string, consumeBody bool) error { err := &errors.APIError{ StatusCode: resp.StatusCode, } if requestMethod != http.MethodHead && resp.Body != nil && consumeBody { errorDecoder := json.NewDecoder(resp.Body) if err := errorDecoder.Decode(err); err != nil { return pkgerrors.Wrapf(err, "unable to decode error response") } } if err.Message == "" { err.Message = fmt.Sprintf("HTTP response returned status code %d", err.StatusCode) } return err } // overrideHeader overrides the header of the passed in HTTP request func (c *Client) overrideHeader(req *http.Request) { if c.RequestHeader != nil { for k := range *c.RequestHeader { req.Header.Set(k, c.RequestHeader.Get(k)) } } } // resetHeader will reset the struct field that stores custom header // information func (c *Client) resetHeader() { c.RequestHeader = nil } // ----------------------------------------------------------------------------- type RequestInput struct { Method string Path string Query *url.Values Headers *http.Header Body interface{} // If the response has the HTTP status code 410 (i.e., "Gone"), should we preserve the contents of the body for the caller? PreserveGone bool } func (c *Client) ExecuteRequestURIParams(ctx context.Context, inputs RequestInput) (io.ReadCloser, error) { defer c.resetHeader() method := inputs.Method path := inputs.Path body := inputs.Body query := inputs.Query var requestBody io.Reader if body != nil { marshaled, err := json.MarshalIndent(body, "", " ") if err != nil { return nil, err } requestBody = bytes.NewReader(marshaled) } endpoint := c.TritonURL endpoint.Path = path if query != nil { endpoint.RawQuery = query.Encode() } req, err := http.NewRequest(method, endpoint.String(), requestBody) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to construct HTTP request") } dateHeader := time.Now().UTC().Format(time.RFC1123) req.Header.Set("date", dateHeader) // NewClient ensures there's always an authorizer (unless this is called // outside that constructor). authHeader, err := c.Authorizers[0].Sign(dateHeader, false) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to sign HTTP request") } req.Header.Set("Authorization", authHeader) req.Header.Set("Accept", "application/json") req.Header.Set("Accept-Version", triton.CloudAPIMajorVersion) req.Header.Set("User-Agent", triton.UserAgent()) if body != nil { req.Header.Set("Content-Type", "application/json") } c.overrideHeader(req) resp, err := c.HTTPClient.Do(req.WithContext(ctx)) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to execute HTTP request") } // We will only return a response from the API it is in the HTTP StatusCode // 2xx range // StatusMultipleChoices is StatusCode 300 if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return resp.Body, nil } return nil, c.DecodeError(resp, req.Method, true) } func (c *Client) ExecuteRequest(ctx context.Context, inputs RequestInput) (io.ReadCloser, error) { return c.ExecuteRequestURIParams(ctx, inputs) } func (c *Client) ExecuteRequestRaw(ctx context.Context, inputs RequestInput) (*http.Response, error) { defer c.resetHeader() method := inputs.Method path := inputs.Path body := inputs.Body query := inputs.Query var requestBody io.Reader if body != nil { marshaled, err := json.MarshalIndent(body, "", " ") if err != nil { return nil, err } requestBody = bytes.NewReader(marshaled) } endpoint := c.TritonURL endpoint.Path = path if query != nil { endpoint.RawQuery = query.Encode() } req, err := http.NewRequest(method, endpoint.String(), requestBody) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to construct HTTP request") } dateHeader := time.Now().UTC().Format(time.RFC1123) req.Header.Set("date", dateHeader) // NewClient ensures there's always an authorizer (unless this is called // outside that constructor). authHeader, err := c.Authorizers[0].Sign(dateHeader, false) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to sign HTTP request") } req.Header.Set("Authorization", authHeader) req.Header.Set("Accept", "application/json") req.Header.Set("Accept-Version", triton.CloudAPIMajorVersion) req.Header.Set("User-Agent", triton.UserAgent()) if body != nil { req.Header.Set("Content-Type", "application/json") } c.overrideHeader(req) resp, err := c.HTTPClient.Do(req.WithContext(ctx)) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to execute HTTP request") } // We will only return a response from the API it is in the HTTP StatusCode // 2xx range // StatusMultipleChoices is StatusCode 300 if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return resp, nil } // GetMachine returns a HTTP 410 response for deleted instances, but the body of the response is still a valid machine object with a State value of "deleted". Return the object to the caller as well as an error. if inputs.PreserveGone && resp.StatusCode == http.StatusGone { // Do not consume the response body. return resp, c.DecodeError(resp, req.Method, false) } return nil, c.DecodeError(resp, req.Method, true) } func (c *Client) ExecuteRequestStorage(ctx context.Context, inputs RequestInput) (io.ReadCloser, http.Header, error) { defer c.resetHeader() method := inputs.Method path := inputs.Path query := inputs.Query headers := inputs.Headers body := inputs.Body endpoint := c.MantaURL endpoint.Path = path var requestBody io.Reader if body != nil { marshaled, err := json.MarshalIndent(body, "", " ") if err != nil { return nil, nil, err } requestBody = bytes.NewReader(marshaled) } req, err := http.NewRequest(method, endpoint.String(), requestBody) if err != nil { return nil, nil, pkgerrors.Wrapf(err, "unable to construct HTTP request") } if body != nil && (headers == nil || headers.Get("Content-Type") == "") { req.Header.Set("Content-Type", "application/json") } if headers != nil { for key, values := range *headers { for _, value := range values { req.Header.Set(key, value) } } } dateHeader := time.Now().UTC().Format(time.RFC1123) req.Header.Set("date", dateHeader) authHeader, err := c.Authorizers[0].Sign(dateHeader, true) if err != nil { return nil, nil, pkgerrors.Wrapf(err, "unable to sign HTTP request") } req.Header.Set("Authorization", authHeader) req.Header.Set("Accept", "*/*") req.Header.Set("User-Agent", triton.UserAgent()) if query != nil { req.URL.RawQuery = query.Encode() } c.overrideHeader(req) resp, err := c.HTTPClient.Do(req.WithContext(ctx)) if err != nil { return nil, nil, pkgerrors.Wrapf(err, "unable to execute HTTP request") } // We will only return a response from the API it is in the HTTP StatusCode // 2xx range // StatusMultipleChoices is StatusCode 300 if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return resp.Body, resp.Header, nil } return nil, nil, c.DecodeError(resp, req.Method, true) } type RequestNoEncodeInput struct { Method string Path string Query *url.Values Headers *http.Header Body io.Reader } func (c *Client) ExecuteRequestNoEncode(ctx context.Context, inputs RequestNoEncodeInput) (io.ReadCloser, http.Header, error) { defer c.resetHeader() method := inputs.Method path := inputs.Path query := inputs.Query headers := inputs.Headers body := inputs.Body endpoint := c.MantaURL endpoint.Path = path req, err := http.NewRequest(method, endpoint.String(), body) if err != nil { return nil, nil, pkgerrors.Wrapf(err, "unable to construct HTTP request") } if headers != nil { for key, values := range *headers { for _, value := range values { req.Header.Set(key, value) } } } dateHeader := time.Now().UTC().Format(time.RFC1123) req.Header.Set("date", dateHeader) authHeader, err := c.Authorizers[0].Sign(dateHeader, true) if err != nil { return nil, nil, pkgerrors.Wrapf(err, "unable to sign HTTP request") } req.Header.Set("Authorization", authHeader) req.Header.Set("Accept", "*/*") req.Header.Set("Accept-Version", triton.CloudAPIMajorVersion) req.Header.Set("User-Agent", triton.UserAgent()) if query != nil { req.URL.RawQuery = query.Encode() } c.overrideHeader(req) resp, err := c.HTTPClient.Do(req.WithContext(ctx)) if err != nil { return nil, nil, pkgerrors.Wrapf(err, "unable to execute HTTP request") } // We will only return a response from the API it is in the HTTP StatusCode // 2xx range // StatusMultipleChoices is StatusCode 300 if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return resp.Body, resp.Header, nil } return nil, nil, c.DecodeError(resp, req.Method, true) } func (c *Client) ExecuteRequestTSG(ctx context.Context, inputs RequestInput) (io.ReadCloser, error) { defer c.resetHeader() method := inputs.Method path := inputs.Path body := inputs.Body query := inputs.Query var requestBody io.Reader if body != nil { marshaled, err := json.MarshalIndent(body, "", " ") if err != nil { return nil, err } requestBody = bytes.NewReader(marshaled) } endpoint := c.ServicesURL endpoint.Path = path if query != nil { endpoint.RawQuery = query.Encode() } req, err := http.NewRequest(method, endpoint.String(), requestBody) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to construct HTTP request") } dateHeader := time.Now().UTC().Format(time.RFC1123) req.Header.Set("date", dateHeader) // NewClient ensures there's always an authorizer (unless this is called // outside that constructor). authHeader, err := c.Authorizers[0].Sign(dateHeader, false) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to sign HTTP request") } req.Header.Set("Authorization", authHeader) req.Header.Set("Accept", "application/json") req.Header.Set("Accept-Version", triton.CloudAPIMajorVersion) req.Header.Set("User-Agent", triton.UserAgent()) if body != nil { req.Header.Set("Content-Type", "application/json") } c.overrideHeader(req) resp, err := c.HTTPClient.Do(req.WithContext(ctx)) if err != nil { return nil, pkgerrors.Wrapf(err, "unable to execute HTTP request") } if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return resp.Body, nil } return nil, fmt.Errorf("could not process backend TSG request") }