Skip to content

Commit 10fce42

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.
1 parent 5ef1ac5 commit 10fce42

12 files changed

+341
-1
lines changed

config/http_config.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import (
3232
"time"
3333

3434
conntrack "github.com/mwitkow/go-conntrack"
35+
spiffebundle "github.com/spiffe/go-spiffe/v2/bundle/x509bundle"
36+
"github.com/spiffe/go-spiffe/v2/spiffeid"
37+
"github.com/spiffe/go-spiffe/v2/spiffetls"
38+
spiffetlsconfig "github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
39+
spiffesvid "github.com/spiffe/go-spiffe/v2/svid/x509svid"
3540
"go.yaml.in/yaml/v2"
3641
"golang.org/x/net/http/httpproxy"
3742
"golang.org/x/net/http2"
@@ -456,6 +461,11 @@ type DialContextFunc func(context.Context, string, string) (net.Conn, error)
456461
// NewTLSConfigFunc returns tls.Config.
457462
type NewTLSConfigFunc func(context.Context, *TLSConfig, ...TLSConfigOption) (*tls.Config, error)
458463

464+
type SpiffeSvidAndBundleSource interface {
465+
spiffesvid.Source
466+
spiffebundle.Source
467+
}
468+
459469
type httpClientOptions struct {
460470
dialContextFunc DialContextFunc
461471
newTLSConfigFunc NewTLSConfigFunc
@@ -465,6 +475,7 @@ type httpClientOptions struct {
465475
userAgent string
466476
host string
467477
secretManager SecretManager
478+
spiffeSourceFn func() (SpiffeSvidAndBundleSource, error)
468479
}
469480

470481
// HTTPClientOption defines an option that can be applied to the HTTP client.
@@ -530,6 +541,20 @@ func WithHost(host string) HTTPClientOption {
530541
})
531542
}
532543

544+
// WithSpiffeSourceFactory allows SPIFFE to be used with this HTTP client.
545+
// The provided function should return the same X509Source on every call
546+
// since all clients can share the same source. The source may either
547+
// already exist (in which case the function can just return a fixed value)
548+
// or be created on demand (in which case no X509Source will be created unless
549+
// SPIFFE is configured and used). The returned X509Source will not be closed
550+
// during the lifetime of the HTTPClient. The default is that there is no
551+
// factory function and SPIFFE is not available.
552+
func WithSpiffeSourceFactory(fn func() (SpiffeSvidAndBundleSource, error)) HTTPClientOption {
553+
return httpClientOptionFunc(func(opts *httpClientOptions) {
554+
opts.spiffeSourceFn = fn
555+
})
556+
}
557+
533558
type secretManagerOption struct {
534559
secretManager SecretManager
535560
}
@@ -584,6 +609,28 @@ func NewRoundTripperFromConfig(cfg HTTPClientConfig, name string, optFuncs ...HT
584609
return NewRoundTripperFromConfigWithContext(context.Background(), cfg, name, optFuncs...)
585610
}
586611

612+
func makeSpiffeDialer(configuredSpiffeID string, getSource func() (SpiffeSvidAndBundleSource, error)) func(ctx context.Context, network, addr string) (net.Conn, error) {
613+
return func(ctx context.Context, network, addr string) (net.Conn, error) {
614+
ids := configuredSpiffeID
615+
if idc := ctx.Value(SpiffeIDContextValue); idc != nil {
616+
ids = idc.(string)
617+
}
618+
peer, err := spiffeid.FromString(ids)
619+
if err != nil {
620+
return nil, fmt.Errorf("unparsable SPIFFE ID %q: %w", ids, err)
621+
}
622+
if getSource == nil {
623+
return nil, errors.New("SPIFFE requested but not configured")
624+
}
625+
source, err := getSource()
626+
if err != nil {
627+
return nil, err
628+
}
629+
mode := spiffetls.MTLSClientWithRawConfig(spiffetlsconfig.AuthorizeID(peer), source, source)
630+
return spiffetls.DialWithMode(ctx, network, addr, mode)
631+
}
632+
}
633+
587634
// NewRoundTripperFromConfigWithContext returns a new HTTP RoundTripper configured for the
588635
// given config.HTTPClientConfig and config.HTTPClientOption.
589636
// The name is used as go-conntrack metric label.
@@ -607,6 +654,11 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
607654
}
608655

