Skip to content

Commit 01e4c33

Browse files
committed
Add support for target scraping with SPIFFE
This is the first step toward #702 The feature is not available until the calling code invokes the WithSpiffeSourceFactory HTTPClientOptions, so no behaviour is yet changed. Use of that option will be the subject of a future change to https://github.com/prometheus/prometheus SPIFFE replaces the usual peer X.509 certificate verification algorithm with its own that checks SPIFFE IDs in URI SANs and checks against different sets of trust roots for different SPIFFE trust domains. Accordingly, none of the other TLS configuration parameters are applicable when SPIFFE is configured and vice versa and this mutual exclusivity is enforced in tls_config. There are two ways to set which SPIFFE ID should be expected from a scrape endpoint that is using HTTPS and SPIFFE: in the tls_config, and per-request. The former would be expected mainly on static scrape configs that define a single (perhaps replicated) scrape endpoint. For scrape configs with target discovery it is expected that different endpoints would present certificates with different SPIFFE IDs and so the per-request version would be used. It is intended that the peer's expected SPIFFE ID should come from a new special label __spiffe_id__ which would be populated by target discovery. That too will be part of the next change. For the purposes of this library, the per-request peer SPIFFE ID is supplied in the Context accompanying the Request. Signed-off-by: Kim Vandry <vandry@TZoNE.ORG>
1 parent d80d854 commit 01e4c33

12 files changed

+338
-0
lines changed

config/http_config.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ import (
3434

3535
"github.com/golang-jwt/jwt/v5"
3636
"github.com/mwitkow/go-conntrack"
37+
spiffebundle "github.com/spiffe/go-spiffe/v2/bundle/x509bundle"
38+
"github.com/spiffe/go-spiffe/v2/spiffeid"
39+
"github.com/spiffe/go-spiffe/v2/spiffetls"
40+
spiffetlsconfig "github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
41+
spiffesvid "github.com/spiffe/go-spiffe/v2/svid/x509svid"
3742
"go.yaml.in/yaml/v2"
3843
"golang.org/x/net/http/httpproxy"
3944
"golang.org/x/net/http2"
@@ -495,6 +500,11 @@ type DialContextFunc func(context.Context, string, string) (net.Conn, error)
495500
// NewTLSConfigFunc returns tls.Config.
496501
type NewTLSConfigFunc func(context.Context, *TLSConfig, ...TLSConfigOption) (*tls.Config, error)
497502

503+
type SpiffeSvidAndBundleSource interface {
504+
spiffesvid.Source
505+
spiffebundle.Source
506+
}
507+
498508
type httpClientOptions struct {
499509
dialContextFunc DialContextFunc
500510
newTLSConfigFunc NewTLSConfigFunc
@@ -504,6 +514,7 @@ type httpClientOptions struct {
504514
userAgent string
505515
host string
506516
secretManager SecretManager
517+
spiffeSourceFn func() (SpiffeSvidAndBundleSource, error)
507518
}
508519

509520
// HTTPClientOption defines an option that can be applied to the HTTP client.
@@ -569,6 +580,20 @@ func WithHost(host string) HTTPClientOption {
569580
})
570581
}
571582

583+
// WithSpiffeSourceFactory allows SPIFFE to be used with this HTTP client.
584+
// The provided function should return the same X509Source on every call
585+
// since all clients can share the same source. The source may either
586+
// already exist (in which case the function can just return a fixed value)
587+
// or be created on demand (in which case no X509Source will be created unless
588+
// SPIFFE is configured and used). The returned X509Source will not be closed
589+
// during the lifetime of the HTTPClient. The default is that there is no
590+
// factory function and SPIFFE is not available.
591+
func WithSpiffeSourceFactory(fn func() (SpiffeSvidAndBundleSource, error)) HTTPClientOption {
592+
return httpClientOptionFunc(func(opts *httpClientOptions) {
593+
opts.spiffeSourceFn = fn
594+
})
595+
}
596+
572597
type secretManagerOption struct {
573598
secretManager SecretManager
574599
}
@@ -623,6 +648,28 @@ func NewRoundTripperFromConfig(cfg HTTPClientConfig, name string, optFuncs ...HT
623648
return NewRoundTripperFromConfigWithContext(context.Background(), cfg, name, optFuncs...)
624649
}
625650

