@@ -28,6 +28,7 @@ import (
2828 "net/url"
2929 "path"
3030 "strings"
31+ "sync"
3132 "time"
3233
3334 "github.com/Keyfactor/keyfactor-auth-client-go/auth_providers"
6970type Client struct {
7071 AuthClient AuthConfig
7172 LoggerType string
73+
74+ // httpClient caches the *http.Client returned by AuthClient.GetHttpClient()
75+ // so that sendRequest reuses a single underlying transport/connection pool
76+ // across requests instead of asking AuthClient to build a brand new one on
77+ // every call. Both CommandConfigOauth.GetHttpClient() and
78+ // CommandAuthConfigBasic.GetHttpClient() (in keyfactor-auth-client-go)
79+ // construct a fresh http.Transport per invocation, and that transport's
80+ // IdleConnTimeout is derived from the configured HttpClientTimeout - so
81+ // without this cache, every request opens its own connection pool whose
82+ // sockets linger for up to HttpClientTimeout before being reclaimed. This
83+ // was already true at the old fixed 60s default; plumbing a caller-supplied
84+ // ClientTimeout (see NewKeyfactorClient) just widens the window, so caching
85+ // here keeps that fix from amplifying a pre-existing resource leak.
86+ httpClient * http.Client
87+ httpClientMu sync.Mutex
88+ }
89+
90+ // getHttpClient returns the cached *http.Client if one has already been
91+ // resolved for this Client, populating the cache on first use otherwise.
92+ // This guarantees AuthClient.GetHttpClient() is invoked at most once per
93+ // Client instance, so the transport (and its connection pool) is reused
94+ // across requests. It is safe for concurrent use.
95+ //
96+ // Note this does not affect OAuth token refresh: the cached *http.Client's
97+ // transport wraps an oauth2 TokenSource that is consulted (and refreshed as
98+ // needed) on every RoundTrip, independent of how many times the *http.Client
99+ // itself is reused.
100+ func (c * Client ) getHttpClient () (* http.Client , error ) {
101+ c .httpClientMu .Lock ()
102+ defer c .httpClientMu .Unlock ()
103+
104+ if c .httpClient != nil {
105+ return c .httpClient , nil
106+ }
107+
108+ httpClient , err := c .AuthClient .GetHttpClient ()
109+ if err != nil {
110+ return nil , err
111+ }
112+ c .httpClient = httpClient
113+ return httpClient , nil
72114}
73115
74116// TerraformLogger wraps the tflog logging to handle Go's log messages with log level mapping.
@@ -142,11 +184,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie
142184 clientAuthType := cfg .GetAuthType ()
143185
144186 baseConfig := auth_providers.CommandAuthConfig {
145- CommandHostName : cfg .Host ,
146- CommandPort : cfg .Port ,
147- CommandAPIPath : cfg .APIPath ,
148- CommandCACert : cfg .CACertPath ,
149- SkipVerify : cfg .SkipTLSVerify ,
187+ CommandHostName : cfg .Host ,
188+ CommandPort : cfg .Port ,
189+ CommandAPIPath : cfg .APIPath ,
190+ CommandCACert : cfg .CACertPath ,
191+ SkipVerify : cfg .SkipTLSVerify ,
192+ HttpClientTimeout : cfg .ClientTimeout ,
150193 }
151194
152195 if clientAuthType == "basic" {
@@ -160,11 +203,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie
160203 if aErr != nil {
161204 return nil , aErr
162205 }
163- _ , cErr := basicCfg .GetHttpClient ()
206+ httpClient , cErr := basicCfg .GetHttpClient ()
164207 if cErr != nil {
165208 return nil , cErr
166209 }
167210 client .AuthClient = & basicCfg
211+ client .httpClient = httpClient
168212 return & client , nil
169213 } else if clientAuthType == "oauth" {
170214 oauthCfg := auth_providers.CommandConfigOauth {
@@ -180,11 +224,12 @@ func NewKeyfactorClient(cfg *auth_providers.Server, ctx *context.Context) (*Clie
180224 if aErr != nil {
181225 return nil , aErr
182226 }
183- _ , cErr := oauthCfg .GetHttpClient ()
227+ httpClient , cErr := oauthCfg .GetHttpClient ()
184228 if cErr != nil {
185229 return nil , cErr
186230 }
187231 client .AuthClient = & oauthCfg
232+ client .httpClient = httpClient
188233 return & client , nil
189234 } else {
190235 return nil , fmt .Errorf ("unsupported auth type or authentication cfg: '%s'" , clientAuthType )
@@ -204,7 +249,12 @@ func logRequest(req *http.Request) error {
204249 // Restore the request body so it can be read later
205250 req .Body = io .NopCloser (bytes .NewBuffer (body ))
206251
207- // Create a struct to hold request data
252+ // Create a struct to hold request data. The body is redacted before
253+ // logging (see redactSensitiveJSONForLogging) since it's always the same
254+ // JSON-marshaled request.Payload passed into sendRequest, which may carry
255+ // a certificate/PFX recovery password or other secret - this must not be
256+ // dumped verbatim into TRACE-level logs.
257+ redactedBody := redactSensitiveJSONForLogging (body )
208258 requestData := struct {
209259 Method string `json:"method"`
210260 URL string `json:"url"`
@@ -214,7 +264,7 @@ func logRequest(req *http.Request) error {
214264 Method : req .Method ,
215265 URL : req .URL .String (),
216266 Headers : req .Header ,
217- Body : string (body ),
267+ Body : string (redactedBody ),
218268 }
219269
220270 // Convert struct to JSON
@@ -251,15 +301,18 @@ func requestToCurl(req *http.Request) (string, error) {
251301 }
252302 }
253303
254- // Add the body if it exists
304+ // Add the body if it exists. The body is redacted before being embedded
305+ // in the logged cURL command (see redactSensitiveJSONForLogging) since a
306+ // TRACE-level cURL command containing a raw password is directly
307+ // replayable by anyone who reads the log, not just informational.
255308 if req .Method == http .MethodPost || req .Method == http .MethodPut {
256309 body , err := io .ReadAll (req .Body )
257310 if err != nil {
258311 return "" , err
259312 }
260313 req .Body = io .NopCloser (bytes .NewBuffer (body )) // Restore the request body
261314
262- curlCommand .WriteString (fmt .Sprintf ("--data %q " , string (body )))
315+ curlCommand .WriteString (fmt .Sprintf ("--data %q " , string (redactSensitiveJSONForLogging ( body ) )))
263316 }
264317
265318 return curlCommand .String (), nil
@@ -317,7 +370,7 @@ func (c *Client) sendRequest(request *request) (*http.Response, error) {
317370 if mErr != nil {
318371 return nil , mErr
319372 }
320- log .Printf ("[TRACE] Request body: %s" , jsonByes )
373+ log .Printf ("[TRACE] Request body: %s" , redactSensitiveJSONForLogging ( jsonByes ) )
321374
322375 req , reqErr := http .NewRequest (request .Method , keyfactorPath , bytes .NewBuffer (jsonByes ))
323376 if reqErr != nil {
@@ -342,49 +395,30 @@ func (c *Client) sendRequest(request *request) (*http.Response, error) {
342395
343396 // Log the request
344397 logRequest (req )
345- httpClient , cErr := c .AuthClient . GetHttpClient ()
398+ httpClient , cErr := c .getHttpClient ()
346399 if cErr != nil {
347400 return nil , cErr
348401 }
349402 resp , respErr := httpClient .Do (req )
350403
351- // check if context deadline exceeded
404+ // NOTE: this used to silently retry on "context deadline exceeded" (up to
405+ // MAX_CONTEXT_DEADLINE_RETRIES times) without ever surfacing that a retry
406+ // happened. That's unsafe for two reasons:
407+ // 1. Retrying a non-idempotent request (e.g. a POST enrollment) after a
408+ // client-side timeout risks creating a second server-side resource
409+ // if the original request actually succeeded after the client gave
410+ // up on it -- exactly the scenario callers need to detect via the
411+ // returned error, not have hidden from them by a "successful" retry.
412+ // 2. If every retry also failed, `resp` was never reassigned from its
413+ // original nil value and there was no `return` for this case, so
414+ // control fell through to `resp.StatusCode` below on a nil
415+ // *http.Response, panicking the caller (e.g. crashing `terraform
416+ // apply` outright).
417+ // Callers that need retry-with-backoff semantics around a timeout (and
418+ // that know their request is safe to repeat) should implement that at
419+ // their own call site, where they have the context to decide; this layer
420+ // now always returns the transport error untouched.
352421 switch {
353- case respErr != nil && (strings .Contains (respErr .Error (), "context deadline exceeded" )):
354- sleepDuration := time .Duration (1 ) * time .Second
355- for i := 0 ; i < MAX_CONTEXT_DEADLINE_RETRIES ; i ++ {
356- // sleep for exponential backoff
357- if i > 0 {
358- sleepDuration *= 2
359- if sleepDuration > time .Duration (MAX_WAIT_SECONDS )* time .Second {
360- sleepDuration = time .Duration (MAX_WAIT_SECONDS ) * time .Second
361- }
362- log .Printf (
363- "[DEBUG] %s request to %s failed with error %s, retrying in %s seconds..." ,
364- request .Method ,
365- keyfactorPath ,
366- respErr .Error (),
367- sleepDuration ,
368- )
369- time .Sleep (sleepDuration )
370- }
371-
372- log .Printf (
373- "[DEBUG] %s request to %s failed with error %s, retrying..." ,
374- request .Method ,
375- keyfactorPath ,
376- respErr .Error (),
377- )
378- req , reqErr = http .NewRequest (request .Method , keyfactorPath , bytes .NewBuffer (jsonByes ))
379- if reqErr != nil {
380- return nil , reqErr
381- }
382- resp2 , respErr2 := httpClient .Do (req )
383- if respErr2 == nil && resp2 != nil {
384- resp = resp2
385- break
386- }
387- }
388422 case respErr != nil :
389423 log .Printf ("[ERROR] Error sending '%s' request to '%s': %s" , request .Method , request .Endpoint , respErr )
390424 return nil , respErr
0 commit comments