Files
oikos/vendor/github.com/infisical/go-sdk/client.go
dtoro febc153b7f
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
fix: add involves edge from task to agent:nomos at creation
Plus sync vendor directory for Docker build compatibility.
2026-08-11 22:03:12 +02:00

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
}
}
}
}
}