mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-30 18:29:21 +08:00
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:
co-authored by
Isaiah Becker-Mayer
parent
4c26c2a426
commit
5a00a67d95
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user