Skip to content

Commit 69a097c

Browse files
authored
Feature: mtls support in httpclient - net.Client (#4026)
Example skipper/net.Client with mTLS and cert rotation ``` rootCAs, _ := x509.SystemCertPool() caCertPEM, _ := os.ReadFile("path/to/your/custom-ca.crt") rootCAs.AppendCertsFromPEM(caCertPEM) net.NewClient(net.Options{ CertFile: certFile, KeyFile: keyFile, CertRefreshInterval: time.Minute, RootCAs: rootCAs, }) ``` --------- Signed-off-by: Sandor Szücs <sandor.szuecs@zalando.de>
1 parent 1cfe962 commit 69a097c

3 files changed

Lines changed: 1004 additions & 1 deletion

File tree

net/httpclient.go

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package net
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/tls"
7+
"crypto/x509"
68
"fmt"
79
"io"
810
"net/http"
911
"net/http/httptrace"
1012
"net/url"
1113
"strings"
1214
"sync"
15+
"sync/atomic"
1316
"time"
1417
"unicode/utf8"
1518

@@ -42,6 +45,109 @@ type clientTraceTimeT int
4245

4346
const clientTraceTime clientTraceTimeT = 1
4447

48+
type CertReloader struct {
49+
cert atomic.Pointer[tls.Certificate]
50+
sp *secrets.SecretPaths
51+
certFile string
52+
keyFile string
53+
lastCertPEM []byte
54+
lastKeyPEM []byte
55+
quit chan struct{}
56+
once sync.Once
57+
log logging.Logger
58+
}
59+
60+
// NewCertReloader reads cert and key from provided files and starts
61+
// an update loop that every interval reads the files. If cert or
62+
// key changed and are valid it will swap the data, such that
63+
// GetClientCertificate returns the new rotated *tls.Certificate.
64+
// You have to use Close() in order to not leak a goroutine.
65+
func NewCertReloader(certFile, keyFile string, interval time.Duration, log logging.Logger) (*CertReloader, error) {
66+
sp := secrets.NewSecretPaths(interval)
67+
if err := sp.Add(certFile); err != nil {
68+
sp.Close()
69+
return nil, fmt.Errorf("cert file: %w", err)
70+
}
71+
if err := sp.Add(keyFile); err != nil {
72+
sp.Close()
73+
return nil, fmt.Errorf("key file: %w", err)
74+
}
75+
certPEM, ok := sp.GetSecret(certFile)
76+
if !ok {
77+
sp.Close()
78+
return nil, fmt.Errorf("cert file not found: %s", certFile)
79+
}
80+
keyPEM, ok := sp.GetSecret(keyFile)
81+
if !ok {
82+
sp.Close()
83+
return nil, fmt.Errorf("key file not found: %s", keyFile)
84+
}
85+
cert, err := tls.X509KeyPair(certPEM, keyPEM)
86+
if err != nil {
87+
sp.Close()
88+
return nil, fmt.Errorf("initial key pair: %w", err)
89+
}
90+
cr := &CertReloader{
91+
sp: sp,
92+
certFile: certFile,
93+
keyFile: keyFile,
94+
lastCertPEM: certPEM,
95+
lastKeyPEM: keyPEM,
96+
quit: make(chan struct{}),
97+
log: log,
98+
}
99+
cr.cert.Store(&cert)
100+
go cr.refreshLoop(interval)
101+
return cr, nil
102+
}
103+
104+
func (cr *CertReloader) refreshLoop(interval time.Duration) {
105+
ticker := time.NewTicker(interval)
106+
defer ticker.Stop()
107+
for {
108+
select {
109+
case <-ticker.C:
110+
cr.reload()
111+
case <-cr.quit:
112+
return
113+
}
114+
}
115+
}
116+
117+
func (cr *CertReloader) reload() {
118+
certPEM, certOK := cr.sp.GetSecret(cr.certFile)
119+
keyPEM, keyOK := cr.sp.GetSecret(cr.keyFile)
120+
if !certOK || !keyOK {
121+
return
122+
}
123+
if bytes.Equal(certPEM, cr.lastCertPEM) && bytes.Equal(keyPEM, cr.lastKeyPEM) {
124+
return
125+
}
126+
cert, err := tls.X509KeyPair(certPEM, keyPEM)
127+
if err != nil {
128+
cr.log.Errorf("certReloader: Failed to parse key pair: %v", err)
129+
return
130+
}
131+
cr.lastCertPEM = certPEM
132+
cr.lastKeyPEM = keyPEM
133+
cr.log.Info("certReloader: updated cert")
134+
cr.cert.Store(&cert)
135+
}
136+
137+
// GetClientCertificate returns the tls.Certificate and can be used as
138+
// tls.Config.GetClientCertificate to get rotated client certs.
139+
func (cr *CertReloader) GetClientCertificate(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
140+
return cr.cert.Load(), nil
141+
}
142+
143+
// Close stops all background goroutines.
144+
func (cr *CertReloader) Close() {
145+
cr.once.Do(func() {
146+
close(cr.quit)
147+
cr.sp.Close()
148+
})
149+
}
150+
45151
// Client adds additional features like Bearer token injection, and
46152
// opentracing to the wrapped http.Client with the same interface as
47153
// http.Client from the stdlib.
@@ -50,6 +156,7 @@ type Client struct {
50156
client http.Client
51157
tr *Transport
52158
sr secrets.SecretsReader
159+
cr *CertReloader
53160
}
54161

55162
// NewClient creates a wrapped http.Client and uses Transport to
@@ -74,11 +181,32 @@ func NewClient(o Options) *Client {
74181
}
75182
sp := secrets.NewSecretPaths(o.BearerTokenRefreshInterval)
76183
if err := sp.Add(o.BearerTokenFile); err != nil {
77-
o.Log.Errorf("failed to read secret: %v", err)
184+
o.Log.Errorf("Failed to read secret: %v", err)
78185
}
79186
sr = secrets.NewStaticDelegateSecret(sp, o.BearerTokenFile)
80187
}
81188