651+
func makeSpiffeDialer(configuredSpiffeID string, getSource func() (SpiffeSvidAndBundleSource, error)) func(ctx context.Context, network, addr string) (net.Conn, error) {
652+
return func(ctx context.Context, network, addr string) (net.Conn, error) {
653+
ids := configuredSpiffeID
654+
if idc := ctx.Value(SpiffeIDContextValue); idc != nil {
655+
ids = idc.(string)
656+
}
657+
peer, err := spiffeid.FromString(ids)
658+
if err != nil {
659+
return nil, fmt.Errorf("unparsable SPIFFE ID %q: %w", ids, err)
660+
}
661+
if getSource == nil {
662+
return nil, errors.New("SPIFFE requested but not configured")
663+
}
664+
source, err := getSource()
665+
if err != nil {
666+
return nil, err
667+
}
668+
mode := spiffetls.MTLSClientWithRawConfig(spiffetlsconfig.AuthorizeID(peer), source, source)
669+
return spiffetls.DialWithMode(ctx, network, addr, mode)
670+
}
671+
}
672+
626673
// NewRoundTripperFromConfigWithContext returns a new HTTP RoundTripper configured for the
627674
// given config.HTTPClientConfig and config.HTTPClientOption.
628675
// The name is used as go-conntrack metric label.
@@ -646,6 +693,11 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
646693
}
647694

