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 <isaiah@goteleport.com>

---------

Co-authored-by: Isaiah Becker-Mayer <isaiah@goteleport.com>
This commit is contained in:
Noah Stride
2024-03-06 09:48:16 +00:00
committed by GitHub
co-authored by Isaiah Becker-Mayer
parent 4c26c2a426
commit 5a00a67d95
24 changed files with 1532 additions and 81 deletions
+3 -1
View File
@@ -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
+4
View File
@@ -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=
-44
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
+6
View File
@@ -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)
}
+10
View File
@@ -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) {
+30 -5
View File
@@ -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)
}
+14 -1
View File
@@ -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",
},
@@ -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.
+300
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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())
}
+150
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
+5 -17
View File
@@ -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
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
@@ -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
@@ -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
@@ -0,0 +1,5 @@
type: spiffe-svid
destination:
type: memory
svid:
path: /foo
@@ -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
@@ -0,0 +1,4 @@
type: spiffe-workload-api
listen: unix:///var/run/spiffe.sock
svids:
- path: /foo
+50
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
+15 -6
View File
@@ -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) {
+489
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
+59 -6
View File
@@ -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()
+104
View File
@@ -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)
}
+8 -1
View File
@@ -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())