609656
newRT := func(tlsConfig *tls.Config) (http.RoundTripper, error) {
657+
var dialTLS func(ctx context.Context, network, addr string) (net.Conn, error)
658+
if tlsConfig == nil {
659+
// Use SPIFFE
660+
dialTLS = makeSpiffeDialer(cfg.TLSConfig.SpiffeID, opts.spiffeSourceFn)
661+
}
610662
// The only timeout we care about is the configured scrape timeout.
611663
// It is applied on request. So we leave out any timings here.
612664
var rt http.RoundTripper = &http.Transport{
@@ -621,6 +673,7 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
621673
TLSHandshakeTimeout: 10 * time.Second,
622674
ExpectContinueTimeout: 1 * time.Second,
623675
DialContext: dialContext,
676+
DialTLSContext: dialTLS,
624677
}
625678
if opts.http2Enabled && cfg.EnableHTTP2 {
626679
// HTTP/2 support is golang had many problematic cornercases where
@@ -691,6 +744,10 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
691744
return rt, nil
692745
}
693746

747+
if cfg.TLSConfig.SpiffeID != "" || cfg.TLSConfig.UseSpiffe {
748+
return newRT(nil)
749+
}
750+
694751
tlsConfig, err := opts.newTLSConfigFunc(ctx, &cfg.TLSConfig, WithSecretManager(opts.secretManager))
695752
if err != nil {
696753
return nil, err
@@ -1140,6 +1197,13 @@ type TLSConfig struct {
11401197
MinVersion TLSVersion `yaml:"min_version,omitempty" json:"min_version,omitempty"`
11411198
// Maximum TLS version.
11421199
MaxVersion TLSVersion `yaml:"max_version,omitempty" json:"max_version,omitempty"`
1200+
// Use SPIFFE to configure TLS. The special label `__spiffe_id__` on the
1201+
// scrape target configures the SPIFFE ID that must be presented.
1202+
UseSpiffe bool `yaml:"use_spiffe,omitempty" json:"use_spiffe,omitempty"`
1203+
// Use SPIFFE to configure TLS. The special label `__spiffe_id__` on the
1204+
// scrape target (first) or the value of this parameter (otherwise)
1205+
// configures the SPIFFE ID that must be presented.
1206+
SpiffeID string `yaml:"spiffe_id,omitempty" json:"spiffe_id,omitempty"`
11431207
}
11441208

11451209
// SetDirectory joins any relative file paths with dir.
@@ -1181,6 +1245,10 @@ func (c *TLSConfig) Validate() error {
11811245
return errors.New("exactly one of cert or cert_file must be configured when a client key is configured")
11821246
}
11831247

1248+
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) {
1249+
return errors.New("either SPIFFE settings or other TLSConfig settings may be set but not both")
1250+
}
1251+
11841252
return nil
11851253
}
11861254

@@ -1536,3 +1604,7 @@ func (c *ProxyConfig) Proxy() (fn func(*http.Request) (*url.URL, error)) {
15361604
func (c *ProxyConfig) GetProxyConnectHeader() http.Header {
15371605
return c.ProxyConnectHeader.HTTPHeader()
15381606
}
1607+
1608+
type spiffeIDContextValue bool
1609+
1610+
const SpiffeIDContextValue = spiffeIDContextValue(false)

config/http_config_test.go

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

1616
import (
1717
"context"
18+
"crypto"
1819
"crypto/tls"
1920
"crypto/x509"
2021
"encoding/json"
22+
"encoding/pem"
2123
"errors"
2224
"fmt"
2325
"io"
@@ -35,6 +37,9 @@ import (
3537
"testing"
3638
"time"
3739

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

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

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

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

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)