From 5a00a67d95d82d64ad088c5f7d9a2fbde03d940e Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 6 Mar 2024 09:48:16 +0000 Subject: [PATCH] Machine ID: SPIFFE support in `tbot` (#37772) * Experiment with issuing SVIDs from `tbot` * Fix missing license headers * Remove interceptors for now * Add basic required grpc server interceptors * Break out CA rotation handler * Tidy up service structure and omit more cert attributes * Various tidying and adding godoc comments * Move dependency to main require block * Fix tests and add test for spiffe-workload-api in config * Add config tests for SPIFFESVIDOutput * Add config tests for SPIFFEWorkloadAPIService * Add otel spans * Add e2e test for the spiffe workload api functionality * Fix trust bundle fetching to use correct client * Reuse outputs service to produce identity for spiffe workload service * Add godocs for SPIFFESVIDOUTPUT * Tidy up Render * Add consts for pem types * Improve logging Co-authored-by: Isaiah Becker-Mayer --------- Co-authored-by: Isaiah Becker-Mayer --- go.mod | 4 +- go.sum | 4 + lib/tbot/bot/service.go | 44 -- lib/tbot/config/bot.go | 6 + lib/tbot/config/bot_test.go | 10 + lib/tbot/config/config.go | 35 +- lib/tbot/config/config_test.go | 15 +- lib/tbot/config/output_client_credential.go | 10 + lib/tbot/config/output_spiffe_svid.go | 300 +++++++++++ lib/tbot/config/output_spiffe_svid_test.go | 150 ++++++ lib/tbot/config/service_example.go | 22 +- .../config/service_spiffe_workload_api.go | 70 +++ .../service_spiffe_workload_api_test.go | 163 ++++++ .../TestBotConfig_YAML/standard_config.golden | 10 + .../TestSPIFFESVIDOutput_YAML/full.golden | 12 + .../TestSPIFFESVIDOutput_YAML/minimal.golden | 5 + .../full.golden | 11 + .../minimal.golden | 4 + lib/tbot/service_example.go | 50 ++ lib/tbot/service_outputs.go | 21 +- lib/tbot/service_spiffe_workload_api.go | 489 ++++++++++++++++++ lib/tbot/tbot.go | 65 ++- lib/tbot/tbot_test.go | 104 ++++ lib/tbot/testhelpers/srv.go | 9 +- 24 files changed, 1532 insertions(+), 81 deletions(-) delete mode 100644 lib/tbot/bot/service.go create mode 100644 lib/tbot/config/output_spiffe_svid.go create mode 100644 lib/tbot/config/output_spiffe_svid_test.go create mode 100644 lib/tbot/config/service_spiffe_workload_api.go create mode 100644 lib/tbot/config/service_spiffe_workload_api_test.go create mode 100644 lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/full.golden create mode 100644 lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/minimal.golden create mode 100644 lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden create mode 100644 lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden create mode 100644 lib/tbot/service_example.go create mode 100644 lib/tbot/service_spiffe_workload_api.go diff --git a/go.mod b/go.mod index 373955b1f52..331489776cf 100644 --- a/go.mod +++ b/go.mod @@ -112,6 +112,7 @@ require ( github.com/gravitational/trace v1.3.1 github.com/gravitational/ttlmap v0.0.0-20171116003245-91fd36b9004c github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.0 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.1 github.com/guptarohit/asciigraph v0.5.6 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/golang-lru/v2 v2.0.7 @@ -165,6 +166,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/snowflakedb/gosnowflake v1.8.0 github.com/spf13/cobra v1.8.0 + github.com/spiffe/go-spiffe/v2 v2.1.7 github.com/stretchr/testify v1.8.4 github.com/tiktoken-go/tokenizer v0.1.0 github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb @@ -355,7 +357,6 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/gosuri/uitable v0.0.4 // indirect github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect @@ -488,6 +489,7 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect github.com/yuin/gopher-lua v1.1.0 // indirect + github.com/zeebo/errs v1.3.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300 // indirect github.com/zmap/zlint/v3 v3.5.0 // indirect diff --git a/go.sum b/go.sum index fd2b5fdae21..8758d707db9 100644 --- a/go.sum +++ b/go.sum @@ -1357,6 +1357,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/spiffe/go-spiffe/v2 v2.1.7 h1:VUkM1yIyg/x8X7u1uXqSRVRCdMdfRIEdFBzpqoeASGk= +github.com/spiffe/go-spiffe/v2 v2.1.7/go.mod h1:QJDGdhXllxjxvd5B+2XnhhXB/+rC8gr+lNrtOryiWeE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -1441,6 +1443,8 @@ github.com/zalando/go-keyring v0.2.2 h1:f0xmpYiSrHtSNAVgwip93Cg8tuF45HJM6rHq/A5R github.com/zalando/go-keyring v0.2.2/go.mod h1:sI3evg9Wvpw3+n4SqplGSJUMwtDeROfD4nsFz4z9PG0= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/errs v1.3.0 h1:hmiaKqgYZzcVgRL1Vkc1Mn2914BbzB0IBxs+ebeutGs= +github.com/zeebo/errs v1.3.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= diff --git a/lib/tbot/bot/service.go b/lib/tbot/bot/service.go deleted file mode 100644 index d2e8ae5b097..00000000000 --- a/lib/tbot/bot/service.go +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package bot - -import "context" - -// Service is a long-running sub-component of tbot. -type Service interface { - // String returns a human-readable name for the service that can be used - // in logging. It should identify the type of the service and any top - // level configuration that could distinguish it from a same-type service. - String() string - // Run starts the service and blocks until the service exits. It should - // return a nil error if the service exits successfully and an error - // if it is unable to proceed. It should exit gracefully if the context - // is canceled. - Run(ctx context.Context) error -} - -// OneShotService is a [Service] that offers a mode in which it runs a single -// time and then exits. This aligns with the `--oneshot` mode of tbot. -type OneShotService interface { - Service - // OneShot runs the service once and then exits. It should return a nil - // error if the service exits successfully and an error if it is unable - // to proceed. It should exit gracefully if the context is canceled. - OneShot(ctx context.Context) error -} diff --git a/lib/tbot/config/bot.go b/lib/tbot/config/bot.go index ac728ce09d1..4371d8d74a0 100644 --- a/lib/tbot/config/bot.go +++ b/lib/tbot/config/bot.go @@ -21,8 +21,11 @@ package config import ( "context" + "google.golang.org/grpc" + "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/client/webclient" + machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1" "github.com/gravitational/teleport/api/types" ) @@ -55,4 +58,7 @@ type provider interface { // GetCertAuthority uses the impersonatedClient to call GetCertAuthority. GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) + + // SignX509SVIDs uses the impersonatedClient to call SignX509SVIDs. + SignX509SVIDs(ctx context.Context, in *machineidv1pb.SignX509SVIDsRequest, opts ...grpc.CallOption) (*machineidv1pb.SignX509SVIDsResponse, error) } diff --git a/lib/tbot/config/bot_test.go b/lib/tbot/config/bot_test.go index 142b1a8dd5e..834f9ea3c45 100644 --- a/lib/tbot/config/bot_test.go +++ b/lib/tbot/config/bot_test.go @@ -28,10 +28,12 @@ import ( "github.com/jonboulle/clockwork" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" + "google.golang.org/grpc" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/client/webclient" "github.com/gravitational/teleport/api/constants" + machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/auth/testauthority" @@ -143,6 +145,14 @@ func (p *mockProvider) AuthPing(_ context.Context) (*proto.PingResponse, error) }, nil } +func (p *mockProvider) SignX509SVIDs( + ctx context.Context, + in *machineidv1pb.SignX509SVIDsRequest, + opts ...grpc.CallOption, +) (*machineidv1pb.SignX509SVIDsResponse, error) { + return nil, nil +} + func (p *mockProvider) GenerateHostCert( ctx context.Context, req *trustpb.GenerateHostCertRequest, ) (*trustpb.GenerateHostCertResponse, error) { diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index 7e98eced5a6..1b3435119e9 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -265,7 +265,7 @@ type BotConfig struct { Onboarding OnboardingConfig `yaml:"onboarding,omitempty"` Storage *StorageConfig `yaml:"storage,omitempty"` Outputs Outputs `yaml:"outputs,omitempty"` - Services Services `yaml:"services,omitempty"` + Services ServiceConfigs `yaml:"services,omitempty"` Debug bool `yaml:"debug"` AuthServer string `yaml:"auth_server"` @@ -341,6 +341,13 @@ func (conf *BotConfig) CheckAndSetDefaults() error { } } + // Validate configured services + for i, service := range conf.Services { + if err := service.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validating service[%d]", i) + } + } + if conf.CertificateTTL == 0 { conf.CertificateTTL = DefaultCertificateTTL } @@ -385,11 +392,17 @@ func (conf *BotConfig) CheckAndSetDefaults() error { return nil } -// Services assists polymorphic unmarshaling of a slice of Services. -type Services []bot.Service +// ServiceConfig is an interface over the various service configurations. +type ServiceConfig interface { + Type() string + CheckAndSetDefaults() error +} -func (o *Services) UnmarshalYAML(node *yaml.Node) error { - var out []bot.Service +// ServiceConfigs assists polymorphic unmarshaling of a slice of ServiceConfigs. +type ServiceConfigs []ServiceConfig + +func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error { + var out []ServiceConfig for _, node := range node.Content { header := struct { Type string `yaml:"type"` @@ -405,6 +418,12 @@ func (o *Services) UnmarshalYAML(node *yaml.Node) error { return trace.Wrap(err) } out = append(out, v) + case SPIFFEWorkloadAPIServiceType: + v := &SPIFFEWorkloadAPIService{} + if err := node.Decode(v); err != nil { + return trace.Wrap(err) + } + out = append(out, v) default: return trace.BadParameter("unrecognized service type (%s)", header.Type) } @@ -458,6 +477,12 @@ func (o *Outputs) UnmarshalYAML(node *yaml.Node) error { return trace.Wrap(err) } out = append(out, v) + case SPIFFESVIDOutputType: + v := &SPIFFESVIDOutput{} + if err := node.Decode(v); err != nil { + return trace.Wrap(err) + } + out = append(out, v) default: return trace.BadParameter("unrecognized output type (%s)", header.Type) } diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go index 8759efe126d..47ff55df8c0 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -250,7 +250,20 @@ func TestBotConfig_YAML(t *testing.T) { }, }, }, - Services: []bot.Service{ + Services: []ServiceConfig{ + &SPIFFEWorkloadAPIService{ + Listen: "unix:///var/run/spiffe.sock", + SVIDs: []SVIDRequest{ + { + Path: "/bar", + Hint: "my hint", + SANS: SVIDRequestSANs{ + DNS: []string{"foo.bar"}, + IP: []string{"10.0.0.1"}, + }, + }, + }, + }, &ExampleService{ Message: "llama", }, diff --git a/lib/tbot/config/output_client_credential.go b/lib/tbot/config/output_client_credential.go index 5363eaf3537..d2b29fc8938 100644 --- a/lib/tbot/config/output_client_credential.go +++ b/lib/tbot/config/output_client_credential.go @@ -96,6 +96,16 @@ func (o *UnstableClientCredentialOutput) SSHClientConfig() (*ssh.ClientConfig, e return o.facade.SSHClientConfig() } +// Facade returns the underlying facade +func (o *UnstableClientCredentialOutput) Facade() (*identity.Facade, error) { + o.mu.Lock() + defer o.mu.Unlock() + if o.facade == nil { + return nil, trace.BadParameter("credentials not yet ready") + } + return o.facade, nil +} + // Render implements the Destination interface and is called regularly by the // bot with new credentials. Render passes these credentials down to the // underlying facade so that they can be used in TLS/SSH configs. diff --git a/lib/tbot/config/output_spiffe_svid.go b/lib/tbot/config/output_spiffe_svid.go new file mode 100644 index 00000000000..b72a1578e5f --- /dev/null +++ b/lib/tbot/config/output_spiffe_svid.go @@ -0,0 +1,300 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package config + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "strings" + "time" + + "github.com/gravitational/trace" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/durationpb" + "gopkg.in/yaml.v3" + + machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/experiment" + "github.com/gravitational/teleport/lib/auth/native" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/tbot/bot" + "github.com/gravitational/teleport/lib/tbot/identity" +) + +const SPIFFESVIDOutputType = "spiffe-svid" + +// Based on the default paths listed in +// https://github.com/spiffe/spiffe-helper/blob/main/README.md +const ( + svidPEMPath = "svid.pem" + svidKeyPEMPath = "svid_key.pem" + svidTrustBundlePEMPath = "svid_bundle.pem" +) + +// SVIDRequestSANs is the configuration for the SANs of a single SVID request. +type SVIDRequestSANs struct { + // DNS is the list of DNS names that are requested to be included in the SVID. + DNS []string `yaml:"dns,omitempty"` + // IP is the list of IP addresses that are requested to be included in the SVID. + // These can be IPv4 or IPv6 addresses. + IP []string `yaml:"ip,omitempty"` +} + +// SVIDRequest is the configuration for a single SVID request. +type SVIDRequest struct { + // Path is the SPIFFE ID path of the SVID. It should be prefixed with "/". + Path string `yaml:"path,omitempty"` + // Hint is the hint for the SVID that will be provided to consumers of the + // SVID to help them identify it. + Hint string `yaml:"hint,omitempty"` + // SANS is the Subject Alternative Names that are requested to be included + // in the SVID. + SANS SVIDRequestSANs `yaml:"sans,omitempty"` +} + +// CheckAndSetDefaults checks the SVIDRequest values and sets any defaults. +func (o *SVIDRequest) CheckAndSetDefaults() error { + switch { + case o.Path == "": + return trace.BadParameter("svid.path: should not be empty") + case !strings.HasPrefix(o.Path, "/"): + return trace.BadParameter("svid.path: should be prefixed with /") + } + for i, stringIP := range o.SANS.IP { + ip := net.ParseIP(stringIP) + if ip == nil { + return trace.BadParameter( + "ip_sans[%d]: invalid IP address %q", i, stringIP, + ) + } + } + return nil +} + +// SPIFFESVIDOutput is the configuration for the SPIFFE SVID output. +// Emulates the output of https://github.com/spiffe/spiffe-helper +type SPIFFESVIDOutput struct { + // Destination is where the credentials should be written to. + Destination bot.Destination `yaml:"destination"` + SVID SVIDRequest `yaml:"svid"` +} + +type spiffeSVIDSigner interface { + SignX509SVIDs(ctx context.Context, req *machineidv1pb.SignX509SVIDsRequest, opts ...grpc.CallOption) (*machineidv1pb.SignX509SVIDsResponse, error) +} + +// GenerateSVID generates the pre-requisites and makes a SVID generation RPC +// call. +func GenerateSVID( + ctx context.Context, + signer spiffeSVIDSigner, + reqs []SVIDRequest, + ttl time.Duration, +) (*machineidv1pb.SignX509SVIDsResponse, *rsa.PrivateKey, error) { + ctx, span := tracer.Start( + ctx, + "GenerateSVID", + ) + defer span.End() + privateKey, err := native.GenerateRSAPrivateKey() + if err != nil { + return nil, nil, trace.Wrap(err) + } + pubBytes, err := x509.MarshalPKIXPublicKey(privateKey.Public()) + if err != nil { + return nil, nil, trace.Wrap(err) + } + + svids := make([]*machineidv1pb.SVIDRequest, 0, len(reqs)) + for _, req := range reqs { + svids = append(svids, &machineidv1pb.SVIDRequest{ + PublicKey: pubBytes, + SpiffeIdPath: req.Path, + DnsSans: req.SANS.DNS, + IpSans: req.SANS.IP, + Hint: req.Hint, + Ttl: durationpb.New(ttl), + }) + } + + res, err := signer.SignX509SVIDs(ctx, &machineidv1pb.SignX509SVIDsRequest{ + Svids: svids, + }) + if err != nil { + return nil, nil, trace.Wrap(err) + } + + return res, privateKey, nil +} + +const ( + // pemPrivateKey is the PEM block type for a PKCS 8 encoded private key. + pemPrivateKey = "PRIVATE KEY" + // pemCertificate is the PEM block type for a DER encoded certificate. + pemCertificate = "CERTIFICATE" +) + +// Render generates the SVID and writes it to the destination. +func (o *SPIFFESVIDOutput) Render( + ctx context.Context, p provider, _ *identity.Identity, +) error { + ctx, span := tracer.Start( + ctx, + "SPIFFESVIDOutput/Render", + ) + defer span.End() + + res, privateKey, err := GenerateSVID( + ctx, + p, + []SVIDRequest{o.SVID}, + // For TTL, we use the one globally configured. + p.Config().CertificateTTL, + ) + if err != nil { + return trace.Wrap(err) + } + + privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return trace.Wrap(err) + } + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: pemPrivateKey, + Bytes: privBytes, + }) + + spiffeCAs, err := p.GetCertAuthorities(ctx, types.SPIFFECA) + if err != nil { + return trace.Wrap(err) + } + + if len(res.Svids) != 1 { + return trace.BadParameter("expected 1 SVID, got %d", len(res.Svids)) + + } + svid := res.Svids[0] + if err := o.Destination.Write(ctx, svidKeyPEMPath, privPEM); err != nil { + return trace.Wrap(err, "writing svid key") + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: pemCertificate, + Bytes: svid.Certificate, + }) + if err := o.Destination.Write(ctx, svidPEMPath, certPEM); err != nil { + return trace.Wrap(err, "writing svid certificate") + } + + trustBundleBytes := &bytes.Buffer{} + for _, ca := range spiffeCAs { + for _, cert := range services.GetTLSCerts(ca) { + // Values are already PEM encoded, so we just append to the buffer + if _, err := trustBundleBytes.Write(cert); err != nil { + return trace.Wrap(err, "writing trust bundle to buffer") + } + } + } + if err := o.Destination.Write( + ctx, svidTrustBundlePEMPath, trustBundleBytes.Bytes(), + ); err != nil { + return trace.Wrap(err, "writing svid trust bundle") + } + + return nil +} + +// Init initializes the destination. +func (o *SPIFFESVIDOutput) Init(ctx context.Context) error { + return trace.Wrap(o.Destination.Init(ctx, []string{})) +} + +// GetDestination returns the destination. +func (o *SPIFFESVIDOutput) GetDestination() bot.Destination { + return o.Destination +} + +// GetRoles returns the roles. SPIFFE SVIDs do not have roles. This exists for +// compatibility with the Output interface. +func (o *SPIFFESVIDOutput) GetRoles() []string { + // Always use all roles default - which is empty + return []string{} +} + +// CheckAndSetDefaults checks the SPIFFESVIDOutput values and sets any defaults. +func (o *SPIFFESVIDOutput) CheckAndSetDefaults() error { + if !experiment.Enabled() { + return trace.AccessDenied("workload identity has not been enabled") + } + + if err := o.SVID.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validating svid") + } + if err := validateOutputDestination(o.Destination); err != nil { + return trace.Wrap(err) + } + return nil +} + +// Describe returns the file descriptions for the SPIFFE SVID output. +func (o *SPIFFESVIDOutput) Describe() []FileDescription { + return []FileDescription{ + { + Name: svidPEMPath, + }, + { + Name: svidKeyPEMPath, + }, + { + Name: svidTrustBundlePEMPath, + }, + } +} + +// MarshalYAML marshals the SPIFFESVIDOutput into YAML. +func (o *SPIFFESVIDOutput) MarshalYAML() (interface{}, error) { + type raw SPIFFESVIDOutput + return withTypeHeader((*raw)(o), SPIFFESVIDOutputType) +} + +// UnmarshalYAML unmarshals the SPIFFESVIDOutput from YAML. +func (o *SPIFFESVIDOutput) UnmarshalYAML(node *yaml.Node) error { + dest, err := extractOutputDestination(node) + if err != nil { + return trace.Wrap(err) + } + // Alias type to remove UnmarshalYAML to avoid recursion + type raw SPIFFESVIDOutput + if err := node.Decode((*raw)(o)); err != nil { + return trace.Wrap(err) + } + o.Destination = dest + return nil +} + +// String returns a string representation of the SPIFFESVIDOutput. +func (o *SPIFFESVIDOutput) String() string { + return fmt.Sprintf("%s (%s) (%s)", SPIFFESVIDOutputType, o.SVID.Path, o.GetDestination()) +} diff --git a/lib/tbot/config/output_spiffe_svid_test.go b/lib/tbot/config/output_spiffe_svid_test.go new file mode 100644 index 00000000000..8f80b453655 --- /dev/null +++ b/lib/tbot/config/output_spiffe_svid_test.go @@ -0,0 +1,150 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package config + +import ( + "testing" + + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/experiment" +) + +func TestSPIFFESVIDOutput_YAML(t *testing.T) { + t.Parallel() + + dest := &DestinationMemory{} + tests := []testYAMLCase[SPIFFESVIDOutput]{ + { + name: "full", + in: SPIFFESVIDOutput{ + Destination: dest, + SVID: SVIDRequest{ + Path: "/foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1", "10.42.0.1"}, + }, + }, + }, + }, + { + name: "minimal", + in: SPIFFESVIDOutput{ + Destination: dest, + SVID: SVIDRequest{ + Path: "/foo", + }, + }, + }, + } + testYAML(t, tests) +} + +func TestSPIFFESVIDOutput_CheckAndSetDefaults(t *testing.T) { + experiment.SetEnabled(true) + defer experiment.SetEnabled(false) + + tests := []testCheckAndSetDefaultsCase[*SPIFFESVIDOutput]{ + { + name: "valid", + in: func() *SPIFFESVIDOutput { + return &SPIFFESVIDOutput{ + Destination: memoryDestForTest(), + SVID: SVIDRequest{ + Path: "/foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1"}, + }, + }, + } + }, + }, + { + name: "missing destination", + in: func() *SPIFFESVIDOutput { + return &SPIFFESVIDOutput{ + Destination: nil, + SVID: SVIDRequest{ + Path: "/foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1"}, + }, + }, + } + }, + wantErr: "no destination configured for output", + }, + { + name: "missing path", + in: func() *SPIFFESVIDOutput { + return &SPIFFESVIDOutput{ + Destination: memoryDestForTest(), + SVID: SVIDRequest{ + Path: "", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1"}, + }, + }, + } + }, + wantErr: "svid.path: should not be empty", + }, + { + name: "path missing leading slash", + in: func() *SPIFFESVIDOutput { + return &SPIFFESVIDOutput{ + Destination: memoryDestForTest(), + SVID: SVIDRequest{ + Path: "foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1"}, + }, + }, + } + }, + wantErr: "svid.path: should be prefixed with /", + }, + { + name: "invalid ip", + in: func() *SPIFFESVIDOutput { + return &SPIFFESVIDOutput{ + Destination: memoryDestForTest(), + SVID: SVIDRequest{ + Path: "/foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"thisisntanip"}, + }, + }, + } + }, + wantErr: "ip_sans[0]: invalid IP address", + }, + } + testCheckAndSetDefaults(t, tests) +} diff --git a/lib/tbot/config/service_example.go b/lib/tbot/config/service_example.go index f6d1172f59d..471b53bf5c1 100644 --- a/lib/tbot/config/service_example.go +++ b/lib/tbot/config/service_example.go @@ -19,10 +19,6 @@ package config import ( - "context" - "fmt" - "time" - "github.com/gravitational/trace" "gopkg.in/yaml.v3" ) @@ -36,17 +32,6 @@ type ExampleService struct { Message string `yaml:"message"` } -func (s *ExampleService) Run(ctx context.Context) error { - for { - select { - case <-ctx.Done(): - return nil - case <-time.After(time.Second * 5): - fmt.Println("Example Service prints message:", s.Message) - } - } -} - func (s *ExampleService) Type() string { return ExampleServiceType } @@ -65,6 +50,9 @@ func (s *ExampleService) UnmarshalYAML(node *yaml.Node) error { return nil } -func (s *ExampleService) String() string { - return fmt.Sprintf("%s:%s", ExampleServiceType, s.Message) +func (s *ExampleService) CheckAndSetDefaults() error { + if s.Message == "" { + return trace.BadParameter("message: should not be empty") + } + return nil } diff --git a/lib/tbot/config/service_spiffe_workload_api.go b/lib/tbot/config/service_spiffe_workload_api.go new file mode 100644 index 00000000000..8c6d39bc1ed --- /dev/null +++ b/lib/tbot/config/service_spiffe_workload_api.go @@ -0,0 +1,70 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package config + +import ( + "github.com/gravitational/trace" + "gopkg.in/yaml.v3" +) + +const SPIFFEWorkloadAPIServiceType = "spiffe-workload-api" + +// SPIFFEWorkloadAPIService is the configuration for the SPIFFE Workload API +// service. +type SPIFFEWorkloadAPIService struct { + // Listen is the address on which the SPIFFE Workload API server should + // listen. This should either be prefixed with "unix://" or "tcp://". + Listen string `yaml:"listen"` + // SVIDs is the list of SVIDs that the SPIFFE Workload API server should + // provide. + SVIDs []SVIDRequest `yaml:"svids"` +} + +func (s *SPIFFEWorkloadAPIService) Type() string { + return SPIFFEWorkloadAPIServiceType +} + +func (s *SPIFFEWorkloadAPIService) MarshalYAML() (interface{}, error) { + type raw SPIFFEWorkloadAPIService + return withTypeHeader((*raw)(s), SPIFFEWorkloadAPIServiceType) +} + +func (s *SPIFFEWorkloadAPIService) UnmarshalYAML(node *yaml.Node) error { + // Alias type to remove UnmarshalYAML to avoid recursion + type raw SPIFFEWorkloadAPIService + if err := node.Decode((*raw)(s)); err != nil { + return trace.Wrap(err) + } + return nil +} + +func (s *SPIFFEWorkloadAPIService) CheckAndSetDefaults() error { + if s.Listen == "" { + return trace.BadParameter("listen: should not be empty") + } + if len(s.SVIDs) == 0 { + return trace.BadParameter("svids: should not be empty") + } + for i, svid := range s.SVIDs { + if err := svid.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validiting svid[%d]", i) + } + } + return nil +} diff --git a/lib/tbot/config/service_spiffe_workload_api_test.go b/lib/tbot/config/service_spiffe_workload_api_test.go new file mode 100644 index 00000000000..baed0a0c0f3 --- /dev/null +++ b/lib/tbot/config/service_spiffe_workload_api_test.go @@ -0,0 +1,163 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package config + +import ( + "testing" + + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/experiment" +) + +func TestSPIFFEWorkloadAPIService_YAML(t *testing.T) { + t.Parallel() + + tests := []testYAMLCase[SPIFFEWorkloadAPIService]{ + { + name: "full", + in: SPIFFEWorkloadAPIService{ + Listen: "unix:///var/run/spiffe.sock", + SVIDs: []SVIDRequest{ + { + Path: "/foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1", "10.42.0.1"}, + }, + }, + }, + }, + }, + { + name: "minimal", + in: SPIFFEWorkloadAPIService{ + Listen: "unix:///var/run/spiffe.sock", + SVIDs: []SVIDRequest{ + { + Path: "/foo", + }, + }, + }, + }, + } + testYAML(t, tests) +} + +func TestSPIFFEWorkloadAPIService_CheckAndSetDefaults(t *testing.T) { + experiment.SetEnabled(true) + defer experiment.SetEnabled(false) + + tests := []testCheckAndSetDefaultsCase[*SPIFFEWorkloadAPIService]{ + { + name: "valid", + in: func() *SPIFFEWorkloadAPIService { + return &SPIFFEWorkloadAPIService{ + Listen: "unix:///var/run/spiffe.sock", + SVIDs: []SVIDRequest{ + { + Path: "/foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1", "10.42.0.1"}, + }, + }, + }, + } + }, + }, + { + name: "missing path", + in: func() *SPIFFEWorkloadAPIService { + return &SPIFFEWorkloadAPIService{ + Listen: "unix:///var/run/spiffe.sock", + SVIDs: []SVIDRequest{ + { + Path: "", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1", "10.42.0.1"}, + }, + }, + }, + } + }, + wantErr: "svid.path: should not be empty", + }, + { + name: "path missing leading slash", + in: func() *SPIFFEWorkloadAPIService { + return &SPIFFEWorkloadAPIService{ + Listen: "unix:///var/run/spiffe.sock", + SVIDs: []SVIDRequest{ + { + Path: "foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1", "10.42.0.1"}, + }, + }, + }, + } + }, + wantErr: "svid.path: should be prefixed with /", + }, + { + name: "missing listen addr", + in: func() *SPIFFEWorkloadAPIService { + return &SPIFFEWorkloadAPIService{ + Listen: "", + SVIDs: []SVIDRequest{ + { + Path: "foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1", "10.42.0.1"}, + }, + }, + }, + } + }, + wantErr: "listen: should not be empty", + }, + { + name: "invalid ip", + in: func() *SPIFFEWorkloadAPIService { + return &SPIFFEWorkloadAPIService{ + Listen: "unix:///var/run/spiffe.sock", + SVIDs: []SVIDRequest{ + { + Path: "/foo", + Hint: "hint", + SANS: SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"invalid ip"}, + }, + }, + }, + } + }, + wantErr: "ip_sans[0]: invalid IP address", + }, + } + testCheckAndSetDefaults(t, tests) +} diff --git a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden index 45ad2fac72f..f2f0769147d 100644 --- a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden +++ b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden @@ -20,6 +20,16 @@ outputs: type: kubernetes_secret name: my-secret services: + - type: spiffe-workload-api + listen: unix:///var/run/spiffe.sock + svids: + - path: /bar + hint: my hint + sans: + dns: + - foo.bar + ip: + - 10.0.0.1 - type: example message: llama debug: true diff --git a/lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/full.golden b/lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/full.golden new file mode 100644 index 00000000000..577c4917661 --- /dev/null +++ b/lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/full.golden @@ -0,0 +1,12 @@ +type: spiffe-svid +destination: + type: memory +svid: + path: /foo + hint: hint + sans: + dns: + - example.com + ip: + - 10.0.0.1 + - 10.42.0.1 diff --git a/lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/minimal.golden b/lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/minimal.golden new file mode 100644 index 00000000000..72d603045cf --- /dev/null +++ b/lib/tbot/config/testdata/TestSPIFFESVIDOutput_YAML/minimal.golden @@ -0,0 +1,5 @@ +type: spiffe-svid +destination: + type: memory +svid: + path: /foo diff --git a/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden new file mode 100644 index 00000000000..2edbb5978ed --- /dev/null +++ b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/full.golden @@ -0,0 +1,11 @@ +type: spiffe-workload-api +listen: unix:///var/run/spiffe.sock +svids: + - path: /foo + hint: hint + sans: + dns: + - example.com + ip: + - 10.0.0.1 + - 10.42.0.1 diff --git a/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden new file mode 100644 index 00000000000..1cef5bf13e1 --- /dev/null +++ b/lib/tbot/config/testdata/TestSPIFFEWorkloadAPIService_YAML/minimal.golden @@ -0,0 +1,4 @@ +type: spiffe-workload-api +listen: unix:///var/run/spiffe.sock +svids: + - path: /foo diff --git a/lib/tbot/service_example.go b/lib/tbot/service_example.go new file mode 100644 index 00000000000..c550c52d9f2 --- /dev/null +++ b/lib/tbot/service_example.go @@ -0,0 +1,50 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tbot + +import ( + "context" + "fmt" + "time" + + "github.com/gravitational/teleport/lib/tbot/config" +) + +// ExampleService is a temporary example service for testing purposes. It is +// not intended to be used and exists to demonstrate how a user configurable +// service integrates with the tbot service manager. +type ExampleService struct { + cfg *config.ExampleService + Message string `yaml:"message"` +} + +func (s *ExampleService) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return nil + case <-time.After(time.Second * 5): + fmt.Println("Example Service prints message:", s.Message) + } + } +} + +func (s *ExampleService) String() string { + return fmt.Sprintf("%s:%s", config.ExampleServiceType, s.Message) +} diff --git a/lib/tbot/service_outputs.go b/lib/tbot/service_outputs.go index 01b17d818a6..d65471693bd 100644 --- a/lib/tbot/service_outputs.go +++ b/lib/tbot/service_outputs.go @@ -30,12 +30,14 @@ import ( "github.com/gravitational/trace" "github.com/sirupsen/logrus" "golang.org/x/crypto/ssh" + "google.golang.org/grpc" apiclient "github.com/gravitational/teleport/api/client" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/client/webclient" "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/defaults" + machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" trustpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/trust/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/api/utils/retryutils" @@ -96,9 +98,7 @@ func (s *outputsService) renewOutputs( // Determine the default role list based on the bot role. The role's // name should match the certificate's Key ID (user and role names // should all match bot-$name) - botIdentity := s.getBotIdentity() - botResourceName := botIdentity.X509Cert.Subject.CommonName - defaultRoles, err := fetchDefaultRoles(ctx, s.botClient, botResourceName) + defaultRoles, err := fetchDefaultRoles(ctx, s.botClient, s.getBotIdentity()) if err != nil { s.log.WithError(err).Warnf("Unable to determine default roles, no roles will be requested if unspecified") defaultRoles = []string{} @@ -127,7 +127,7 @@ func (s *outputsService) renewOutputs( } impersonatedIdentity, impersonatedClient, err := s.generateImpersonatedIdentity( - ctx, s.botClient, botIdentity, output, defaultRoles, + ctx, s.botClient, s.getBotIdentity(), output, defaultRoles, ) if err != nil { return trace.Wrap(err, "generating impersonated certs for output: %s", output) @@ -636,6 +636,8 @@ func (s *outputsService) generateImpersonatedIdentity( return impersonatedIdentity, impersonatedClient, nil case *config.UnstableClientCredentialOutput: return impersonatedIdentity, impersonatedClient, nil + case *config.SPIFFESVIDOutput: + return impersonatedIdentity, impersonatedClient, nil default: return nil, nil, trace.BadParameter("generateImpersonatedIdentity does not support output type (%T)", output) } @@ -643,8 +645,8 @@ func (s *outputsService) generateImpersonatedIdentity( // fetchDefaultRoles requests the bot's own role from the auth server and // extracts its full list of allowed roles. -func fetchDefaultRoles(ctx context.Context, roleGetter services.RoleGetter, botRole string) ([]string, error) { - role, err := roleGetter.GetRole(ctx, botRole) +func fetchDefaultRoles(ctx context.Context, roleGetter services.RoleGetter, identity *identity.Identity) ([]string, error) { + role, err := roleGetter.GetRole(ctx, identity.X509Cert.Subject.CommonName) if err != nil { return nil, trace.Wrap(err) } @@ -790,6 +792,13 @@ func (op *outputProvider) GetCertAuthority(ctx context.Context, id types.CertAut return op.impersonatedClient.GetCertAuthority(ctx, id, loadKeys) } +// SignX509SVIDs uses the impersonatedClient to call SignX509SVIDs. +func (op *outputProvider) SignX509SVIDs( + ctx context.Context, in *machineidv1pb.SignX509SVIDsRequest, opts ...grpc.CallOption, +) (*machineidv1pb.SignX509SVIDsResponse, error) { + return op.impersonatedClient.WorkloadIdentityServiceClient().SignX509SVIDs(ctx, in, opts...) +} + // chooseOneDatabase chooses one matched database by name, or tries to choose // one database by unambiguous "discovered name". func chooseOneDatabase(databases []types.Database, name string) (types.Database, error) { diff --git a/lib/tbot/service_spiffe_workload_api.go b/lib/tbot/service_spiffe_workload_api.go new file mode 100644 index 00000000000..092596658bb --- /dev/null +++ b/lib/tbot/service_spiffe_workload_api.go @@ -0,0 +1,489 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tbot + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/url" + "strings" + "sync" + "time" + + "github.com/gravitational/trace" + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" + "github.com/prometheus/client_golang/prometheus" + "github.com/sirupsen/logrus" + workloadpb "github.com/spiffe/go-spiffe/v2/proto/spiffe/workload" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "golang.org/x/sync/errgroup" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/experiment" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/observability/metrics" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/tbot/config" +) + +// SPIFFEWorkloadAPIService implements a gRPC server that fulfills the SPIFFE +// Workload API specification. It provides X509 SVIDs and trust bundles to +// workloads that connect over the configured listener. +// +// Sources: +// - https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_Endpoint.md +// - https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_API.md +// - https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md +// - https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md +type SPIFFEWorkloadAPIService struct { + workloadpb.UnimplementedSpiffeWorkloadAPIServer + + svcIdentity *config.UnstableClientCredentialOutput + botCfg *config.BotConfig + cfg *config.SPIFFEWorkloadAPIService + log logrus.FieldLogger + botClient auth.ClientI + resolver reversetunnelclient.Resolver + // rootReloadBroadcaster allows the service to listen for CA rotations and + // update the trust bundle cache. + rootReloadBroadcaster *channelBroadcaster + // trustBundleBroadcast is a channel broadcaster is triggered when the trust + // bundle cache has been updated and active streams should be renewed. + trustBundleBroadcast *channelBroadcaster + + // client holds the impersonated client for the service + client auth.ClientI + + trustDomain string + + // trustBundle is protected by trustBundleMu. Use setTrustBundle and + // getTrustBundle to access it. + trustBundle []byte + trustBundleMu sync.Mutex +} + +func (s *SPIFFEWorkloadAPIService) setTrustBundle(trustBundle []byte) { + s.trustBundleMu.Lock() + s.trustBundle = trustBundle + s.trustBundleMu.Unlock() + // Alert active streaming RPCs to renew their trust bundles + s.trustBundleBroadcast.broadcast() +} + +func (s *SPIFFEWorkloadAPIService) getTrustBundle() []byte { + s.trustBundleMu.Lock() + defer s.trustBundleMu.Unlock() + return s.trustBundle +} + +func (s *SPIFFEWorkloadAPIService) fetchBundle(ctx context.Context) error { + cas, err := s.botClient.GetCertAuthorities(ctx, types.SPIFFECA, false) + if err != nil { + return trace.Wrap(err) + } + trustBundleBytes := &bytes.Buffer{} + for _, ca := range cas { + for _, cert := range services.GetTLSCerts(ca) { + // The values from GetTLSCerts are PEM encoded. We need them to be + // the bare ASN.1 DER encoded certificate. + block, _ := pem.Decode(cert) + trustBundleBytes.Write(block.Bytes) + } + } + + s.log.Info("Fetched new trust bundle") + s.setTrustBundle(trustBundleBytes.Bytes()) + return nil +} + +// setup initializes the service, performing tasks such as determining the +// trust domain, fetching the initial trust bundle and creating an impersonated +// client. +func (s *SPIFFEWorkloadAPIService) setup(ctx context.Context) (err error) { + ctx, span := tracer.Start(ctx, "SPIFFEWorkloadAPIService/setup") + defer span.End() + + // Wait for the impersonated identity to be ready for us to consume here. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Second): + return trace.BadParameter("timeout waiting for identity to be ready") + case <-s.svcIdentity.Ready(): + } + facade, err := s.svcIdentity.Facade() + if err != nil { + return trace.Wrap(err) + } + client, err := clientForFacade( + ctx, s.log, s.botCfg, facade, s.resolver, + ) + if err != nil { + return trace.Wrap(err) + } + s.client = client + // Closure is managed by the caller if this function succeeds. But if it + // fails, we need to close the client. + defer func() { + if err != nil { + client.Close() + } + }() + + if err := s.fetchBundle(ctx); err != nil { + return trace.Wrap(err) + } + authPing, err := client.Ping(ctx) + if err != nil { + return trace.Wrap(err) + } + s.trustDomain = authPing.ClusterName + + return nil +} + +func createListener(addr string) (net.Listener, error) { + parsed, err := url.Parse(addr) + if err != nil { + return nil, trace.Wrap(err, "parsing %q", addr) + } + + switch parsed.Scheme { + case "tcp": + return net.Listen("tcp", parsed.Host) + case "unix": + return net.Listen("unix", parsed.Path) + default: + return nil, trace.BadParameter("unsupported scheme %q", parsed.Scheme) + } +} + +func (s *SPIFFEWorkloadAPIService) Run(ctx context.Context) error { + ctx, span := tracer.Start(ctx, "SPIFFEWorkloadAPIService/Run") + defer span.End() + if !experiment.Enabled() { + return trace.BadParameter("workload identity has not been enabled") + } + + s.log.Debug("Starting pre-run initialization") + if err := s.setup(ctx); err != nil { + return trace.Wrap(err) + } + defer s.client.Close() + s.log.Debug("Completed pre-run initialization") + + srvMetrics := metrics.CreateGRPCServerMetrics( + true, prometheus.Labels{ + teleport.TagServer: "tbot-spiffe-workload-api", + }, + ) + if err := metrics.RegisterPrometheusCollectors(srvMetrics); err != nil { + return trace.Wrap(err) + } + srv := grpc.NewServer( + grpc.Creds( + // SPEC (SPIFFE_Workload_endpoint) 3. Transport: + // - Transport Layer Security MUST NOT be required + // TODO(noah): We should optionally provide TLS support here down + // the road. + insecure.NewCredentials(), + ), + grpc.ChainUnaryInterceptor( + recovery.UnaryServerInterceptor(), + srvMetrics.UnaryServerInterceptor(), + ), + grpc.ChainStreamInterceptor( + recovery.StreamServerInterceptor(), + srvMetrics.StreamServerInterceptor(), + ), + grpc.StatsHandler(otelgrpc.NewServerHandler()), + grpc.MaxConcurrentStreams(defaults.GRPCMaxConcurrentStreams), + ) + workloadpb.RegisterSpiffeWorkloadAPIServer(srv, s) + + lis, err := createListener(s.cfg.Listen) + if err != nil { + return trace.Wrap(err, "creating listener") + } + defer func() { + if err := lis.Close(); err != nil { + s.log.WithError(err).Error("Encountered error closing listener") + } + }() + s.log.WithField("addr", lis.Addr().String()).Info("Listener opened for Workload API endpoint") + if lis.Addr().Network() == "tcp" { + s.log.Warn( + "Workload API endpoint listening on a TCP port. Ensure that only intended hosts can reach this port!", + ) + } + + // Set off the long running tasks in an errgroup + eg, egCtx := errgroup.WithContext(ctx) + eg.Go(func() error { + // Start the gRPC server + return srv.Serve(lis) + }) + eg.Go(func() error { + // Shutdown the server when the context is canceled + <-egCtx.Done() + s.log.Debug("Shutting down Workload API endpoint") + srv.Stop() + s.log.Info("Shut down Workload API endpoint") + return nil + }) + eg.Go(func() error { + // Handle CA rotations + return s.handleCARotations(egCtx) + }) + + return trace.Wrap(eg.Wait()) +} + +// handleCARotations listens on a channel subscribed to the bot's CA watcher and +// refetches the trust bundle when a rotation is detected. +func (s *SPIFFEWorkloadAPIService) handleCARotations(ctx context.Context) error { + ctx, span := tracer.Start(ctx, "SPIFFEWorkloadAPIService/handleCARotations") + defer span.End() + reloadCh, unsubscribe := s.rootReloadBroadcaster.subscribe() + defer unsubscribe() + for { + select { + case <-ctx.Done(): + return nil + case <-reloadCh: + } + + s.log.Info("CA rotation detected, fetching trust bundle") + err := s.fetchBundle(ctx) + if err != nil { + return trace.Wrap(err, "updating trust bundle") + } + } +} + +// serialString returns a human-readable colon-separated string of the serial +// number in hex. +func serialString(serial *big.Int) string { + hex := serial.Text(16) + if len(hex)%2 == 1 { + hex = "0" + hex + } + + out := strings.Builder{} + for i := 0; i < len(hex); i += 2 { + if i != 0 { + out.WriteString(":") + } + out.WriteString(hex[i : i+2]) + } + return out.String() +} + +// fetchX509SVIDs fetches the X.509 SVIDs for the bot's configured SVIDs and +// returns them in the SPIFFE Workload API format. +func (s *SPIFFEWorkloadAPIService) fetchX509SVIDs( + ctx context.Context, +) ([]*workloadpb.X509SVID, error) { + ctx, span := tracer.Start(ctx, "SPIFFEWorkloadAPIService/fetchX509SVIDs") + defer span.End() + // Fetch this once at the start and share it across all SVIDs to reduce + // contention on the mutex and to ensure that all SVIDs are using the + // same trust bundle. + trustBundle := s.getTrustBundle() + // TODO(noah): We should probably take inspiration from SPIRE agent's + // behavior of pre-fetching the SVIDs rather than doing this for + // every request. + res, privateKey, err := config.GenerateSVID( + ctx, + s.client.WorkloadIdentityServiceClient(), + s.cfg.SVIDs, + // For TTL, we use the one globally configured. + s.botCfg.CertificateTTL, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + // Convert the private key to PKCS#8 format as per SPIFFE spec. + pkcs8PrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, trace.Wrap(err) + } + + // Convert responses from the Teleport API to the SPIFFE Workload API + // format. + svids := make([]*workloadpb.X509SVID, len(res.Svids)) + for i, svidRes := range res.Svids { + svids[i] = &workloadpb.X509SVID{ + // Required. The SPIFFE ID of the SVID in this entry + SpiffeId: svidRes.SpiffeId, + // Required. ASN.1 DER encoded certificate chain. MAY include + // intermediates, the leaf certificate (or SVID itself) MUST come first. + X509Svid: svidRes.Certificate, + // Required. ASN.1 DER encoded PKCS#8 private key. MUST be unencrypted. + X509SvidKey: pkcs8PrivateKey, + // Required. ASN.1 DER encoded X.509 bundle for the trust domain. + Bundle: trustBundle, + Hint: svidRes.Hint, + } + cert, err := x509.ParseCertificate(svidRes.Certificate) + if err != nil { + return nil, trace.Wrap(err, "parsing issued svid received from server") + } + // Log a message which correlates with the audit log entry and can + // provide additional metadata about the client. + s.log.WithFields(logrus.Fields{ + "svid_type": "x509", + "spiffe_id": svidRes.SpiffeId, + "serial_number": serialString(cert.SerialNumber), + "hint": svidRes.Hint, + "not_after": cert.NotAfter, + "not_before": cert.NotBefore, + "dns_sans": cert.DNSNames, + "ip_sans": cert.IPAddresses, + "serial": cert.SerialNumber, + }).Info("Issued SVID for workload") + } + + return svids, nil +} + +// FetchX509SVID generates and returns the X.509 SVIDs available to a workload. +// It is a streaming RPC, and sends renewed SVIDs to the client before they +// expire. +// Implements the SPIFFE Workload API FetchX509SVID method. +func (s *SPIFFEWorkloadAPIService) FetchX509SVID( + _ *workloadpb.X509SVIDRequest, + srv workloadpb.SpiffeWorkloadAPI_FetchX509SVIDServer, +) error { + renewCh, unsubscribe := s.trustBundleBroadcast.subscribe() + defer unsubscribe() + s.log.Info("FetchX509SVID stream opened by workload") + defer s.log.Info("FetchX509SVID stream has closed") + + for { + s.log.Info("Starting to issue X509 SVIDs to workload") + + svids, err := s.fetchX509SVIDs(srv.Context()) + if err != nil { + return trace.Wrap(err) + } + err = srv.Send(&workloadpb.X509SVIDResponse{ + Svids: svids, + }) + if err != nil { + return trace.Wrap(err) + } + s.log.Debug( + "Finished issuing SVIDs to workload. Waiting for next renewal interval or CA rotation", + ) + + select { + case <-srv.Context().Done(): + s.log.Debug("Context closed, stopping SVID stream") + return nil + case <-time.After(s.botCfg.RenewalInterval): + s.log.Debug("Renewal interval reached, renewing SVIDs") + // Time to renew the certificate + continue + case <-renewCh: + s.log.Debug("Trust bundle has been updated, renewing SVIDs") + continue + } + } +} + +// FetchX509Bundles returns the trust bundle for the trust domain. It is a +// streaming RPC, and will send rotated trust bundles to the client for as long +// as the client is connected. +// Implements the SPIFFE Workload API FetchX509SVID method. +func (s *SPIFFEWorkloadAPIService) FetchX509Bundles( + _ *workloadpb.X509BundlesRequest, + srv workloadpb.SpiffeWorkloadAPI_FetchX509BundlesServer, +) error { + s.log.Info("FetchX509Bundles stream opened by workload") + defer s.log.Info("FetchX509Bundles stream has closed") + renewCh, unsubscribe := s.trustBundleBroadcast.subscribe() + defer unsubscribe() + + for { + s.log.Info("Sending X.509 trust bundles to workload") + err := srv.Send(&workloadpb.X509BundlesResponse{ + // Bundles keyed by trust domain + Bundles: map[string][]byte{ + s.trustDomain: s.getTrustBundle(), + }, + }) + if err != nil { + return trace.Wrap(err) + } + + select { + case <-srv.Context().Done(): + s.log.Debug("Context closed, stopping x.509 trust bundle stream") + return nil + case <-renewCh: + s.log.Debug("Trust bundle has been updated, resending trust bundle") + continue + } + } +} + +// FetchJWTSVID implements the SPIFFE Workload API FetchJWTSVID method. +func (s *SPIFFEWorkloadAPIService) FetchJWTSVID( + ctx context.Context, + req *workloadpb.JWTSVIDRequest, +) (*workloadpb.JWTSVIDResponse, error) { + // JWT functionality currently not implemented in Teleport Workload Identity. + return nil, trace.NotImplemented("method not implemented") +} + +// FetchJWTBundles implements the SPIFFE Workload API FetchJWTBundles method. +func (s *SPIFFEWorkloadAPIService) FetchJWTBundles( + req *workloadpb.JWTBundlesRequest, + srv workloadpb.SpiffeWorkloadAPI_FetchJWTBundlesServer, +) error { + // JWT functionality currently not implemented in Teleport Workload Identity. + return trace.NotImplemented("method not implemented") +} + +// ValidateJWTSVID implements the SPIFFE Workload API ValidateJWTSVID method. +func (s *SPIFFEWorkloadAPIService) ValidateJWTSVID( + ctx context.Context, + req *workloadpb.ValidateJWTSVIDRequest, +) (*workloadpb.ValidateJWTSVIDResponse, error) { + // JWT functionality currently not implemented in Teleport Workload Identity. + return nil, trace.NotImplemented("method not implemented") +} + +// String returns a human-readable string that can uniquely identify the +// service. +func (s *SPIFFEWorkloadAPIService) String() string { + return fmt.Sprintf("%s:%s", config.SPIFFEWorkloadAPIServiceType, s.cfg.Listen) +} diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go index fc1e7d0a9e8..54bce60927a 100644 --- a/lib/tbot/tbot.go +++ b/lib/tbot/tbot.go @@ -38,7 +38,6 @@ import ( "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/reversetunnelclient" - "github.com/gravitational/teleport/lib/tbot/bot" "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/teleport/lib/tbot/identity" "github.com/gravitational/teleport/lib/utils" @@ -48,6 +47,29 @@ var tracer = otel.Tracer("github.com/gravitational/teleport/lib/tbot") const componentTBot = "tbot" +// Service is a long-running sub-component of tbot. +type Service interface { + // String returns a human-readable name for the service that can be used + // in logging. It should identify the type of the service and any top + // level configuration that could distinguish it from a same-type service. + String() string + // Run starts the service and blocks until the service exits. It should + // return a nil error if the service exits successfully and an error + // if it is unable to proceed. It should exit gracefully if the context + // is canceled. + Run(ctx context.Context) error +} + +// OneShotService is a [Service] that offers a mode in which it runs a single +// time and then exits. This aligns with the `--oneshot` mode of tbot. +type OneShotService interface { + Service + // OneShot runs the service once and then exits. It should return a nil + // error if the service exits successfully and an error if it is unable + // to proceed. It should exit gracefully if the context is canceled. + OneShot(ctx context.Context) error +} + type Bot struct { cfg *config.BotConfig log logrus.FieldLogger @@ -124,7 +146,7 @@ func (b *Bot) Run(ctx context.Context) error { // Create an error group to manage all the services lifetimes. eg, egCtx := errgroup.WithContext(ctx) - var services []bot.Service + var services []Service // ReloadBroadcaster allows multiple entities to trigger a reload of // all services. This allows os signals and other events such as CA @@ -196,8 +218,40 @@ func (b *Bot) Run(ctx context.Context) error { ), reloadBroadcaster: reloadBroadcaster, }) + // Append any services configured by the user - services = append(services, b.cfg.Services...) + for _, svcCfg := range b.cfg.Services { + // Convert the service config into the actual service type. + switch svcCfg := svcCfg.(type) { + case *config.SPIFFEWorkloadAPIService: + // Create a credential output for the SPIFFE Workload API service to + // use as a source of an impersonated identity. + svcIdentity := &config.UnstableClientCredentialOutput{} + b.cfg.Outputs = append(b.cfg.Outputs, svcIdentity) + + svc := &SPIFFEWorkloadAPIService{ + botClient: b.botIdentitySvc.GetClient(), + svcIdentity: svcIdentity, + botCfg: b.cfg, + cfg: svcCfg, + resolver: resolver, + rootReloadBroadcaster: reloadBroadcaster, + trustBundleBroadcast: &channelBroadcaster{ + chanSet: map[chan struct{}]struct{}{}, + }, + } + svc.log = b.log.WithField( + trace.Component, teleport.Component(componentTBot, "svc", svc.String()), + ) + services = append(services, svc) + case *config.ExampleService: + services = append(services, &ExampleService{ + cfg: svcCfg, + }) + default: + return trace.BadParameter("unknown service type: %T", svcCfg) + } + } b.log.Info("Initialization complete. Starting services.") // Start services @@ -206,7 +260,7 @@ func (b *Bot) Run(ctx context.Context) error { log := b.log.WithField("service", svc.String()) if b.cfg.Oneshot { - svc, ok := svc.(bot.OneShotService) + svc, ok := svc.(OneShotService) // We ignore services with no one-shot implementation if !ok { log.Debug("Service does not support oneshot mode, ignoring.") @@ -360,8 +414,7 @@ func clientForFacade( log logrus.FieldLogger, cfg *config.BotConfig, facade *identity.Facade, - resolver reversetunnelclient.Resolver, -) (auth.ClientI, error) { + resolver reversetunnelclient.Resolver) (auth.ClientI, error) { ctx, span := tracer.Start(ctx, "clientForFacade") defer span.End() diff --git a/lib/tbot/tbot_test.go b/lib/tbot/tbot_test.go index e155fcb60c3..ff2c5404da3 100644 --- a/lib/tbot/tbot_test.go +++ b/lib/tbot/tbot_test.go @@ -23,15 +23,20 @@ import ( "context" "crypto/rand" "fmt" + "net" "os" + "path" "testing" + "time" + "github.com/spiffe/go-spiffe/v2/workloadapi" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" "github.com/gravitational/teleport/api/types" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/sshutils" + "github.com/gravitational/teleport/lib/auth/machineid/machineidv1/experiment" "github.com/gravitational/teleport/lib/auth/native" apisshutils "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/tbot/bot" @@ -605,3 +610,102 @@ func newMockDiscoveredKubeCluster(t *testing.T, name, discoveredName string) *ty require.NoError(t, err) return kubeCluster } + +// TestBotSPIFFEWorkloadAPI is an end-to-end test of Workload ID's ability to +// issue a SPIFFE SVID to a workload connecting via the SPIFFE Workload API. +func TestBotSPIFFEWorkloadAPI(t *testing.T) { + experiment.SetEnabled(true) + defer experiment.SetEnabled(false) + + ctx := context.Background() + log := utils.NewLoggerForTests() + + // Make a new auth server. + fc, fds := testhelpers.DefaultConfig(t) + _ = testhelpers.MakeAndRunTestAuthServer(t, log, fc, fds) + rootClient := testhelpers.MakeDefaultAuthClient(t, log, fc) + + // Create a role that allows the bot to issue a SPIFFE SVID. + role, err := types.NewRole("spiffe-issuer", types.RoleSpecV6{ + Allow: types.RoleConditions{ + SPIFFE: []*types.SPIFFERoleCondition{ + { + Path: "/*", + DNSSANs: []string{ + "*", + }, + IPSANs: []string{ + "0.0.0.0/0", + }, + }, + }, + }, + }) + require.NoError(t, err) + role, err = rootClient.UpsertRole(ctx, role) + require.NoError(t, err) + + tempDir := t.TempDir() + socketPath := "unix://" + path.Join(tempDir, "spiffe.sock") + onboarding, _ := testhelpers.MakeBot(t, rootClient, "test", role.GetName()) + botConfig := testhelpers.DefaultBotConfig( + t, fc, onboarding, []config.Output{}, + testhelpers.DefaultBotConfigOpts{ + UseAuthServer: true, + Insecure: true, + ServiceConfigs: []config.ServiceConfig{ + &config.SPIFFEWorkloadAPIService{ + Listen: socketPath, + SVIDs: []config.SVIDRequest{ + { + Path: "/foo", + Hint: "hint", + SANS: config.SVIDRequestSANs{ + DNS: []string{"example.com"}, + IP: []string{"10.0.0.1"}, + }, + }, + }, + }, + }, + }, + ) + botConfig.Oneshot = false + b := New(botConfig, log) + + // Spin up goroutine for bot to run in + botCtx, cancelBot := context.WithCancel(ctx) + botCh := make(chan error, 1) + go func() { + botCh <- b.Run(botCtx) + }() + + // This has a little flexibility internally in terms of waiting for the + // socket to come up, so we don't need a manual sleep/retry here. + source, err := workloadapi.NewX509Source( + ctx, + workloadapi.WithClientOptions(workloadapi.WithAddr(socketPath)), + ) + require.NoError(t, err) + defer source.Close() + + svid, err := source.GetX509SVID() + require.NoError(t, err) + + // SVID has successfully been issued. We can now assert that it's correct. + require.Equal(t, "spiffe://localhost/foo", svid.ID.String()) + cert := svid.Certificates[0] + require.Equal(t, "spiffe://localhost/foo", cert.URIs[0].String()) + require.True(t, net.IPv4(10, 0, 0, 1).Equal(cert.IPAddresses[0])) + require.Equal(t, []string{"example.com"}, cert.DNSNames) + require.WithinRange( + t, + cert.NotAfter, + cert.NotBefore.Add(time.Hour-time.Minute), + cert.NotBefore.Add(time.Hour+time.Minute), + ) + + // Shut down bot and make sure it exits cleanly. + cancelBot() + require.NoError(t, <-botCh) +} diff --git a/lib/tbot/testhelpers/srv.go b/lib/tbot/testhelpers/srv.go index 51a0c560f49..682330d5989 100644 --- a/lib/tbot/testhelpers/srv.go +++ b/lib/tbot/testhelpers/srv.go @@ -47,6 +47,8 @@ type DefaultBotConfigOpts struct { // Makes the bot accept an Insecure auth or proxy server Insecure bool + + ServiceConfigs botconfig.ServiceConfigs } // DefaultConfig returns a FileConfig to be used in tests, with random listen @@ -203,7 +205,11 @@ func MakeBot(t *testing.T, client auth.ClientI, name string, roles ...string) (* // - Uses a memory storage destination // - Does not verify Proxy WebAPI certificates func DefaultBotConfig( - t *testing.T, fc *config.FileConfig, onboarding *botconfig.OnboardingConfig, outputs []botconfig.Output, opts DefaultBotConfigOpts, + t *testing.T, + fc *config.FileConfig, + onboarding *botconfig.OnboardingConfig, + outputs []botconfig.Output, + opts DefaultBotConfigOpts, ) *botconfig.BotConfig { t.Helper() @@ -227,6 +233,7 @@ func DefaultBotConfig( // Set Insecure so the bot will trust the Proxy's webapi default signed // certs. Insecure: opts.Insecure, + Services: opts.ServiceConfigs, } require.NoError(t, cfg.CheckAndSetDefaults())