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())