189+
var cr *CertReloader
190+
if o.CertFile != "" && o.KeyFile != "" && o.Transport == nil {
191+
if o.CertRefreshInterval == 0 {
192+
if o.BearerTokenRefreshInterval != 0 {
193+
o.CertRefreshInterval = o.BearerTokenRefreshInterval
194+
} else {
195+
o.CertRefreshInterval = defaultRefreshInterval
196+
}
197+
}
198+
var err error
199+
cr, err = NewCertReloader(o.CertFile, o.KeyFile, o.CertRefreshInterval, o.Log)
200+
if err != nil {
201+
o.Log.Errorf("Failed to initialize cert reloader: %v", err)
202+
} else {
203+
tr.tr.TLSClientConfig = &tls.Config{
204+
RootCAs: o.RootCAs,
205+
GetClientCertificate: cr.GetClientCertificate,
206+
}
207+
}
208+
}
209+
82210
c := &Client{
83211
once: sync.Once{},
84212
client: http.Client{
@@ -88,6 +216,7 @@ func NewClient(o Options) *Client {
88216
},
89217
tr: tr,
90218
sr: sr,
219+
cr: cr,
91220
}
92221

93222
return c
@@ -99,6 +228,9 @@ func (c *Client) Close() {
99228
if c.sr != nil {
100229
c.sr.Close()
101230
}
231+
if c.cr != nil {
232+
c.cr.Close()
233+
}
102234
})
103235
}
104236

@@ -229,6 +361,18 @@ type Options struct {
229361
// SecretsReader is used to read and refresh bearer tokens
230362
SecretsReader secrets.SecretsReader
231363

364+
// CertFile is the path to a PEM-encoded client certificate for mTLS.
365+
// Must be set together with KeyFile. Ignored when Transport is non-nil.
366+
CertFile string
367+
// KeyFile is the path to a PEM-encoded private key for mTLS.
368+
// Must be set together with CertFile. Ignored when Transport is non-nil.
369+
KeyFile string
370+
// CertRefreshInterval is how often CertFile/KeyFile are re-read.
371+
// Defaults to BearerTokenRefreshInterval, or 5 minutes if both are zero.
372+
CertRefreshInterval time.Duration
373+
// RootCAs to pass as part of the TLSClientConfig to the underlying http.Transport
374+
RootCAs *x509.CertPool
375+
232376
// Log is used for error logging
233377
Log logging.Logger
234378

0 commit comments

Comments
 (0)