1+ package certmanager
2+
3+ import (
4+ "context"
5+ "crypto/x509"
6+ "errors"
7+ "fmt"
8+ "net/http"
9+ "strings"
10+ "time"
11+ )
12+
13+ // IsCertRelatedTLSError reports whether err is (or wraps) a TLS/x509 failure
14+ // that is likely to be fixed by rotating or reloading local certificate
15+ // material. Non-TLS errors (connection refused, timeouts, HTTP 5xx, etc.)
16+ // return false so callers do not stampede Vault on unrelated failures.
17+ func IsCertRelatedTLSError (err error ) bool {
18+ if err == nil {
19+ return false
20+ }
21+
22+ var (
23+ unknownAuth x509.UnknownAuthorityError
24+ hostnameErr x509.HostnameError
25+ invalidCert x509.CertificateInvalidError
26+ )
27+ if errors .As (err , & unknownAuth ) ||
28+ errors .As (err , & hostnameErr ) ||
29+ errors .As (err , & invalidCert ) {
30+ return true
31+ }
32+
33+ msg := strings .ToLower (err .Error ())
34+ for _ , n := range []string {
35+ "certificate" ,
36+ "tls:" ,
37+ "x509:" ,
38+ "bad certificate" ,
39+ "certificate required" ,
40+ "certificate signed by unknown authority" ,
41+ "expired certificate" ,
42+ "authentication handshake failed" ,
43+ "remote error: tls:" ,
44+ } {
45+ if strings .Contains (msg , n ) {
46+ return true
47+ }
48+ }
49+ return false
50+ }
51+
52+ // WithCertRetry runs fn. On cert-related TLS failures it calls m.ForceRotate
53+ // and retries up to maxAttempts times with a short linear backoff.
54+ // Non-TLS errors are returned immediately. maxAttempts <= 0 defaults to 3.
55+ //
56+ // Typical use (gRPC dial, one-shot RPC, custom client):
57+ //
58+ // err := certmanager.WithCertRetry(ctx, mgr, 3, func(ctx context.Context) error {
59+ // return dialOrCall(ctx)
60+ // })
61+ func WithCertRetry (ctx context.Context , m * Manager , maxAttempts int , fn func (context.Context ) error ) error {
62+ if m == nil {
63+ return errors .New ("certmanager: WithCertRetry called with nil Manager" )
64+ }
65+ if fn == nil {
66+ return errors .New ("certmanager: WithCertRetry called with nil fn" )
67+ }
68+ if maxAttempts <= 0 {
69+ maxAttempts = 3
70+ }
71+
72+ var last error
73+ for attempt := 1 ; attempt <= maxAttempts ; attempt ++ {
74+ last = fn (ctx )
75+ if last == nil {
76+ return nil
77+ }
78+ if ! IsCertRelatedTLSError (last ) {
79+ return last
80+ }
81+
82+ m .logger .WithError (last ).WithField ("attempt" , attempt ).
83+ Warn ("TLS error; forcing certificate rotation before retry" )
84+
85+ if err := m .ForceRotate (ctx ); err != nil {
86+ m .logger .WithError (err ).Warn ("ForceRotate failed; still retrying" )
87+ }
88+
89+ if attempt == maxAttempts {
90+ break
91+ }
92+ select {
93+ case <- ctx .Done ():
94+ return ctx .Err ()
95+ case <- time .After (time .Duration (attempt ) * 300 * time .Millisecond ):
96+ }
97+ }
98+ return fmt .Errorf ("certmanager: call failed after %d attempts: %w" , maxAttempts , last )
99+ }
100+
101+ // CertRetryTransport is an http.RoundTripper that retries requests when the
102+ // underlying RoundTrip fails with a cert-related TLS error. On each such
103+ // failure it calls Manager.ForceRotate and optionally invokes AfterRotate
104+ // (e.g. to reload on-disk material into a tls.Config holder and close idle
105+ // connections).
106+ //
107+ // Base must be non-nil. AfterRotate may be nil. MaxAttempts <= 0 defaults to 3.
108+ //
109+ // Request bodies are only retried when req.GetBody is set (httputil.ReverseProxy
110+ // and most standard clients set this). Otherwise the transport returns the
111+ // TLS error without retrying to avoid sending a consumed body.
112+ type CertRetryTransport struct {
113+ Base http.RoundTripper
114+ Manager * Manager
115+ MaxAttempts int
116+ // AfterRotate is called after a successful or attempted ForceRotate,
117+ // before the next attempt. Use it to Reload cert files and
118+ // CloseIdleConnections on the underlying *http.Transport.
119+ AfterRotate func ()
120+ }
121+
122+ func (t * CertRetryTransport ) RoundTrip (req * http.Request ) (* http.Response , error ) {
123+ if t .Base == nil {
124+ return nil , errors .New ("certmanager: CertRetryTransport.Base is nil" )
125+ }
126+ max := t .MaxAttempts
127+ if max <= 0 {
128+ max = 3
129+ }
130+
131+ var last error
132+ for attempt := 1 ; attempt <= max ; attempt ++ {
133+ resp , err := t .Base .RoundTrip (req )
134+ if err == nil {
135+ return resp , nil
136+ }
137+ last = err
138+
139+ if ! IsCertRelatedTLSError (err ) {
140+ return nil , err
141+ }
142+
143+ if t .Manager != nil {
144+ t .Manager .logger .WithError (err ).WithField ("attempt" , attempt ).
145+ Warn ("HTTP TLS error; forcing certificate rotation before retry" )
146+ if ferr := t .Manager .ForceRotate (req .Context ()); ferr != nil {
147+ t .Manager .logger .WithError (ferr ).Warn ("ForceRotate failed; still retrying" )
148+ }
149+ }
150+ if t .AfterRotate != nil {
151+ t .AfterRotate ()
152+ }
153+
154+ if attempt == max {
155+ break
156+ }
157+
158+ if req .Body != nil && req .GetBody == nil {
159+ return nil , fmt .Errorf ("certmanager: tls retry aborted (request body not replayable): %w" , last )
160+ }
161+ if req .GetBody != nil {
162+ body , berr := req .GetBody ()
163+ if berr != nil {
164+ return nil , fmt .Errorf ("certmanager: tls retry aborted (GetBody): %v (last: %w)" , berr , last )
165+ }
166+ req .Body = body
167+ }
168+
169+ select {
170+ case <- req .Context ().Done ():
171+ return nil , req .Context ().Err ()
172+ case <- time .After (time .Duration (attempt ) * 300 * time .Millisecond ):
173+ }
174+ }
175+ return nil , fmt .Errorf ("certmanager: upstream failed after %d tls retries: %w" , max , last )
176+ }
0 commit comments