516 lines
15 KiB
Go
516 lines
15 KiB
Go
package infisical
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
|
|
"math"
|
|
"math/rand"
|
|
"net"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
"github.com/hashicorp/golang-lru/v2/expirable"
|
|
"github.com/infisical/go-sdk/packages/models"
|
|
"github.com/infisical/go-sdk/packages/util"
|
|
)
|
|
|
|
type LogLevel string
|
|
|
|
const (
|
|
LogLevelDebug LogLevel = "debug"
|
|
)
|
|
|
|
type InfisicalClient struct {
|
|
authMethod util.AuthMethod
|
|
credential interface{}
|
|
tokenDetails MachineIdentityCredential
|
|
lastFetchedTime time.Time
|
|
firstFetchedTime time.Time
|
|
|
|
mu sync.RWMutex
|
|
|
|
// refreshMu is used to prevent concurrent token refreshes.
|
|
// Only one refresh should happen at a time to avoid race conditions.
|
|
refreshMu sync.Mutex
|
|
|
|
cache *expirable.LRU[string, interface{}]
|
|
|
|
httpClient *resty.Client
|
|
config Config
|
|
|
|
secrets SecretsInterface
|
|
folders FoldersInterface
|
|
auth AuthInterface
|
|
dynamicSecrets DynamicSecretsInterface
|
|
kms KmsInterface
|
|
ssh SshInterface
|
|
|
|
logger zerolog.Logger
|
|
}
|
|
|
|
type InfisicalClientInterface interface {
|
|
UpdateConfiguration(config Config)
|
|
Secrets() SecretsInterface
|
|
Folders() FoldersInterface
|
|
Auth() AuthInterface
|
|
DynamicSecrets() DynamicSecretsInterface
|
|
Kms() KmsInterface
|
|
Ssh() SshInterface
|
|
}
|
|
|
|
type ExponentialBackoffStrategy struct {
|
|
// Base delay between retries. Defaults to 1 second
|
|
BaseDelay time.Duration
|
|
|
|
// Maximum number of retries. Defaults to 3
|
|
MaxRetries int
|
|
|
|
// Maximum delay between retries. Defaults to 30 seconds
|
|
MaxDelay time.Duration
|
|
}
|
|
|
|
func (s *ExponentialBackoffStrategy) GetDelay(retryCount int) time.Duration {
|
|
|
|
if s.BaseDelay == 0 {
|
|
s.BaseDelay = 1 * time.Second
|
|
}
|
|
|
|
if s.MaxDelay == 0 {
|
|
s.MaxDelay = 30 * time.Second
|
|
}
|
|
|
|
if s.MaxRetries == 0 {
|
|
s.MaxRetries = 3
|
|
}
|
|
|
|
delay := s.BaseDelay * time.Duration(math.Pow(2, float64(retryCount)))
|
|
|
|
// if delay is greater than the user-configured max delay, set the delay to the max delay
|
|
if delay > s.MaxDelay {
|
|
delay = s.MaxDelay
|
|
}
|
|
|
|
return s.Jitter(delay)
|
|
}
|
|
|
|
func (s *ExponentialBackoffStrategy) Jitter(delay time.Duration) time.Duration {
|
|
// 20% jitter, negative and positive
|
|
|
|
jitterFactor := 0.2
|
|
|
|
// generates random value in [-0.2, +0.2] range
|
|
randomFactor := (rand.Float64()*2 - 1) * jitterFactor
|
|
jitter := time.Duration(randomFactor * float64(delay))
|
|
return delay + jitter
|
|
}
|
|
|
|
type RetryRequestsConfig struct {
|
|
ExponentialBackoff *ExponentialBackoffStrategy
|
|
}
|
|
|
|
type Config struct {
|
|
SiteUrl string `default:"https://app.infisical.com"`
|
|
CaCertificate string
|
|
LogLevel LogLevel // Specify the log level for the SDK. If set to debug, the SDK will print to stdout with verbose logging. Defaults to no logging.
|
|
UserAgent string `default:"infisical-go-sdk"` // User-Agent header to be used on requests sent by the SDK. Defaults to `infisical-go-sdk`. Do not modify this unless you have a reason to do so.
|
|
AutoTokenRefresh *bool // Whether or not to automatically refresh the auth token after using one of the .Auth() methods. Defaults to `true` when nil. Pass a pointer to `false` to disable.
|
|
SilentMode bool // If enabled, the SDK will not print any warnings to the console.
|
|
CacheExpiryInSeconds int // Defines how long certain API responses should be cached in memory, in seconds. When set to a positive value, responses from specific fetch API requests (like secret fetching) will be cached for this duration. Set to 0 to disable caching. Defaults to 0.
|
|
CustomHeaders map[string]string
|
|
RetryRequestsConfig *RetryRequestsConfig
|
|
LogWriter io.Writer
|
|
}
|
|
|
|
func setupLogger(logLevel LogLevel, logWriter io.Writer) zerolog.Logger {
|
|
// very annoying but zerolog doesn't allow us to change one color without changing all of them
|
|
// these are the default colors for each level, except for warn
|
|
levelColors := map[string]string{
|
|
"trace": "\033[35m", // magenta
|
|
"debug": "\033[33m", // yellow
|
|
"info": "\033[32m", // green
|
|
"warn": "\033[33m", // yellow (this one is custom, the default is red \033[31m)
|
|
"error": "\033[31m", // red
|
|
"fatal": "\033[31m", // red
|
|
"panic": "\033[31m", // red
|
|
}
|
|
|
|
// map full level names to abbreviated forms (default zerolog behavior)
|
|
// see consoleDefaultFormatLevel, in zerolog for example
|
|
levelAbbrev := map[string]string{
|
|
"trace": "TRC",
|
|
"debug": "DBG",
|
|
"info": "INF",
|
|
"warn": "WRN",
|
|
"error": "ERR",
|
|
"fatal": "FTL",
|
|
"panic": "PNC",
|
|
}
|
|
|
|
// default to stderr
|
|
if logWriter == nil {
|
|
logWriter = os.Stderr
|
|
}
|
|
|
|
logger := log.Output(zerolog.ConsoleWriter{
|
|
Out: logWriter,
|
|
TimeFormat: time.RFC3339,
|
|
|
|
FormatLevel: func(i interface{}) string {
|
|
level := fmt.Sprintf("%s", i)
|
|
color := levelColors[level]
|
|
if color == "" {
|
|
color = "\033[0m" // no color for unknown levels
|
|
}
|
|
abbrev := levelAbbrev[level]
|
|
if abbrev == "" {
|
|
abbrev = strings.ToUpper(level) // fallback to uppercase if unknown
|
|
}
|
|
return color + abbrev + "\033[0m"
|
|
},
|
|
})
|
|
|
|
if logLevel != "" {
|
|
level, err := zerolog.ParseLevel(string(logLevel))
|
|
if err != nil {
|
|
logger.Warn().Msgf("Invalid log level: %s", logLevel)
|
|
} else {
|
|
logger = logger.Level(level)
|
|
logger.Debug().Msgf("Infisical SDK log level set to %s", logLevel)
|
|
}
|
|
} else {
|
|
logger = logger.Level(zerolog.InfoLevel)
|
|
}
|
|
|
|
return logger
|
|
}
|
|
|
|
// autoTokenRefreshEnabled returns true if AutoTokenRefresh is unset (nil) or
|
|
// explicitly set to true. nil is treated as the default-on case so that
|
|
// callers who omit the field keep the historical behavior, while callers that
|
|
// pass a pointer to false can actually disable the background refresh.
|
|
func autoTokenRefreshEnabled(cfg Config) bool {
|
|
return cfg.AutoTokenRefresh == nil || *cfg.AutoTokenRefresh
|
|
}
|
|
|
|
func setDefaults(cfg *Config) {
|
|
t := reflect.TypeOf(*cfg) // we need to dereference the pointer to get the struct type
|
|
v := reflect.ValueOf(cfg).Elem()
|
|
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
defaultVal := field.Tag.Get("default")
|
|
if defaultVal == "" {
|
|
continue
|
|
}
|
|
|
|
switch field.Type.Kind() {
|
|
case reflect.Int:
|
|
if v.Field(i).Int() == 0 {
|
|
val, _ := strconv.Atoi(defaultVal)
|
|
v.Field(i).SetInt(int64(val))
|
|
}
|
|
case reflect.String:
|
|
if v.Field(i).String() == "" {
|
|
v.Field(i).SetString(defaultVal)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// BoolPtr returns a pointer to the given bool value. Use this helper when
|
|
// setting AutoTokenRefresh in Config, e.g.: AutoTokenRefresh: infisical.BoolPtr(false).
|
|
func BoolPtr(v bool) *bool { return &v }
|
|
|
|
func (c *InfisicalClient) setAccessToken(tokenDetails MachineIdentityCredential, credential interface{}, authMethod util.AuthMethod) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.tokenDetails = tokenDetails
|
|
c.lastFetchedTime = time.Now()
|
|
|
|
if c.authMethod != authMethod || c.firstFetchedTime.IsZero() {
|
|
c.firstFetchedTime = time.Now()
|
|
c.authMethod = authMethod
|
|
}
|
|
|
|
c.credential = credential
|
|
c.httpClient.SetAuthScheme("Bearer")
|
|
c.httpClient.SetAuthToken(c.tokenDetails.AccessToken)
|
|
}
|
|
|
|
func (c *InfisicalClient) clearAccessToken() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.tokenDetails = MachineIdentityCredential{}
|
|
c.authMethod = ""
|
|
c.httpClient.SetAuthScheme("")
|
|
c.httpClient.SetAuthToken("")
|
|
}
|
|
func (c *InfisicalClient) setPlainAccessToken(accessToken string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.authMethod = util.ACCESS_TOKEN
|
|
c.httpClient.SetAuthScheme("Bearer")
|
|
c.httpClient.SetAuthToken(accessToken)
|
|
|
|
c.tokenDetails.AccessToken = accessToken
|
|
c.credential = models.AccessTokenCredential{AccessToken: accessToken}
|
|
}
|
|
|
|
func NewInfisicalClient(context context.Context, config Config) InfisicalClientInterface {
|
|
logger := setupLogger(config.LogLevel, config.LogWriter)
|
|
|
|
client := &InfisicalClient{
|
|
logger: logger,
|
|
}
|
|
setDefaults(&config)
|
|
client.UpdateConfiguration(config) // set httpClient and config
|
|
|
|
// add interfaces here
|
|
client.secrets = NewSecrets(client)
|
|
client.folders = NewFolders(client)
|
|
client.auth = NewAuth(client)
|
|
client.dynamicSecrets = NewDynamicSecrets(client)
|
|
client.kms = NewKms(client)
|
|
client.ssh = NewSsh(client)
|
|
if config.CacheExpiryInSeconds != 0 {
|
|
// hard limit set at 1000 cache items until forced eviction
|
|
client.cache = expirable.NewLRU[string, interface{}](1000, nil, time.Second*time.Duration(config.CacheExpiryInSeconds))
|
|
}
|
|
|
|
if autoTokenRefreshEnabled(config) {
|
|
go client.handleTokenLifeCycle(context)
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
func (c *InfisicalClient) UpdateConfiguration(config Config) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
setDefaults(&config)
|
|
config.SiteUrl = util.AppendAPIEndpoint(config.SiteUrl)
|
|
c.config = config
|
|
|
|
if c.httpClient == nil {
|
|
c.httpClient = resty.New().
|
|
SetHeader("User-Agent", config.UserAgent).
|
|
SetBaseURL(config.SiteUrl)
|
|
|
|
maxRetries := 3
|
|
maxWaitTime := 30 * time.Second
|
|
|
|
if config.RetryRequestsConfig != nil && config.RetryRequestsConfig.ExponentialBackoff != nil {
|
|
maxRetries = config.RetryRequestsConfig.ExponentialBackoff.MaxRetries
|
|
maxWaitTime = 10 * time.Minute
|
|
}
|
|
|
|
c.httpClient.SetRetryCount(maxRetries).
|
|
SetRetryWaitTime(1 * time.Second).
|
|
SetRetryMaxWaitTime(maxWaitTime).
|
|
SetRetryAfter(func(rc *resty.Client, r *resty.Response) (time.Duration, error) {
|
|
|
|
if config.RetryRequestsConfig != nil && config.RetryRequestsConfig.ExponentialBackoff != nil {
|
|
delay := config.RetryRequestsConfig.ExponentialBackoff.GetDelay(r.Request.Attempt)
|
|
if !config.SilentMode {
|
|
util.PrintWarning(c.logger, fmt.Sprintf("Request failed, [url=%s] [status=%d] [method=%s]\nRetrying in %s (attempt %d)", r.Request.URL, r.StatusCode(), r.Request.Method, delay.String(), r.Request.Attempt))
|
|
}
|
|
return delay, nil
|
|
}
|
|
|
|
attempt := r.Request.Attempt + 1
|
|
if attempt <= 0 {
|
|
attempt = 1
|
|
}
|
|
waitTime := math.Min(float64(rc.RetryWaitTime)*math.Pow(2, float64(attempt-1)), float64(rc.RetryMaxWaitTime))
|
|
|
|
// Add jitter of +/-20%
|
|
jitterFactor := 0.8 + (rand.Float64() * 0.4)
|
|
waitTime = waitTime * jitterFactor
|
|
|
|
waitDuration := time.Duration(waitTime)
|
|
return waitDuration, nil
|
|
}).
|
|
AddRetryCondition(func(r *resty.Response, err error) bool {
|
|
// don't retry if there's no error or it's a timeout
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return false
|
|
}
|
|
|
|
if err == nil && r == nil {
|
|
return false
|
|
}
|
|
|
|
if config.RetryRequestsConfig != nil && config.RetryRequestsConfig.ExponentialBackoff != nil {
|
|
if (r != nil && r.IsError()) || err != nil {
|
|
return r.Request.Attempt <= config.RetryRequestsConfig.ExponentialBackoff.MaxRetries
|
|
}
|
|
}
|
|
|
|
networkErrors := []string{
|
|
"connection refused",
|
|
"connection reset",
|
|
"network",
|
|
"connection",
|
|
"no such host",
|
|
"i/o timeout",
|
|
"dial tcp",
|
|
"broken pipe",
|
|
"wsaetimeout",
|
|
"wsaeconnreset",
|
|
"econnreset",
|
|
"econnrefused",
|
|
"ehostunreach",
|
|
"enetunreach",
|
|
}
|
|
|
|
isConditionMet := false
|
|
|
|
var netErr net.Error
|
|
if errors.As(err, &netErr) {
|
|
return true
|
|
}
|
|
|
|
if err != nil {
|
|
for _, netErr := range networkErrors {
|
|
errMsg := err.Error()
|
|
|
|
if strings.Contains(strings.ToLower(errMsg), netErr) {
|
|
isConditionMet = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return isConditionMet
|
|
|
|
})
|
|
|
|
// OnBeforeRequest hook to validate and refresh token before each request.
|
|
// This is a safety net to catch cases where the background token lifecycle
|
|
// goroutine might miss a refresh window due to timing issues (GC pauses,
|
|
// CPU contention, etc.). Most requests will not trigger a refresh here
|
|
// because the background goroutine handles proactive token management.
|
|
if autoTokenRefreshEnabled(config) {
|
|
c.httpClient.OnBeforeRequest(c.beforeRequestAuthInterceptor)
|
|
}
|
|
} else {
|
|
c.httpClient.
|
|
SetHeader("User-Agent", config.UserAgent).
|
|
SetBaseURL(config.SiteUrl)
|
|
}
|
|
|
|
if len(config.CustomHeaders) > 0 {
|
|
c.httpClient.SetHeaders(config.CustomHeaders)
|
|
}
|
|
|
|
if config.CaCertificate != "" {
|
|
caCertPool, err := x509.SystemCertPool()
|
|
if err != nil && !config.SilentMode {
|
|
util.PrintWarning(c.logger, fmt.Sprintf("failed to load system root CA pool: %v", err))
|
|
}
|
|
|
|
if ok := caCertPool.AppendCertsFromPEM([]byte(config.CaCertificate)); !ok && !config.SilentMode {
|
|
util.PrintWarning(c.logger, "failed to append CA certificate")
|
|
}
|
|
|
|
tlsConfig := &tls.Config{
|
|
RootCAs: caCertPool,
|
|
}
|
|
|
|
c.httpClient.SetTLSClientConfig(tlsConfig)
|
|
}
|
|
}
|
|
|
|
func (c *InfisicalClient) Secrets() SecretsInterface {
|
|
return c.secrets
|
|
}
|
|
|
|
func (c *InfisicalClient) Folders() FoldersInterface {
|
|
return c.folders
|
|
}
|
|
|
|
func (c *InfisicalClient) Auth() AuthInterface {
|
|
return c.auth
|
|
}
|
|
|
|
func (c *InfisicalClient) DynamicSecrets() DynamicSecretsInterface {
|
|
return c.dynamicSecrets
|
|
}
|
|
|
|
func (c *InfisicalClient) Kms() KmsInterface {
|
|
return c.kms
|
|
}
|
|
|
|
func (c *InfisicalClient) Ssh() SshInterface {
|
|
return c.ssh
|
|
}
|
|
|
|
func (c *InfisicalClient) handleTokenLifeCycle(context context.Context) {
|
|
var warningPrinted = false
|
|
|
|
for {
|
|
select {
|
|
case <-context.Done():
|
|
return // The context has been cancelled, clean up and return from the loop to stop the goroutine
|
|
default:
|
|
c.mu.RLock()
|
|
config := c.config
|
|
authMethod := c.authMethod
|
|
tokenDetails := c.tokenDetails
|
|
c.mu.RUnlock()
|
|
|
|
if autoTokenRefreshEnabled(config) && authMethod != "" && authMethod != util.ACCESS_TOKEN {
|
|
// Print warning once for short TTLs
|
|
if !config.SilentMode && !warningPrinted && tokenDetails.AccessTokenMaxTTL != 0 && tokenDetails.ExpiresIn != 0 {
|
|
if tokenDetails.AccessTokenMaxTTL < 60 || tokenDetails.ExpiresIn < 60 {
|
|
util.PrintWarning(c.logger, "Machine Identity access token TTL or max TTL is less than 60 seconds. This may cause excessive API calls, and you may be subject to rate-limits.")
|
|
}
|
|
warningPrinted = true
|
|
}
|
|
|
|
// Check if token needs refresh (using the same buffer as OnBeforeRequest)
|
|
if c.isTokenExpiringSoon(renewalBufferSeconds) {
|
|
// Use refreshTokenSynchronously which handles both renewal and re-auth
|
|
// Pass false for manualTrigger since this is from the background goroutine
|
|
if err := c.refreshTokenSynchronously(false); err != nil {
|
|
c.logger.Debug().Msgf("Background token refresh failed: %s", err.Error())
|
|
}
|
|
|
|
// Re-read token details after refresh attempt
|
|
c.mu.RLock()
|
|
tokenDetails = c.tokenDetails
|
|
c.mu.RUnlock()
|
|
}
|
|
|
|
// Calculate sleep time until next check
|
|
sleepTime := c.calculateSleepTime(tokenDetails, renewalBufferSeconds)
|
|
|
|
if err := util.SleepWithContext(context, sleepTime); err != nil && (err == util.ErrContextCanceled || errors.Is(err, util.ErrContextDeadlineExceeded)) {
|
|
return
|
|
}
|
|
} else {
|
|
if err := util.SleepWithContext(context, 1*time.Second); err != nil && (err == util.ErrContextCanceled || errors.Is(err, util.ErrContextDeadlineExceeded)) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|