mirror of
https://github.com/ceph/ceph-csi.git
synced 2024-11-30 02:00:19 +00:00
511 lines
14 KiB
Go
511 lines
14 KiB
Go
|
// Copyright 2019 IBM Corp.
|
||
|
//
|
||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
// you may not use this file except in compliance with the License.
|
||
|
// You may obtain a copy of the License at
|
||
|
//
|
||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||
|
//
|
||
|
// Unless required by applicable law or agreed to in writing, software
|
||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
// See the License for the specific language governing permissions and
|
||
|
// limitations under the License.
|
||
|
|
||
|
// keyprotect-go-client is a Go client library for interacting with the IBM KeyProtect service.
|
||
|
package kp
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"context"
|
||
|
"crypto/tls"
|
||
|
"encoding/json"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"io/ioutil"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"github.com/google/uuid"
|
||
|
rhttp "github.com/hashicorp/go-retryablehttp"
|
||
|
|
||
|
"github.com/IBM/keyprotect-go-client/iam"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
// DefaultBaseURL ...
|
||
|
DefaultBaseURL = "https://us-south.kms.cloud.ibm.com"
|
||
|
// DefaultTokenURL ..
|
||
|
DefaultTokenURL = iam.IAMTokenURL
|
||
|
|
||
|
// VerboseNone ...
|
||
|
VerboseNone = 0
|
||
|
// VerboseBodyOnly ...
|
||
|
VerboseBodyOnly = 1
|
||
|
// VerboseAll ...
|
||
|
VerboseAll = 2
|
||
|
// VerboseFailOnly ...
|
||
|
VerboseFailOnly = 3
|
||
|
// VerboseAllNoRedact ...
|
||
|
VerboseAllNoRedact = 4
|
||
|
|
||
|
authContextKey ContextKey = 0
|
||
|
defaultTimeout = 30 // in seconds.
|
||
|
)
|
||
|
|
||
|
var (
|
||
|
// RetryWaitMax is the maximum time to wait between HTTP retries
|
||
|
RetryWaitMax = 30 * time.Second
|
||
|
|
||
|
// RetryMax is the max number of attempts to retry for failed HTTP requests
|
||
|
RetryMax = 4
|
||
|
|
||
|
cidCtxKey = ctxKey("X-Correlation-Id")
|
||
|
)
|
||
|
|
||
|
type ctxKey string
|
||
|
|
||
|
// ClientConfig ...
|
||
|
type ClientConfig struct {
|
||
|
BaseURL string
|
||
|
Authorization string // The IBM Cloud (Bluemix) access token
|
||
|
APIKey string // Service ID API key, can be used instead of an access token
|
||
|
TokenURL string // The URL used to get an access token from the API key
|
||
|
InstanceID string // The IBM Cloud (Bluemix) instance ID that identifies your Key Protect service instance.
|
||
|
KeyRing string // The ID of the target Key Ring the key is associated with. It is optional but recommended for better performance.
|
||
|
Verbose int // See verbose values above
|
||
|
Timeout float64 // KP request timeout in seconds.
|
||
|
}
|
||
|
|
||
|
// DefaultTransport ...
|
||
|
func DefaultTransport() http.RoundTripper {
|
||
|
transport := &http.Transport{
|
||
|
DisableKeepAlives: true,
|
||
|
MaxIdleConnsPerHost: -1,
|
||
|
TLSClientConfig: &tls.Config{
|
||
|
InsecureSkipVerify: false,
|
||
|
},
|
||
|
}
|
||
|
return transport
|
||
|
}
|
||
|
|
||
|
// API is deprecated. Use Client instead.
|
||
|
type API = Client
|
||
|
|
||
|
// Client holds configuration and auth information to interact with KeyProtect.
|
||
|
// It is expected that one of these is created per KeyProtect service instance/credential pair.
|
||
|
type Client struct {
|
||
|
URL *url.URL
|
||
|
HttpClient http.Client
|
||
|
Dump Dump
|
||
|
Config ClientConfig
|
||
|
Logger Logger
|
||
|
|
||
|
tokenSource iam.TokenSource
|
||
|
}
|
||
|
|
||
|
// New creates and returns a Client without logging.
|
||
|
func New(config ClientConfig, transport http.RoundTripper) (*Client, error) {
|
||
|
return NewWithLogger(config, transport, nil)
|
||
|
}
|
||
|
|
||
|
// NewWithLogger creates and returns a Client with logging. The
|
||
|
// error value will be non-nil if the config is invalid.
|
||
|
func NewWithLogger(config ClientConfig, transport http.RoundTripper, logger Logger) (*Client, error) {
|
||
|
|
||
|
if transport == nil {
|
||
|
transport = DefaultTransport()
|
||
|
}
|
||
|
|
||
|
if logger == nil {
|
||
|
logger = NewLogger(func(args ...interface{}) {
|
||
|
fmt.Println(args...)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
if config.Verbose > len(dumpers)-1 || config.Verbose < 0 {
|
||
|
return nil, errors.New("verbose value is out of range")
|
||
|
}
|
||
|
|
||
|
if config.Timeout == 0 {
|
||
|
config.Timeout = defaultTimeout
|
||
|
}
|
||
|
keysURL := fmt.Sprintf("%s/api/v2/", config.BaseURL)
|
||
|
|
||
|
u, err := url.Parse(keysURL)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
ts := iam.CredentialFromAPIKey(config.APIKey)
|
||
|
|
||
|
if config.TokenURL != "" {
|
||
|
ts.TokenURL = config.TokenURL
|
||
|
}
|
||
|
|
||
|
c := &Client{
|
||
|
URL: u,
|
||
|
HttpClient: http.Client{
|
||
|
Timeout: time.Duration(config.Timeout * float64(time.Second)),
|
||
|
Transport: transport,
|
||
|
},
|
||
|
Dump: dumpers[config.Verbose],
|
||
|
Config: config,
|
||
|
Logger: logger,
|
||
|
tokenSource: ts,
|
||
|
}
|
||
|
return c, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) newRequest(method, path string, body interface{}) (*http.Request, error) {
|
||
|
|
||
|
u, err := c.URL.Parse(path)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
var reqBody []byte
|
||
|
var buf io.Reader
|
||
|
|
||
|
if body != nil {
|
||
|
reqBody, err = json.Marshal(body)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
buf = bytes.NewBuffer(reqBody)
|
||
|
}
|
||
|
|
||
|
request, err := http.NewRequest(method, u.String(), buf)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
request.Header.Set("accept", "application/json")
|
||
|
|
||
|
return request, nil
|
||
|
}
|
||
|
|
||
|
type reason struct {
|
||
|
Code string
|
||
|
Message string
|
||
|
Status int
|
||
|
MoreInfo string
|
||
|
}
|
||
|
|
||
|
func (r reason) String() string {
|
||
|
if r.MoreInfo != "" {
|
||
|
return fmt.Sprintf("%s: %s - FOR_MORE_INFO_REFER: %s", r.Code, r.Message, r.MoreInfo)
|
||
|
}
|
||
|
return fmt.Sprintf("%s: %s", r.Code, r.Message)
|
||
|
}
|
||
|
|
||
|
type Error struct {
|
||
|
URL string // URL of request that resulted in this error
|
||
|
StatusCode int // HTTP error code from KeyProtect service
|
||
|
Message string // error message from KeyProtect service
|
||
|
BodyContent []byte // raw body content if more inspection is needed
|
||
|
CorrelationID string // string value of a UUID that uniquely identifies the request to KeyProtect
|
||
|
Reasons []reason // collection of reason types containing detailed error messages
|
||
|
}
|
||
|
|
||
|
// Error returns correlation id and error message string
|
||
|
func (e Error) Error() string {
|
||
|
var extraVars string
|
||
|
if e.Reasons != nil && len(e.Reasons) > 0 {
|
||
|
extraVars = fmt.Sprintf(", reasons='%s'", e.Reasons)
|
||
|
}
|
||
|
|
||
|
return fmt.Sprintf("kp.Error: correlation_id='%v', msg='%s'%s", e.CorrelationID, e.Message, extraVars)
|
||
|
}
|
||
|
|
||
|
// URLError wraps an error from client.do() calls with a correlation ID from KeyProtect
|
||
|
type URLError struct {
|
||
|
Err error
|
||
|
CorrelationID string
|
||
|
}
|
||
|
|
||
|
func (e URLError) Error() string {
|
||
|
return fmt.Sprintf(
|
||
|
"error during request to KeyProtect correlation_id='%s': %s", e.CorrelationID, e.Err.Error())
|
||
|
}
|
||
|
|
||
|
func (c *Client) do(ctx context.Context, req *http.Request, res interface{}) (*http.Response, error) {
|
||
|
|
||
|
acccesToken, err := c.getAccessToken(ctx)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
// retrieve the correlation id from the context. If not present, then a UUID will be
|
||
|
// generated for the correlation ID and feed it into the request
|
||
|
// KeyProtect will use this when it is set on a request header rather than generating its
|
||
|
// own inside the service
|
||
|
// if not present, we generate our own here because a connection error might actually
|
||
|
// mean the request doesn't make it server side, so having a correlation ID locally helps
|
||
|
// us know that when comparing with server side logs.
|
||
|
corrID := c.getCorrelationID(ctx)
|
||
|
|
||
|
req.Header.Set("bluemix-instance", c.Config.InstanceID)
|
||
|
req.Header.Set("authorization", acccesToken)
|
||
|
req.Header.Set("correlation-id", corrID)
|
||
|
|
||
|
if c.Config.KeyRing != "" {
|
||
|
req.Header.Set("x-kms-key-ring", c.Config.KeyRing)
|
||
|
}
|
||
|
|
||
|
// set request up to be retryable on 500-level http codes and client errors
|
||
|
retryableClient := getRetryableClient(&c.HttpClient)
|
||
|
retryableRequest, err := rhttp.FromRequest(req)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
response, err := retryableClient.Do(retryableRequest.WithContext(ctx))
|
||
|
if err != nil {
|
||
|
return nil, &URLError{err, corrID}
|
||
|
}
|
||
|
defer response.Body.Close()
|
||
|
|
||
|
resBody, err := ioutil.ReadAll(response.Body)
|
||
|
redact := []string{c.Config.APIKey, req.Header.Get("authorization")}
|
||
|
c.Dump(req, response, []byte{}, resBody, c.Logger, redact)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
type KPErrorMsg struct {
|
||
|
Message string `json:"errorMsg,omitempty"`
|
||
|
Reasons []reason
|
||
|
}
|
||
|
|
||
|
type KPError struct {
|
||
|
Resources []KPErrorMsg `json:"resources,omitempty"`
|
||
|
}
|
||
|
|
||
|
switch response.StatusCode {
|
||
|
case http.StatusCreated:
|
||
|
if len(resBody) != 0 {
|
||
|
if err := json.Unmarshal(resBody, res); err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
}
|
||
|
case http.StatusOK:
|
||
|
if err := json.Unmarshal(resBody, res); err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
case http.StatusNoContent:
|
||
|
default:
|
||
|
errMessage := string(resBody)
|
||
|
var reasons []reason
|
||
|
|
||
|
if strings.Contains(string(resBody), "errorMsg") {
|
||
|
kperr := KPError{}
|
||
|
json.Unmarshal(resBody, &kperr)
|
||
|
if len(kperr.Resources) > 0 && len(kperr.Resources[0].Message) > 0 {
|
||
|
errMessage = kperr.Resources[0].Message
|
||
|
reasons = kperr.Resources[0].Reasons
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return nil, &Error{
|
||
|
URL: response.Request.URL.String(),
|
||
|
StatusCode: response.StatusCode,
|
||
|
Message: errMessage,
|
||
|
BodyContent: resBody,
|
||
|
CorrelationID: corrID,
|
||
|
Reasons: reasons,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return response, nil
|
||
|
}
|
||
|
|
||
|
// getRetryableClient returns a fully configured retryable HTTP client
|
||
|
func getRetryableClient(client *http.Client) *rhttp.Client {
|
||
|
// build base client with the library defaults and override as neeeded
|
||
|
rc := rhttp.NewClient()
|
||
|
rc.Logger = nil
|
||
|
rc.HTTPClient = client
|
||
|
rc.RetryWaitMax = RetryWaitMax
|
||
|
rc.RetryMax = RetryMax
|
||
|
rc.CheckRetry = kpCheckRetry
|
||
|
rc.ErrorHandler = rhttp.PassthroughErrorHandler
|
||
|
return rc
|
||
|
}
|
||
|
|
||
|
// kpCheckRetry will retry on connection errors, server errors, and 429s (rate limit)
|
||
|
func kpCheckRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
|
||
|
// do not retry on context.Canceled or context.DeadlineExceeded
|
||
|
if ctx.Err() != nil {
|
||
|
return false, ctx.Err()
|
||
|
}
|
||
|
|
||
|
if err != nil {
|
||
|
return true, err
|
||
|
}
|
||
|
// Retry on connection errors, 500+ errors (except 501 - not implemented), and 429 - too many requests
|
||
|
if resp.StatusCode == 0 || resp.StatusCode == 429 || (resp.StatusCode >= 500 && resp.StatusCode != 501) {
|
||
|
return true, nil
|
||
|
}
|
||
|
|
||
|
return false, nil
|
||
|
}
|
||
|
|
||
|
// ContextKey provides a type to auth context keys.
|
||
|
type ContextKey int
|
||
|
|
||
|
// NewContextWithAuth ...
|
||
|
func NewContextWithAuth(parent context.Context, auth string) context.Context {
|
||
|
return context.WithValue(parent, authContextKey, auth)
|
||
|
}
|
||
|
|
||
|
// getAccessToken returns the auth context from the given Context, or
|
||
|
// calls to the IAMTokenSource to retrieve an auth token.
|
||
|
func (c *Client) getAccessToken(ctx context.Context) (string, error) {
|
||
|
if ctx.Value(authContextKey) != nil {
|
||
|
return ctx.Value(authContextKey).(string), nil
|
||
|
}
|
||
|
|
||
|
if len(c.Config.Authorization) > 0 {
|
||
|
return c.Config.Authorization, nil
|
||
|
}
|
||
|
|
||
|
token, err := c.tokenSource.Token()
|
||
|
if err != nil {
|
||
|
return "", err
|
||
|
}
|
||
|
|
||
|
return fmt.Sprintf("%s %s", token.TokenType, token.AccessToken), nil
|
||
|
}
|
||
|
|
||
|
// getCorrelationId returns the correlation ID value from the given Context, or
|
||
|
// returns a new UUID if not present
|
||
|
func (c *Client) getCorrelationID(ctx context.Context) string {
|
||
|
corrID := GetCorrelationID(ctx)
|
||
|
if corrID == nil {
|
||
|
return uuid.New().String()
|
||
|
}
|
||
|
|
||
|
return corrID.String()
|
||
|
}
|
||
|
|
||
|
// NewContextWithCorrelationID retuns a context containing the UUID
|
||
|
func NewContextWithCorrelationID(ctx context.Context, uuid *uuid.UUID) context.Context {
|
||
|
return context.WithValue(ctx, cidCtxKey, uuid)
|
||
|
}
|
||
|
|
||
|
// GetCorrelationID returns the correlation ID from the context
|
||
|
func GetCorrelationID(ctx context.Context) *uuid.UUID {
|
||
|
if id := ctx.Value(cidCtxKey); id != nil {
|
||
|
return id.(*uuid.UUID)
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c ctxKey) String() string {
|
||
|
return string(c)
|
||
|
}
|
||
|
|
||
|
// Logger writes when called.
|
||
|
type Logger interface {
|
||
|
Info(...interface{})
|
||
|
}
|
||
|
|
||
|
type logger struct {
|
||
|
writer func(...interface{})
|
||
|
}
|
||
|
|
||
|
func (l *logger) Info(args ...interface{}) {
|
||
|
l.writer(args...)
|
||
|
}
|
||
|
|
||
|
func NewLogger(writer func(...interface{})) Logger {
|
||
|
return &logger{writer: writer}
|
||
|
}
|
||
|
|
||
|
var dumpers = []Dump{dumpNone, dumpBodyOnly, dumpAll, dumpFailOnly, dumpAllNoRedact}
|
||
|
|
||
|
// Dump writes various parts of an HTTP request and an HTTP response.
|
||
|
type Dump func(*http.Request, *http.Response, []byte, []byte, Logger, []string)
|
||
|
|
||
|
// Redact replaces various pieces of output.
|
||
|
type Redact func(string, []string) string
|
||
|
|
||
|
// dumpFailOnly calls dumpAll when the HTTP response isn't 200 (ok),
|
||
|
// 201 (created), or 204 (no content).
|
||
|
func dumpFailOnly(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
|
||
|
switch rsp.StatusCode {
|
||
|
case http.StatusOK, http.StatusCreated, http.StatusNoContent:
|
||
|
return
|
||
|
}
|
||
|
dumpAll(req, rsp, reqBody, resBody, log, redactStrings)
|
||
|
}
|
||
|
|
||
|
// dumpAll dumps the HTTP request and the HTTP response body.
|
||
|
func dumpAll(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
|
||
|
dumpRequest(req, rsp, log, redactStrings, redact)
|
||
|
dumpBody(reqBody, resBody, log, redactStrings, redact)
|
||
|
}
|
||
|
|
||
|
// dumpAllNoRedact dumps the HTTP request and HTTP response body without redaction.
|
||
|
func dumpAllNoRedact(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
|
||
|
dumpRequest(req, rsp, log, redactStrings, noredact)
|
||
|
dumpBody(reqBody, resBody, log, redactStrings, noredact)
|
||
|
}
|
||
|
|
||
|
// dumpBodyOnly dumps the HTTP response body.
|
||
|
func dumpBodyOnly(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
|
||
|
dumpBody(reqBody, resBody, log, redactStrings, redact)
|
||
|
}
|
||
|
|
||
|
// dumpNone does nothing.
|
||
|
func dumpNone(req *http.Request, rsp *http.Response, reqBody, resBody []byte, log Logger, redactStrings []string) {
|
||
|
}
|
||
|
|
||
|
// dumpRequest dumps the HTTP request.
|
||
|
func dumpRequest(req *http.Request, rsp *http.Response, log Logger, redactStrings []string, redact Redact) {
|
||
|
// log.Info(redact(fmt.Sprint(req), redactStrings))
|
||
|
// log.Info(redact(fmt.Sprint(rsp), redactStrings))
|
||
|
}
|
||
|
|
||
|
// dumpBody dumps the HTTP response body with redactions.
|
||
|
func dumpBody(reqBody, resBody []byte, log Logger, redactStrings []string, redact Redact) {
|
||
|
// log.Info(string(redact(string(reqBody), redactStrings)))
|
||
|
// Redact the access token and refresh token if it shows up in the reponnse body. This will happen
|
||
|
// when using an API Key
|
||
|
var auth iam.Token
|
||
|
if strings.Contains(string(resBody), "access_token") {
|
||
|
err := json.Unmarshal(resBody, &auth)
|
||
|
if err != nil {
|
||
|
log.Info(err)
|
||
|
}
|
||
|
redactStrings = append(redactStrings, auth.AccessToken)
|
||
|
redactStrings = append(redactStrings, auth.RefreshToken)
|
||
|
}
|
||
|
// log.Info(string(redact(string(resBody), redactStrings)))
|
||
|
}
|
||
|
|
||
|
// redact replaces substrings within the given string.
|
||
|
func redact(s string, redactStrings []string) string {
|
||
|
if len(redactStrings) < 1 {
|
||
|
return s
|
||
|
}
|
||
|
var a []string
|
||
|
for _, s1 := range redactStrings {
|
||
|
if s1 != "" {
|
||
|
a = append(a, s1)
|
||
|
a = append(a, "***Value redacted***")
|
||
|
}
|
||
|
}
|
||
|
r := strings.NewReplacer(a...)
|
||
|
return r.Replace(s)
|
||
|
}
|
||
|
|
||
|
// noredact does not perform redaction, and returns the given string.
|
||
|
func noredact(s string, redactStrings []string) string {
|
||
|
return s
|
||
|
}
|