648695
newRT := func(tlsConfig *tls.Config) (http.RoundTripper, error) {
696+
var dialTLS func(ctx context.Context, network, addr string) (net.Conn, error)
697+
if tlsConfig == nil {
698+
// Use SPIFFE
699+
dialTLS = makeSpiffeDialer(cfg.TLSConfig.SpiffeID, opts.spiffeSourceFn)
700+
}
649701
// The only timeout we care about is the configured scrape timeout.
650702
// It is applied on request. So we leave out any timings here.
651703
var rt http.RoundTripper = &http.Transport{
@@ -660,6 +712,7 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
660712
TLSHandshakeTimeout: 10 * time.Second,
661713
ExpectContinueTimeout: 1 * time.Second,
662714
DialContext: dialContext,
715+
DialTLSContext: dialTLS,
663716
}
664717
if opts.http2Enabled && cfg.EnableHTTP2 {
665718
http2t, err := http2.ConfigureTransports(rt.(*http.Transport))
@@ -736,6 +789,10 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
736789
return rt, nil
737790
}
738791

792+
if cfg.TLSConfig.SpiffeID != "" || cfg.TLSConfig.UseSpiffe {
793+
return newRT(nil)
794+
}
795+
739796
tlsConfig, err := opts.newTLSConfigFunc(ctx, &cfg.TLSConfig, WithSecretManager(opts.secretManager))
740797
if err != nil {
741798
return nil, err
@@ -1226,6 +1283,13 @@ type TLSConfig struct {
12261283
MinVersion TLSVersion `yaml:"min_version,omitempty" json:"min_version,omitempty"`
12271284
// Maximum TLS version.
12281285
MaxVersion TLSVersion `yaml:"max_version,omitempty" json:"max_version,omitempty"`
1286+
// Use SPIFFE to configure TLS. The special label `__spiffe_id__` on the
1287+
// scrape target configures the SPIFFE ID that must be presented.
1288+
UseSpiffe bool `yaml:"use_spiffe,omitempty" json:"use_spiffe,omitempty"`
1289+
// Use SPIFFE to configure TLS. The special label `__spiffe_id__` on the
1290+
// scrape target (first) or the value of this parameter (otherwise)
1291+
// configures the SPIFFE ID that must be presented.
1292+
SpiffeID string `yaml:"spiffe_id,omitempty" json:"spiffe_id,omitempty"`
12291293
}
12301294

12311295
// SetDirectory joins any relative file paths with dir.
@@ -1267,6 +1331,10 @@ func (c *TLSConfig) Validate() error {
12671331
return errors.New("exactly one of cert or cert_file must be configured when a client key is configured")
12681332
}
12691333

1334+
if (len(c.CA) > 0 || len(c.CAFile) > 0 || len(c.CARef) > 0 || len(c.Cert) > 0 || len(c.CertFile) > 0 || len(c.CertRef) > 0 || len(c.Key) > 0 || len(c.KeyFile) > 0 || len(c.KeyRef) > 0 || len(c.ServerName) > 0 || c.InsecureSkipVerify) && (len(c.SpiffeID) > 0 || c.UseSpiffe) {
1335+
return errors.New("either SPIFFE settings or other TLSConfig settings may be set but not both")
1336+
}
1337+
12701338
return nil
12711339
}
12721340

@@ -1625,3 +1693,7 @@ func (c *ProxyConfig) Proxy() (fn func(*http.Request) (*url.URL, error)) {
16251693
func (c *ProxyConfig) GetProxyConnectHeader() http.Header {
16261694
return c.ProxyConnectHeader.HTTPHeader()
16271695
}
1696+
1697+
type spiffeIDContextValue bool
1698+
1699+
const SpiffeIDContextValue = spiffeIDContextValue(false)

config/http_config_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ package config
1515

1616
import (
1717
"context"
18+
"crypto"
1819
"crypto/tls"
1920
"crypto/x509"
2021
"encoding/base64"
2122
"encoding/json"
23+
"encoding/pem"
2224
"errors"
2325
"fmt"
2426
"io"
@@ -36,6 +38,9 @@ import (
3638
"testing"
3739
"time"
3840

41+
spiffebundle "github.com/spiffe/go-spiffe/v2/bundle/x509bundle"
42+
"github.com/spiffe/go-spiffe/v2/spiffeid"
43+
spiffesvid "github.com/spiffe/go-spiffe/v2/svid/x509svid"
3944
"github.com/stretchr/testify/require"
4045
"go.yaml.in/yaml/v2"
4146
)
@@ -54,6 +59,12 @@ const (
5459
MissingCert = "missing/cert.crt"
5560
MissingKey = "missing/secret.key"
5661

62+
SpiffeWorkload1Cert = "testdata/spiffe.workload1.cert.pem"
63+
SpiffeWorkload1Key = "testdata/spiffe.workload1.key.pem"
64+
SpiffeWorkload2Cert = "testdata/spiffe.workload2.cert.pem"
65+
SpiffeWorkload2Key = "testdata/spiffe.workload2.key.pem"
66+
SpiffeBundle = "testdata/spiffe.bundle.pem"
67+
5768
ExpectedMessage = "I'm here to serve you!!!"
5869
ExpectedError = "expected error"
5970
AuthorizationCredentials = "theanswertothegreatquestionoflifetheuniverseandeverythingisfortytwo"
@@ -171,6 +182,37 @@ func newTestServer(handler func(w http.ResponseWriter, r *http.Request)) (*httpt
171182
return testServer, nil
172183
}
173184

185+
func newSpiffeTestServer() (*httptest.Server, error) {
186+
handler := func(w http.ResponseWriter, _ *http.Request) {
187+
fmt.Fprint(w, ExpectedMessage)
188+
}
189+
testServer := httptest.NewUnstartedServer(http.HandlerFunc(handler))
190+
191+
tlsCAChain, err := os.ReadFile(SpiffeBundle)
192+
if err != nil {
193+
return nil, fmt.Errorf("Can't read %s", SpiffeBundle)
194+
}
195+
serverCertificate, err := tls.LoadX509KeyPair(SpiffeWorkload1Cert, SpiffeWorkload1Key)
196+
if err != nil {
197+
return nil, fmt.Errorf("Can't load X509 key pair %s - %s", SpiffeWorkload1Cert, SpiffeWorkload1Key)
198+
}
199+
200+
rootCAs := x509.NewCertPool()
201+
rootCAs.AppendCertsFromPEM(tlsCAChain)
202+
203+
testServer.TLS = &tls.Config{
204+
Certificates: make([]tls.Certificate, 1),
205+
RootCAs: rootCAs,
206+
ClientAuth: tls.RequireAndVerifyClientCert,
207+
ClientCAs: rootCAs,
208+
}
209+
testServer.TLS.Certificates[0] = serverCertificate
210+
211+
testServer.StartTLS()
212+
213+
return testServer, nil
214+
}
215+
174216
func TestNewClientFromConfig(t *testing.T) {
175217
newClientValidConfig := []struct {
176218
clientConfig HTTPClientConfig
@@ -1280,6 +1322,131 @@ func TestTLSRoundTripper_Inline(t *testing.T) {
12801322
}
12811323
}
12821324

1325+
type testSpiffeSource struct{}
1326+
1327+
func (*testSpiffeSource) GetX509SVID() (*spiffesvid.SVID, error) {
1328+
cert, err := tls.LoadX509KeyPair(SpiffeWorkload2Cert, SpiffeWorkload2Key)
1329+
if err != nil {
1330+
return nil, fmt.Errorf("Can't load X509 key pair %s - %s", SpiffeWorkload2Cert, SpiffeWorkload2Key)
1331+
}
1332+
if signer, ok := cert.PrivateKey.(crypto.Signer); ok {
1333+
return &spiffesvid.SVID{
1334+
ID: spiffeid.RequireFromString("spiffe://example.org/workload2"),
1335+
Certificates: []*x509.Certificate{cert.Leaf},
1336+
PrivateKey: signer,
1337+
}, nil
1338+
}
1339+
return nil, errors.New("private key is not crypto.Signer")
1340+
}
1341+
1342+
func (*testSpiffeSource) GetX509BundleForTrustDomain(trustDomain spiffeid.TrustDomain) (*spiffebundle.Bundle, error) {
1343+
if trustDomain.Name() == "example.org" {
1344+
bundlePem, err := os.ReadFile(SpiffeBundle)
1345+
if err != nil {
1346+
return nil, fmt.Errorf("Can't read %s", SpiffeBundle)
1347+
}
1348+
var bundle []*x509.Certificate
1349+
for len(bundlePem) > 0 {
1350+
b, more := pem.Decode(bundlePem)
1351+
c, err := x509.ParseCertificate(b.Bytes)
1352+
if err != nil {
1353+
return nil, fmt.Errorf("Can't parse %s as a certificate", SpiffeBundle)
1354+
}
1355+
bundlePem = more
1356+
bundle = append(bundle, c)
1357+
}
1358+
return spiffebundle.FromX509Authorities(trustDomain, bundle), nil
1359+
}
1360+
return nil, fmt.Errorf("No bundle for trust domain %v", trustDomain)
1361+
}
1362+
1363+
func spiffeMaker() (SpiffeSvidAndBundleSource, error) {
1364+
return &testSpiffeSource{}, nil
1365+
}
1366+
1367+
func TestTLSRoundTripper_SPIFFE(t *testing.T) {
1368+
testServer, err := newSpiffeTestServer()
1369+
require.NoError(t, err)
1370+
defer testServer.Close()
1371+
1372+
testCases := []struct {
1373+
disabled bool
1374+
useSpiffe bool
1375+
confID string
1376+
ctxID string
1377+
1378+
errMsg string
1379+
}{
1380+
{
1381+
disabled: true,
1382+
confID: "spiffe://trust.domain/foo",
1383+
errMsg: "SPIFFE requested but not configured",
1384+
},
1385+
{
1386+
confID: "spiffe://example.org/workload1",
1387+
},
1388+
{
1389+
confID: "spiffe://example.org/wrong",
1390+
errMsg: "unexpected ID",
1391+
},
1392+
{
1393+
confID: "unparsable",
1394+
errMsg: "unparsable",
1395+
},
1396+
{
1397+
useSpiffe: true,
1398+
ctxID: "spiffe://example.org/workload1",
1399+
},
1400+
{
1401+
confID: "spiffe://overridden/ignored",
1402+
ctxID: "spiffe://example.org/workload1",
1403+
},
1404+
}
1405+
1406+
for i, tc := range testCases {
1407+
tc := tc
1408+
t.Run(strconv.Itoa(i), func(t *testing.T) {
1409+
cfg := HTTPClientConfig{
1410+
TLSConfig: TLSConfig{
1411+
UseSpiffe: tc.useSpiffe,
1412+
SpiffeID: tc.confID,
1413+
},
1414+
}
1415+
1416+
var opts []HTTPClientOption
1417+
if !tc.disabled {
1418+
opts = append(opts, WithSpiffeSourceFactory(spiffeMaker))
1419+
}
1420+
c, err := NewClientFromConfig(cfg, "test", opts...)
1421+
require.NoErrorf(t, err, "Error creating HTTP client: %v", err)
1422+
req, err := http.NewRequest(http.MethodGet, testServer.URL, nil)
1423+
require.NoErrorf(t, err, "Error creating HTTP request: %v", err)
1424+
ctx := context.Background()
1425+
if tc.ctxID != "" {
1426+
ctx = context.WithValue(ctx, SpiffeIDContextValue, tc.ctxID)
1427+
}
1428+
r, err := c.Do(req.WithContext(ctx))
1429+
if tc.errMsg != "" {
1430+
require.ErrorContainsf(t, err, tc.errMsg, "Expected error message to contain %q, got %q", tc.errMsg, err)
1431+
return
1432+
} else if err != nil {
1433+
t.Fatalf("Error executing HTTP request: %v", err)
1434+
}
1435+
1436+
b, err := io.ReadAll(r.Body)
1437+
r.Body.Close()
1438+
if err != nil {
1439+
t.Errorf("Can't read the server response body")
1440+
}
1441+
1442+
got := strings.TrimSpace(string(b))
1443+
if ExpectedMessage != got {
1444+
t.Errorf("The expected message %q differs from the obtained message %q", ExpectedMessage, got)
1445+
}
1446+
})
1447+
}
1448+
}
1449+
12831450
func TestTLSRoundTripperRaces(t *testing.T) {
12841451
bs := getCertificateBlobs(t)
12851452

config/testdata/spiffe.bundle.pem

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIICBDCCAaqgAwIBAgIRAP6xM1DuTb3X0lAhRTLmNhMwCgYIKoZIzj0EAwIwUDEL
3+
MAkGA1UEBhMCVVMxDzANBgNVBAoTBlNQSUZGRTEwMC4GA1UEBRMnMzM4NTQzOTg4
4+
Mjg4MjIzNDk5NzE4MjE1ODYwMjk1NDUyNDcyODUxMCAXDTI1MDkwNzA5MjQzMVoY
5+
DzIyMjUwNzIxMDkyNDQxWjBQMQswCQYDVQQGEwJVUzEPMA0GA1UEChMGU1BJRkZF
6+
MTAwLgYDVQQFEyczMzg1NDM5ODgyODgyMjM0OTk3MTgyMTU4NjAyOTU0NTI0NzI4
7+
NTEwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQtQEBKDICx1ywQOjIiYGOO6DI8
8+
edAy3MxMQrWs737AgD45ixo1fNIGHfpD9v0WH3kq5C9ycB5YNpIaSdfXJiiJo2Mw
9+
YTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUZ5LV
10+
HKgEZ5Kvs50NaJboDBt+U/owHwYDVR0RBBgwFoYUc3BpZmZlOi8vZXhhbXBsZS5v
11+
cmcwCgYIKoZIzj0EAwIDSAAwRQIgOvUg85SrI2hmeCStTkCF9or0Vgcuzdifwfq5
12+
qQBJ9W8CIQD13tnaExaopel4BbweByAnXHqYEwomLlMbLs6ldz7a7w==
13+
-----END CERTIFICATE-----
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIICGjCCAb+gAwIBAgIQS8qrtPjeVHsY0SXsfT8B/zAKBggqhkjOPQQDAjBQMQsw
3+
CQYDVQQGEwJVUzEPMA0GA1UEChMGU1BJRkZFMTAwLgYDVQQFEyczMzg1NDM5ODgy
4+
ODgyMjM0OTk3MTgyMTU4NjAyOTU0NTI0NzI4NTEwIBcNMjUwOTA3MDkyNjUzWhgP
5+
MjEwMDA4MjAwOTI3MDNaMB0xCzAJBgNVBAYTAlVTMQ4wDAYDVQQKEwVTUElSRTBZ
6+
MBMGByqGSM49AgEGCCqGSM49AwEHA0IABO+FhNA7IYyFNA/BDOovyNVlwZVesbfj
7+
knv/alVHrJ8Z5PPLv5RC4EqxDUCIdYH8IcfpXbRUsxV3Y8oIRkQFWpOjgaswgagw
8+
DgYDVR0PAQH/BAQDAgOoMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAM
9+
BgNVHRMBAf8EAjAAMB0GA1UdDgQWBBQJ/hirbRAZC32OWIHWBdVfOF67gzAfBgNV
10+
HSMEGDAWgBRnktUcqARnkq+znQ1olugMG35T+jApBgNVHREEIjAghh5zcGlmZmU6
11+
Ly9leGFtcGxlLm9yZy93b3JrbG9hZDEwCgYIKoZIzj0EAwIDSQAwRgIhAPIkKNdq
12+
SBb9ROIe2eo5ed1/0ay89UMjd+dxlkuyX8jAAiEAx1P1bBnzAVhuQx0YbzG2lJnK
13+
QBCtPcGZ/amdWZt9G0A=
14+
-----END CERTIFICATE-----
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgorRBVyov8trytD8G
3+
v094aUhNkeOlBfV6mdZfC7rm66+hRANCAATvhYTQOyGMhTQPwQzqL8jVZcGVXrG3
4+
45J7/2pVR6yfGeTzy7+UQuBKsQ1AiHWB/CHH6V20VLMVd2PKCEZEBVqT
5+
-----END PRIVATE KEY-----

0 commit comments

Comments
 (0)