App service use upstream TLS options (#66454)

* feat: app service use upstream tls options

* refactor: code review suggestions

* refactor(upstreamtls): code review suggestions

* test(app): provide missing transport options

* chore(app): fix lint
This commit is contained in:
Gabriel Corado
2026-05-27 13:41:28 +00:00
committed by GitHub
parent 10d7a5d2db
commit e5c992ccc1
16 changed files with 1409 additions and 76 deletions
+2 -2
View File
@@ -535,12 +535,12 @@ func (a *AppV3) checkTCPPorts() error {
}
switch {
// The scheme of URI is enforced to be "tcp" on purpose. This way in the future we can add
// The scheme of URI is enforced to be "tcp" (or "tls") on purpose. This way in the future we can add
// multi-port support to web apps without throwing hard errors when a cluster with a multi-port
// web app gets downgraded to a version which supports multi-port only for TCP apps.
//
// For now, we simply ignore the Ports field set on non-TCP apps.
case uri.Scheme != "tcp":
case uri.Scheme != "tcp" && uri.Scheme != SchemeTLS:
return nil
case a.Spec.MCP != nil:
return trace.BadParameter("TCP app %q cannot specify 'mcp' configuration", a.GetName())
+1 -1
View File
@@ -236,7 +236,7 @@ func (a *App) checkPorts() error {
// web app gets downgraded to a version which supports multi-port only for TCP apps.
//
// For now, we simply ignore the Ports field set on non-TCP apps.
if uri.Scheme != "tcp" {
if uri.Scheme != "tcp" && uri.Scheme != types.SchemeTLS {
return nil
}
+20 -16
View File
@@ -22,6 +22,7 @@ import (
"crypto"
"crypto/tls"
"crypto/x509"
"iter"
"slices"
"github.com/gravitational/trace"
@@ -262,6 +263,19 @@ func GetTLSCerts(ca types.CertAuthority) [][]byte {
return out
}
// GetX509Certs returns parsed TLS certificates from CA as [x509.Certificate].
func GetX509Certs(ca types.CertAuthority) iter.Seq2[*x509.Certificate, error] {
pairs := ca.GetTrustedTLSKeyPairs()
return func(yield func(*x509.Certificate, error) bool) {
for _, pair := range pairs {
cert, err := tlsca.ParseCertificatePEM(pair.Cert)
if !yield(cert, err) {
return
}
}
}
}
// GetSSHCheckingKeys returns SSH public keys from CA
func GetSSHCheckingKeys(ca types.CertAuthority) [][]byte {
pairs := ca.GetTrustedSSHKeyPairs()
@@ -279,12 +293,7 @@ func CertPoolFromCertAuthorities(cas []types.CertAuthority) (*x509.CertPool, int
certPool := x509.NewCertPool()
count := 0
for _, ca := range cas {
keyPairs := ca.GetTrustedTLSKeyPairs()
if len(keyPairs) == 0 {
continue
}
for _, keyPair := range keyPairs {
cert, err := tlsca.ParseCertificatePEM(keyPair.Cert)
for cert, err := range GetX509Certs(ca) {
if err != nil {
return nil, 0, trace.Wrap(err)
}
@@ -298,17 +307,12 @@ func CertPoolFromCertAuthorities(cas []types.CertAuthority) (*x509.CertPool, int
// CertPool returns certificate pools from TLS certificates
// set up in the certificate authority
func CertPool(ca types.CertAuthority) (*x509.CertPool, error) {
keyPairs := ca.GetTrustedTLSKeyPairs()
if len(keyPairs) == 0 {
return nil, trace.BadParameter("certificate authority has no TLS certificates")
certPool, count, err := CertPoolFromCertAuthorities([]types.CertAuthority{ca})
if err != nil {
return nil, trace.Wrap(err)
}
certPool := x509.NewCertPool()
for _, keyPair := range keyPairs {
cert, err := tlsca.ParseCertificatePEM(keyPair.Cert)
if err != nil {
return nil, trace.Wrap(err)
}
certPool.AddCert(cert)
if count == 0 {
return nil, trace.BadParameter("certificate authority has no TLS certificates")
}
return certPool, nil
}
+16 -9
View File
@@ -208,7 +208,8 @@ type ConnectionsHandler struct {
// authMiddleware allows wrapping connections with identity information.
authMiddleware *authz.Middleware
proxyPort string
proxyPort string
clusterName string
// getAppByPublicAddress returns a types.Application using the public address as matcher.
getAppByPublicAddress func(context.Context, string) (types.Application, error)
@@ -245,6 +246,11 @@ func NewConnectionsHandler(closeContext context.Context, cfg *ConnectionsHandler
return nil, trace.Wrap(err)
}
clusterName, err := cfg.AccessPoint.GetClusterName(closeContext)
if err != nil {
return nil, trace.Wrap(err)
}
c := &ConnectionsHandler{
cfg: cfg,
closeContext: closeContext,
@@ -253,6 +259,7 @@ func NewConnectionsHandler(closeContext context.Context, cfg *ConnectionsHandler
gcpHandler: gcpHandler,
connAuth: make(map[net.Conn]error),
log: slog.With(teleport.ComponentKey, cfg.ServiceComponent),
clusterName: clusterName.GetClusterName(),
getAppByPublicAddress: func(ctx context.Context, s string) (types.Application, error) {
return nil, trace.NotFound("no applications are being proxied")
},
@@ -270,10 +277,6 @@ func NewConnectionsHandler(closeContext context.Context, cfg *ConnectionsHandler
if err != nil {
return nil, trace.Wrap(err)
}
clustername, err := c.cfg.AccessPoint.GetClusterName(closeContext)
if err != nil {
return nil, trace.Wrap(err)
}
// Create limiter for connection and rate limiting. Applied to all
// app protocols (HTTP, TCP, MCP) in handleConnection.
@@ -283,7 +286,7 @@ func NewConnectionsHandler(closeContext context.Context, cfg *ConnectionsHandler
}
// Create and configure HTTP server with authorizing middleware.
c.httpServer = c.newHTTPServer(clustername.GetClusterName())
c.httpServer = c.newHTTPServer(clusterName.GetClusterName())
// TCP server will handle TCP applications.
tcpServer, err := c.newTCPServer()
@@ -421,9 +424,13 @@ func (c *ConnectionsHandler) sessionStartTime(ctx context.Context) time.Time {
// newTCPServer creates a server that proxies TCP applications.
func (c *ConnectionsHandler) newTCPServer() (*tcpServer, error) {
return &tcpServer{
emitter: c.cfg.Emitter,
hostID: c.cfg.HostID,
log: c.log,
emitter: c.cfg.Emitter,
hostID: c.cfg.HostID,
log: c.log,
caGetter: c.cfg.AccessPoint,
clusterName: c.clusterName,
cipherSuites: c.cfg.CipherSuites,
insecureMode: c.cfg.InsecureMode,
}, nil
}
+10
View File
@@ -163,6 +163,11 @@ type suiteConfig struct {
// ManualStart skips calling Start() automatically so the
// caller can inject state before starting the server.
ManualStart bool
// OverrideCAs are cert authorities to upsert into the test cluster after
// the auth server starts.
OverrideCAs []types.CertAuthority
// InsecureMode sets service to insecure mode.
InsecureMode bool
}
type fakeConnMonitor struct{}
@@ -212,6 +217,10 @@ func SetUpSuiteWithConfig(t *testing.T, config suiteConfig) *Suite {
s.tlsServer.Close()
})
for _, ca := range config.OverrideCAs {
require.NoError(t, s.tlsServer.Auth().UpsertCertAuthority(s.closeContext, ca))
}
// Set up the host cert pool.
rootCA, err := s.tlsServer.Auth().GetCertAuthority(context.Background(), types.CertAuthID{
Type: types.HostCA,
@@ -380,6 +389,7 @@ func SetUpSuiteWithConfig(t *testing.T, config suiteConfig) *Suite {
ConnectionMonitor: fakeConnMonitor{},
CipherSuites: utils.DefaultCipherSuites(),
ServiceComponent: teleport.ComponentApp,
InsecureMode: config.InsecureMode,
AWSConfigOptions: []awsconfig.OptionsFn{
awsconfig.WithSTSClientProvider(func(_ aws.Config) awsconfig.STSClient {
return &mocks.STSClient{}
+10 -8
View File
@@ -159,14 +159,16 @@ func (c *ConnectionsHandler) withJWTTokenForwarder(ctx context.Context, sess *se
// Create a rewriting transport that will be used to forward requests.
transport, err := newTransport(c.closeContext,
&transportConfig{
app: app,
publicPort: c.proxyPort,
cipherSuites: c.cfg.CipherSuites,
jwt: jwt,
rewriteTraits: rewriteTraits,
log: c.log,
hostID: c.cfg.HostID,
insecureMode: c.cfg.InsecureMode,
app: app,
publicPort: c.proxyPort,
cipherSuites: c.cfg.CipherSuites,
jwt: jwt,
rewriteTraits: rewriteTraits,
log: c.log,
hostID: c.cfg.HostID,
insecureMode: c.cfg.InsecureMode,
clusterName: c.clusterName,
certAuthorityGetter: c.cfg.AccessPoint,
})
if err != nil {
return trace.Wrap(err)
+76 -10
View File
@@ -20,9 +20,11 @@ package app
import (
"context"
"crypto/tls"
"log/slog"
"net"
"strconv"
"strings"
"github.com/gravitational/trace"
@@ -31,25 +33,33 @@ import (
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/srv/app/common"
"github.com/gravitational/teleport/lib/srv/app/upstreamtls"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
)
type certificateAuthorityGetter interface {
// GetCertAuthority returns cert authority by id.
GetCertAuthority(context.Context, apitypes.CertAuthID, bool) (apitypes.CertAuthority, error)
}
type tcpServer struct {
emitter apievents.Emitter
hostID string
log *slog.Logger
emitter apievents.Emitter
hostID string
log *slog.Logger
caGetter certificateAuthorityGetter
clusterName string
cipherSuites []uint16
insecureMode bool
}
// handleConnection handles connection from a TCP application.
func (s *tcpServer) handleConnection(ctx context.Context, clientConn net.Conn, identity *tlsca.Identity, app apitypes.Application) error {
uriAddr, err := utils.ParseAddr(app.GetURI())
// Validates the app is TCP.
uriAddr, isTLS, err := tcpAppAddr(app)
if err != nil {
return trace.Wrap(err)
}
if uriAddr.AddrNetwork != "tcp" {
return trace.BadParameter(`unexpected app %q address network, expected "tcp": %+v`, app.GetName(), uriAddr)
}
dialer := net.Dialer{
Timeout: apidefaults.DefaultIOTimeout,
@@ -58,9 +68,35 @@ func (s *tcpServer) handleConnection(ctx context.Context, clientConn net.Conn, i
if err != nil {
return trace.Wrap(err)
}
serverConn, err := dialer.DialContext(ctx, uriAddr.AddrNetwork, dialTarget)
if err != nil {
return trace.Wrap(err)
var serverConn net.Conn
switch {
case isTLS:
tlsConfig, err := upstreamtls.Configure(ctx, upstreamtls.Options{
Logger: s.log,
CAGetter: s.caGetter,
ClusterName: s.clusterName,
App: app,
CipherSuites: s.cipherSuites,
InsecureMode: s.insecureMode,
})
if err != nil {
return trace.Wrap(err)
}
tlsDialer := tls.Dialer{
NetDialer: &dialer,
Config: tlsConfig,
}
serverConn, err = tlsDialer.DialContext(ctx, uriAddr.AddrNetwork, dialTarget)
if err != nil {
return trace.Wrap(err)
}
default:
serverConn, err = dialer.DialContext(ctx, uriAddr.AddrNetwork, dialTarget)
if err != nil {
return trace.Wrap(err)
}
}
audit, err := common.NewAudit(common.AuditConfig{
@@ -145,3 +181,33 @@ func ensureZeroTargetPortOrEqualToPortFromURI(uriAddr *utils.NetAddr, targetPort
return nil
}
// tcpAppAddr this function validates the app is a TCP app, and parses its URI.
//
// Note, instead of changing the `utils.ParseAddr` (which is widely used across
// Teleport) to include an internal scheme/protocol we handle this here,
// avoiding introducing any regression.
func tcpAppAddr(app apitypes.Application) (*utils.NetAddr, bool, error) {
uri := app.GetURI()
if !app.IsTCP() {
return nil, false, trace.BadParameter(`unexpected app %q address network, expected "tcp" or %q: %s`, app.GetName(), apitypes.SchemeTLS, uri)
}
uriAddr, err := utils.ParseAddr(uri)
if err == nil {
return uriAddr, false, nil
}
tlsScheme := apitypes.SchemeTLS + "://"
if !strings.HasPrefix(uri, tlsScheme) {
return nil, false, trace.Wrap(err)
}
// Replaces the "tls://" scheme with "tcp://" and try parsing again.
uriAddr, err = utils.ParseAddr(strings.Replace(uri, tlsScheme, "tcp://", 1))
if err != nil {
return nil, false, trace.Wrap(err)
}
return uriAddr, true, nil
}
+19 -17
View File
@@ -20,7 +20,6 @@ package app
import (
"context"
"crypto/tls"
"io"
"log/slog"
"net"
@@ -39,6 +38,7 @@ import (
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/srv/app/common"
"github.com/gravitational/teleport/lib/srv/app/upstreamtls"
"github.com/gravitational/teleport/lib/utils"
)
@@ -56,8 +56,10 @@ type transportConfig struct {
rewriteTraits wrappers.Traits
log *slog.Logger
// hostID is purely for troubleshooting purposes (put in the error messages)
hostID string
insecureMode bool
hostID string
insecureMode bool
clusterName string
certAuthorityGetter upstreamtls.CertificateAuthorityGetter
}
// Check validates configuration.
@@ -74,6 +76,12 @@ func (c *transportConfig) Check() error {
if c.log == nil {
c.log = slog.With(teleport.ComponentKey, "transport")
}
if c.clusterName == "" {
return trace.BadParameter("cluster name missing")
}
if c.certAuthorityGetter == nil {
return trace.BadParameter("cert authority getter missing")
}
return nil
}
@@ -110,7 +118,14 @@ func newTransport(ctx context.Context, c *transportConfig) (*transport, error) {
tr.ResponseHeaderTimeout = responseHeaderTimeout
tr.TLSClientConfig, err = configureTLS(c)
tr.TLSClientConfig, err = upstreamtls.Configure(ctx, upstreamtls.Options{
Logger: c.log,
CAGetter: c.certAuthorityGetter,
ClusterName: c.clusterName,
App: c.app,
CipherSuites: c.cipherSuites,
InsecureMode: c.insecureMode,
})
if err != nil {
return nil, trace.Wrap(err)
}
@@ -284,19 +299,6 @@ func (t *transport) rewriteRedirect(resp *http.Response) error {
return nil
}
// configureTLS creates and configures a *tls.Config that will be used for
// mutual authentication.
func configureTLS(c *transportConfig) (*tls.Config, error) {
tlsConfig := utils.TLSConfig(c.cipherSuites)
// Don't verify the server's certificate if Teleport was started with
// the --insecure flag, or 'insecure_skip_verify' was specifically requested in
// the application config.
tlsConfig.InsecureSkipVerify = (c.insecureMode || c.app.GetInsecureSkipVerify())
return tlsConfig, nil
}
// host returns the host from a host:port string.
func host(addr string) string {
host, _, err := net.SplitHostPort(addr)
+13 -3
View File
@@ -30,6 +30,7 @@ import (
"testing/synctest"
"time"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
@@ -197,9 +198,11 @@ func newTestTransport(t *testing.T, dial func(context.Context, string, string) (
})
require.NoError(t, err)
tr, err := newTransport(t.Context(), &transportConfig{
app: app,
publicPort: "443",
jwt: "test-jwt",
app: app,
publicPort: "443",
jwt: "test-jwt",
clusterName: "example",
certAuthorityGetter: &emptyCertAuthorityGetter{},
})
require.NoError(t, err)
tr.tr.(*http.Transport).DialContext = dial
@@ -266,3 +269,10 @@ func (noopAudit) OnDynamoDBRequest(context.Context, *common.SessionContext, *htt
func (noopAudit) EmitEvent(context.Context, apievents.AuditEvent) error {
return nil
}
type emptyCertAuthorityGetter struct{}
// GetCertAuthority returns cert authority by id.
func (emptyCertAuthorityGetter) GetCertAuthority(context.Context, types.CertAuthID, bool) (types.CertAuthority, error) {
return nil, trace.NotFound("certificate authority not found")
}
+289
View File
@@ -0,0 +1,289 @@
// Teleport
// Copyright (C) 2026 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 upstreamtls
import (
"cmp"
"context"
"crypto/tls"
"crypto/x509"
"iter"
"log/slog"
"net/url"
"slices"
"github.com/gravitational/trace"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/spiffetls"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/tlsutils"
"github.com/gravitational/teleport/lib/services"
"github.com/gravitational/teleport/lib/utils"
)
// CertificateAuthorityGetter used to retrieve CA public information.
type CertificateAuthorityGetter interface {
// GetCertAuthority returns cert authority by id.
GetCertAuthority(context.Context, types.CertAuthID, bool) (types.CertAuthority, error)
}
// Options are the options used to configure the TLS.
type Options struct {
// Logger is the slog.Logger used by the configurator.
Logger *slog.Logger
// CAGetter is the interface used to retrieve certificate authorities.
CAGetter CertificateAuthorityGetter
// ClusterName is the current cluster name.
ClusterName string
// App is the app being configured.
App types.Application
// CipherSuites are the TLS cipher suites.
CipherSuites []uint16
// InsecureMode indicates the service is running in insecure mode.
InsecureMode bool
}
// CheckAndSetDefaults validates required fields and sets defaults for optional
// ones.
func (o *Options) CheckAndSetDefaults() error {
if o.CAGetter == nil {
return trace.BadParameter("CAGetter is required")
}
if o.ClusterName == "" {
return trace.BadParameter("ClusterName is required")
}
if o.App == nil {
return trace.BadParameter("App is required")
}
if o.Logger == nil {
o.Logger = slog.Default()
}
return nil
}
// Configure creates and configures a *tls.Config that will be used to verify
// upstream TLS servers. This function assumes a validated AppTLS, meaning it
// won't perform validation checks for fields and their contents.
//
// Note that [types.AppTLS] can be nil since it is not required for supported
// protocols. This function must take that into account.
func Configure(ctx context.Context, opts Options) (*tls.Config, error) {
if err := opts.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
// Service-level insecure mode takes precedence over app configuration.
if opts.InsecureMode {
return configureTLSInsecure(opts.CipherSuites)
}
appTLS := cmp.Or(opts.App.GetTLS(), &types.AppTLS{})
logger := opts.Logger.With("app", opts.App.GetName())
caPool, err := newTLSCertPool(ctx, logger, opts.CAGetter, opts.ClusterName, appTLS.AllowedCas)
if err != nil {
return nil, trace.Wrap(err)
}
switch opts.App.GetTLSMode() {
case types.AppTLSModeVerifyFull:
spiffeID, err := spiffeid.FromString(appTLS.ServerSpiffeId)
if err != nil {
return nil, trace.BadParameter("app contains an invalid tls.server_spiffe_id field: %v", err)
}
return configureTLSVerifyFull(opts.CipherSuites, caPool, opts.App.GetURI(), spiffeID, appTLS.ServerName)
case types.AppTLSModeVerifySpiffeID:
spiffeID, err := spiffeid.FromString(appTLS.ServerSpiffeId)
if err != nil {
return nil, trace.BadParameter("app contains an invalid tls.server_spiffe_id field: %v", err)
}
return configureTLSSpiffeIDVerify(opts.CipherSuites, caPool, spiffeID)
case types.AppTLSModeVerifyServerName:
return configureTLSVerifyServerName(opts.CipherSuites, caPool, appTLS.ServerName)
case types.AppTLSModeInsecure:
return configureTLSInsecure(opts.CipherSuites)
case "":
// Empty mode is returned by GetTLSMode() for app protocols that don't
// support TLS configuration. Return baseline tls.Config so callers
// still get configured cipher suites.
return utils.TLSConfig(opts.CipherSuites), nil
default:
// This should be unreachable. In case it does, it is probably caused by
// a new TLS mode being added to the config but not here. Ensure this
// switch-case handles all valid options.
return nil, trace.BadParameter("unsupported TLS mode %q", opts.App.GetTLSMode())
}
}
func configureTLSSpiffeIDVerify(cipherSuites []uint16, caPool *x509.CertPool, spiffeID spiffeid.ID) (*tls.Config, error) {
tlsConfig := utils.TLSConfig(cipherSuites)
tlsConfig.RootCAs = caPool
tlsConfig.InsecureSkipVerify = true
tlsConfig.ServerName = ""
// Skips server name verification.
tlsConfig.VerifyConnection = tlsVerifyPeerCertificateWithSPIFFE(tlsConfig.RootCAs, spiffeID, "")
return tlsConfig, nil
}
func configureTLSVerifyFull(cipherSuites []uint16, caPool *x509.CertPool, appURI string, spiffeID spiffeid.ID, serverName string) (*tls.Config, error) {
tlsConfig := utils.TLSConfig(cipherSuites)
tlsConfig.RootCAs = caPool
tlsConfig.InsecureSkipVerify = true
// Since we're providing a custom VerifyConnection we must ensure that
// server name is not empty, otherwise it would just skip the server name
// verification.
//
// To do this we parse the app URI and lookup for the hostname.
if serverName == "" {
u, err := url.Parse(appURI)
if err != nil {
return nil, trace.Wrap(err)
}
serverName = u.Hostname()
if serverName == "" {
return nil, trace.BadParameter("cannot derive server name from app URI %q", appURI)
}
}
tlsConfig.ServerName = serverName
tlsConfig.VerifyConnection = tlsVerifyPeerCertificateWithSPIFFE(tlsConfig.RootCAs, spiffeID, serverName)
return tlsConfig, nil
}
// configureTLSVerifyServerName setups a TLS config with standard hostname
// certificate match verification.
func configureTLSVerifyServerName(cipherSuites []uint16, caPool *x509.CertPool, serverName string) (*tls.Config, error) {
tlsConfig := utils.TLSConfig(cipherSuites)
tlsConfig.RootCAs = caPool
// In case the property is empty (default value), the standard library
// will pickup the appropriate hostname value.
tlsConfig.ServerName = serverName
return tlsConfig, nil
}
func configureTLSInsecure(cipherSuites []uint16) (*tls.Config, error) {
tlsConfig := utils.TLSConfig(cipherSuites)
tlsConfig.InsecureSkipVerify = true
return tlsConfig, nil
}
// tlsVerifyPeerCertificateWithSPIFFE creates a tls.VerifyConnection function
// that verifies the certificate chain, SPIFFE ID extension, and server name.
func tlsVerifyPeerCertificateWithSPIFFE(roots *x509.CertPool, spiffeID spiffeid.ID, serverName string) func(cs tls.ConnectionState) error {
return func(cs tls.ConnectionState) error {
opts := x509.VerifyOptions{
Roots: roots,
// When `serverName` is empty, it means no DNSName verification will
// be done.
DNSName: serverName,
Intermediates: nil,
}
if len(cs.PeerCertificates) == 0 {
return trace.BadParameter("no peer certificates presented")
}
if len(cs.PeerCertificates) > 1 {
opts.Intermediates = x509.NewCertPool()
for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert)
}
}
if _, err := cs.PeerCertificates[0].Verify(opts); err != nil {
return trace.Wrap(err)
}
// Returns error if the certificate doesn't contain URI SAN (SPIFFE ID).
upstreamID, err := spiffetls.PeerIDFromConnectionState(cs)
if err != nil {
return trace.Wrap(err)
}
if upstreamID != spiffeID {
return trace.BadParameter("spiffe id mismatch. expected %q but got %q", spiffeID, upstreamID)
}
return nil
}
}
// newTLSCertPool creates a new x509 cert pool using the list of allowed CAs.
func newTLSCertPool(ctx context.Context, logger *slog.Logger, getter CertificateAuthorityGetter, clusterName string, cas []string) (*x509.CertPool, error) {
// If no options are provided, use the host's root CA (default behavior).
// This is mainly to keep backwards compatibility for apps using TLS
// connections, and that doesn't configure the CA list.
if len(cas) == 0 {
logger.DebugContext(ctx, "using system trust store")
return nil, nil
}
caPool := x509.NewCertPool()
for _, ca := range cas {
switch {
case slices.Contains(types.AppSupportedInternalCAs(), ca):
certs, err := loadCACertificates(ctx, getter, clusterName, ca)
if err != nil {
return nil, trace.Wrap(err)
}
var hasAny bool
for cert, err := range certs {
if err != nil {
return nil, trace.Wrap(err)
}
hasAny = true
caPool.AddCert(cert)
}
if !hasAny {
logger.WarnContext(ctx, "CA alias contains non active keys, it won't be effective", "alias", ca)
}
default:
caCert, err := tlsutils.ParseCertificatePEMStrict([]byte(ca))
if err != nil {
return nil, trace.Wrap(err)
}
caPool.AddCert(caCert)
}
}
return caPool, nil
}
// loadCACertificates takes a "CA alias" and resolve to Teleport CA certificates.
func loadCACertificates(ctx context.Context, getter CertificateAuthorityGetter, clusterName string, alias types.AppTLSInternalCA) (iter.Seq2[*x509.Certificate, error], error) {
var caType types.CertAuthType
switch alias {
case types.AppTLSInternalCAWorkloadIdentity:
caType = types.SPIFFECA
default:
// This should be unreachable. If it happens there is probably a
// mismatch between this switch and the
// `types.AppSupportedInternalCAs` function.
return nil, trace.BadParameter("unsupported CA %q", alias)
}
ca, err := getter.GetCertAuthority(ctx, types.CertAuthID{
Type: caType,
DomainName: clusterName,
}, false)
if err != nil {
return nil, trace.Wrap(err)
}
return services.GetX509Certs(ca), nil
}
+189
View File
@@ -0,0 +1,189 @@
// Teleport
// Copyright (C) 2026 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 upstreamtls
import (
"context"
"crypto/x509"
"crypto/x509/pkix"
"testing"
"time"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils/log/logtest"
)
func TestNewTLSCertPool(t *testing.T) {
t.Parallel()
const clusterName = "test-cluster"
_, inlineCAPEM, err := tlsca.GenerateSelfSignedCA(
pkix.Name{Organization: []string{"inline-ca"}}, nil, time.Hour,
)
require.NoError(t, err)
_, spiffeCAPEM1, err := tlsca.GenerateSelfSignedCA(
pkix.Name{Organization: []string{"spiffe-ca-1"}}, nil, time.Hour,
)
require.NoError(t, err)
_, spiffeCAPEM2, err := tlsca.GenerateSelfSignedCA(
pkix.Name{Organization: []string{"spiffe-ca-2"}}, nil, time.Hour,
)
require.NoError(t, err)
spiffeCAID := types.CertAuthID{Type: types.SPIFFECA, DomainName: clusterName}
for name, tc := range map[string]struct {
cas []string
getter CertificateAuthorityGetter
expectedErr require.ErrorAssertionFunc
expectedPool require.ValueAssertionFunc
}{
"empty input returns nil pool": {
cas: nil,
getter: &fakeCAGetter{},
expectedErr: require.NoError,
expectedPool: require.Nil,
},
"inline PEM only": {
cas: []string{string(inlineCAPEM)},
getter: &fakeCAGetter{},
expectedErr: require.NoError,
expectedPool: func(tt require.TestingT, i1 any, i2 ...any) {
expected := x509.NewCertPool()
require.True(tt, expected.AppendCertsFromPEM(inlineCAPEM), i2...)
generatedPool, _ := i1.(*x509.CertPool)
require.True(tt, generatedPool.Equal(expected), "cert pool contents differ")
},
},
"alias resolves to single key pair": {
cas: []string{types.AppTLSInternalCAWorkloadIdentity},
getter: &fakeCAGetter{cas: map[types.CertAuthID]types.CertAuthority{
spiffeCAID: newSPIFFECertAuthority(t, clusterName, spiffeCAPEM1),
}},
expectedErr: require.NoError,
expectedPool: func(tt require.TestingT, i1 any, i2 ...any) {
expected := x509.NewCertPool()
require.True(tt, expected.AppendCertsFromPEM(spiffeCAPEM1), i2...)
generatedPool, _ := i1.(*x509.CertPool)
require.True(tt, generatedPool.Equal(expected), "cert pool contents differ")
},
},
"alias resolves to multiple key pairs (rotation)": {
cas: []string{types.AppTLSInternalCAWorkloadIdentity},
getter: &fakeCAGetter{cas: map[types.CertAuthID]types.CertAuthority{
spiffeCAID: newSPIFFECertAuthority(t, clusterName, spiffeCAPEM1, spiffeCAPEM2),
}},
expectedErr: require.NoError,
expectedPool: func(tt require.TestingT, i1 any, i2 ...any) {
expected := x509.NewCertPool()
require.True(tt, expected.AppendCertsFromPEM(spiffeCAPEM1))
require.True(tt, expected.AppendCertsFromPEM(spiffeCAPEM2))
generatedPool, _ := i1.(*x509.CertPool)
require.True(tt, generatedPool.Equal(expected), "cert pool contents differ")
},
},
"alias resolves but CA has no TLS key pairs": {
cas: []string{types.AppTLSInternalCAWorkloadIdentity},
getter: &fakeCAGetter{cas: map[types.CertAuthID]types.CertAuthority{
spiffeCAID: newSPIFFECertAuthority(t, clusterName),
}},
expectedErr: require.NoError,
expectedPool: func(tt require.TestingT, i1 any, i2 ...any) {
generatedPool, _ := i1.(*x509.CertPool)
require.NotNil(t, generatedPool)
require.True(tt, generatedPool.Equal(x509.NewCertPool()), "expected empty cert pool")
},
},
"alias getter error is propagated": {
cas: []string{types.AppTLSInternalCAWorkloadIdentity},
getter: &fakeCAGetter{err: trace.NotFound("no SPIFFE CA")},
expectedErr: require.Error,
expectedPool: require.Nil,
},
"inline PEM and alias combined": {
cas: []string{string(inlineCAPEM), types.AppTLSInternalCAWorkloadIdentity},
getter: &fakeCAGetter{cas: map[types.CertAuthID]types.CertAuthority{
spiffeCAID: newSPIFFECertAuthority(t, clusterName, spiffeCAPEM1),
}},
expectedErr: require.NoError,
expectedPool: func(tt require.TestingT, i1 any, i2 ...any) {
expected := x509.NewCertPool()
require.True(tt, expected.AppendCertsFromPEM(inlineCAPEM))
require.True(tt, expected.AppendCertsFromPEM(spiffeCAPEM1))
generatedPool, _ := i1.(*x509.CertPool)
require.True(tt, generatedPool.Equal(expected), "cert pool contents differ")
},
},
"malformed PEM returns error": {
cas: []string{"not a pem"},
getter: &fakeCAGetter{},
expectedErr: require.Error,
expectedPool: require.Nil,
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
pool, err := newTLSCertPool(context.Background(), logtest.NewLogger(), tc.getter, clusterName, tc.cas)
tc.expectedErr(t, err)
tc.expectedPool(t, pool)
})
}
}
// fakeCAGetter is a CertificateAuthorityGetter backed by an in-memory map.
type fakeCAGetter struct {
cas map[types.CertAuthID]types.CertAuthority
err error
}
func (f *fakeCAGetter) GetCertAuthority(_ context.Context, id types.CertAuthID, _ bool) (types.CertAuthority, error) {
if f.err != nil {
return nil, f.err
}
ca, ok := f.cas[id]
if !ok {
return nil, trace.NotFound("ca %v not found", id)
}
return ca, nil
}
// newSPIFFECertAuthority builds a SPIFFE CertAuthority whose ActiveKeys.TLS
// holds one entry per provided cert PEM.
func newSPIFFECertAuthority(t *testing.T, clusterName string, certPEMs ...[]byte) types.CertAuthority {
t.Helper()
keyPairs := make([]*types.TLSKeyPair, 0, len(certPEMs))
for _, certPEM := range certPEMs {
keyPairs = append(keyPairs, &types.TLSKeyPair{Cert: certPEM})
}
ca, err := types.NewCertAuthority(types.CertAuthoritySpecV2{
Type: types.SPIFFECA,
ClusterName: clusterName,
ActiveKeys: types.CAKeySet{TLS: keyPairs},
})
require.NoError(t, err)
return ca
}
+729
View File
@@ -0,0 +1,729 @@
// Teleport
// Copyright (C) 2026 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 app
import (
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jonboulle/clockwork"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/auth/authcatest"
"github.com/gravitational/teleport/lib/cryptosuites"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
)
// TestHandleConnectionTLSUpstream verifies that the app service can establish
// a TLS connection with an upstream HTTPS application using the "tls" options.
//
// Note: We can't test default behavior when TLS is empty because our test
// server uses self-signed certificate, and setting `SSL_CERT_FILE` would only
// work on Linux.
func TestHandleConnectionTLSUpstream(t *testing.T) {
for name, tc := range map[string]struct {
tlsOptsFunc func(*tlsUpstreamSetup) *types.AppTLS
upstreamOpts []upstreamServerOpt
insecureMode bool
expectError bool
}{
"verify-server-name": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyServerName,
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
expectError: false,
},
"verify-server-name custom server name": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyServerName,
ServerName: "example-server.com",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"example-server.com"}),
},
expectError: false,
},
"verify-server-name mismatch": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyServerName,
ServerName: "random-server.com",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"example-server.com"}),
},
expectError: true,
},
"verify-server-name untrusted CA": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyServerName,
}
},
expectError: true,
},
"verify-spiffe-id": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: false,
},
"verify-spiffe-id skips server name verify": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"example-server.com"}),
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: false,
},
"verify-spiffe-id mismatch": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/random",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: true,
},
"verify-spiffe-id non-SVID cert": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/random",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
expectError: true,
},
"verify-spiffe-id untrusted CA": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/example",
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: true,
},
"verify-spiffe-id workload_identity alias": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{types.AppTLSInternalCAWorkloadIdentity},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
withUpstreamCustomCA(types.SPIFFECA),
},
expectError: false,
},
"verify-spiffe-id workload_identity alias with cert from another CA": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{types.AppTLSInternalCAWorkloadIdentity},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: true,
},
"verify-spiffe-id workload_identity alias combined with inline CA": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifySpiffeID,
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{
types.AppTLSInternalCAWorkloadIdentity,
string(setup.serverCACertPEM),
},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: false,
},
"verify-full": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerName: "example-server.com",
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"example-server.com"}),
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: false,
},
"verify-full unspecified server name": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: false,
},
"verify-full untrusted CA": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerName: "example-server.com",
ServerSpiffeId: "spiffe://mycluster/svc/example",
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"example-server.com"}),
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: true,
},
"verify-full server name mismatch": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerName: "example-server.com",
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"random-server.com"}),
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
expectError: true,
},
"verify-full mismatch server spiffe id": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerName: "example-server.com",
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"example-server.com"}),
withUpstreamSpiffeID("spiffe://mycluster/svc/random"),
},
expectError: true,
},
"verify-full workload_identity alias": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerName: "example-server.com",
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{types.AppTLSInternalCAWorkloadIdentity},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"example-server.com"}),
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
withUpstreamCustomCA(types.SPIFFECA),
},
expectError: false,
},
"verify-full both mismatch": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerName: "example-server.com",
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"random-server.com"}),
withUpstreamSpiffeID("spiffe://mycluster/svc/random"),
},
expectError: true,
},
"verify-full server name is set to an IP value": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerName: "127.0.0.1",
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
},
"verify-full server name falls back to app URI hostname": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyFull,
ServerSpiffeId: "spiffe://mycluster/svc/example",
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamSpiffeID("spiffe://mycluster/svc/example"),
},
},
"service level insecure mode takes precedence": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeVerifyServerName,
AllowedCas: []string{string(setup.serverCACertPEM)},
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"random-value.com"}),
},
insecureMode: true,
expectError: false,
},
"insecure": {
tlsOptsFunc: func(setup *tlsUpstreamSetup) *types.AppTLS {
return &types.AppTLS{
Mode: types.AppTLSModeInsecure,
}
},
upstreamOpts: []upstreamServerOpt{
withUpstreamCertSANs([]string{"random-value.com"}),
},
expectError: false,
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
clock := clockwork.NewRealClock()
setup := newTLSUpstreamSetup(t)
httpsMessage := uuid.New().String()
httpsUpstreamAddr := setup.startUpstreamHTTPSServer(
t,
clock,
httpsMessage,
tc.upstreamOpts...,
)
tlsMessage := uuid.New().String()
tlsUpstreamAddr := setup.startUpstreamTLSServer(
t,
clock,
tlsMessage,
tc.upstreamOpts...,
)
// Since we're configuring the TLS verify function, we must ensure
// that different handling (IP and DNS) should work the same way.
httpsApps, httpsPublicAddrs := configureAppIPAndLocalhost(t, setup, httpsUpstreamAddr, tc.tlsOptsFunc(setup))
tlsApps, tlsPublicAddrs := configureAppIPAndLocalhost(t, setup, tlsUpstreamAddr, tc.tlsOptsFunc(setup))
s := SetUpSuiteWithConfig(t, suiteConfig{
Apps: append(httpsApps, tlsApps...),
OverrideCAs: []types.CertAuthority{setup.spiffeCAResource},
InsecureMode: tc.insecureMode,
})
t.Run("HTTPS", func(t *testing.T) {
for _, publicAddr := range httpsPublicAddrs {
t.Run(publicAddr, func(t *testing.T) {
cert := s.generateCertificate(t, s.user, publicAddr, "")
s.checkHTTPResponse(t, cert, func(resp *http.Response) {
if tc.expectError {
require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected HTTPS request to fail")
return
}
require.Equal(t, http.StatusOK, resp.StatusCode)
buf, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, httpsMessage, strings.TrimSpace(string(buf)))
})
})
}
})
t.Run("TLS", func(t *testing.T) {
for _, publicAddr := range tlsPublicAddrs {
t.Run(publicAddr, func(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
var wg sync.WaitGroup
wg.Go(func() {
s.appServer.HandleConnection(clientConn)
})
// This is dialing the app server not the upstream
// target. We should expect no errors in this phase.
cert := s.generateCertificate(t, s.user, publicAddr, "")
tlsConn := tls.Client(serverConn, &tls.Config{
RootCAs: s.hostCertPool,
Certificates: []tls.Certificate{cert},
ServerName: constants.APIDomain,
Time: s.clock.Now,
})
t.Cleanup(func() { _ = tlsConn.Close() })
err := tlsConn.HandshakeContext(t.Context())
require.NoError(t, err)
// Upstream errors will appear for clients on the first
// write.
_, err = tlsConn.Write([]byte("hello"))
if tc.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
buf := make([]byte, len(tlsMessage))
_, err = tlsConn.Read(buf)
require.NoError(t, err)
require.Equal(t, tlsMessage, string(buf))
})
}
})
})
}
}
// tlsUpstreamSetup holds the CAs, and helpers needed to configure an upstream
// TLS application for testing.
type tlsUpstreamSetup struct {
serverCA *tlsca.CertAuthority
serverCACertPEM []byte
spiffeCA *tlsca.CertAuthority
spiffeCAResource types.CertAuthority
}
func newTLSUpstreamSetup(t *testing.T) *tlsUpstreamSetup {
t.Helper()
serverCAKey, serverCACertPEM, err := tlsca.GenerateSelfSignedCA(
pkix.Name{Organization: []string{"test-upstream-server-ca"}},
nil,
1*time.Hour,
)
require.NoError(t, err)
serverCA, err := tlsca.FromKeys(serverCACertPEM, serverCAKey)
require.NoError(t, err)
// Build a SPIFFE CA whose private key is owned by the test.
spiffeCAResource, err := authcatest.NewCA(types.SPIFFECA, "root.example.com")
require.NoError(t, err)
spiffeKP := spiffeCAResource.GetActiveKeys().TLS[0]
spiffeCA, err := tlsca.FromKeys(spiffeKP.Cert, spiffeKP.Key)
require.NoError(t, err)
return &tlsUpstreamSetup{
serverCA: serverCA,
serverCACertPEM: serverCACertPEM,
spiffeCA: spiffeCA,
spiffeCAResource: spiffeCAResource,
}
}
type upstreamConfig struct {
sans []string
spiffeID string
customCA types.CertAuthType
}
type upstreamServerOpt func(c *upstreamConfig)
func withUpstreamCertSANs(sans []string) upstreamServerOpt {
return func(c *upstreamConfig) {
c.sans = sans
}
}
func withUpstreamSpiffeID(s string) upstreamServerOpt {
return func(c *upstreamConfig) {
c.spiffeID = s
}
}
func withUpstreamCustomCA(caType types.CertAuthType) upstreamServerOpt {
return func(c *upstreamConfig) {
c.customCA = caType
}
}
// startUpstreamHTTPSServer starts an httptest HTTPS server using self-signed
// certificate.
func (m *tlsUpstreamSetup) startUpstreamHTTPSServer(t *testing.T, clock clockwork.Clock, message string, opts ...upstreamServerOpt) string {
t.Helper()
var cfg upstreamConfig
for _, opt := range opts {
opt(&cfg)
}
upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintln(w, message)
}))
// Disable keep alive so we force new connections every time, forcing
// certificate validation.
upstream.Config.SetKeepAlivesEnabled(false)
upstream.TLS = m.generateUpstreamTLSConfig(t, cfg, clock)
upstream.StartTLS()
t.Cleanup(upstream.Close)
return upstream.URL
}
// startUpstreamTLSServer starts an httptest HTTPS server using self-signed
// certificate.
func (m *tlsUpstreamSetup) startUpstreamTLSServer(t *testing.T, clock clockwork.Clock, message string, opts ...upstreamServerOpt) string {
t.Helper()
var cfg upstreamConfig
for _, opt := range opts {
opt(&cfg)
}
ln, err := tls.Listen("tcp", "127.0.0.1:0", m.generateUpstreamTLSConfig(t, cfg, clock))
require.NoError(t, err)
var wg sync.WaitGroup
t.Cleanup(func() {
_ = ln.Close()
wg.Wait()
})
wg.Go(func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
wg.Go(func() {
defer conn.Close()
buf := make([]byte, 32)
for {
_, err := conn.Read(buf)
if err != nil {
return
}
fmt.Fprintln(conn, message)
}
})
}
})
return types.SchemeTLS + "://" + ln.Addr().String()
}
// generateUpstreamTLSConfig generates a server certificate signed by the server
// CA (using real time for NotAfter so the app service transport can validate it).
func (m *tlsUpstreamSetup) generateUpstreamTLSConfig(t *testing.T, cfg upstreamConfig, clock clockwork.Clock) *tls.Config {
t.Helper()
serverKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
require.NoError(t, err)
serverKeyPEM, err := keys.MarshalPrivateKey(serverKey)
require.NoError(t, err)
csr := tlsca.CertificateRequest{
Clock: clock,
PublicKey: serverKey.Public(),
Subject: pkix.Name{CommonName: "127.0.0.1"},
NotAfter: time.Now().Add(1 * time.Hour),
DNSNames: []string{"127.0.0.1", "localhost"},
}
if len(cfg.sans) > 0 {
csr.DNSNames = cfg.sans
}
signingCA := m.serverCA
if cfg.customCA == types.SPIFFECA {
signingCA = m.spiffeCA
}
var serverCertPEM []byte
if cfg.spiffeID != "" {
spiffeID, err := spiffeid.FromString(cfg.spiffeID)
require.NoError(t, err)
serverCertPEM = generateSVIDCertificate(t, *signingCA, csr, spiffeID)
} else {
serverCertPEM, err = signingCA.GenerateCertificate(csr)
require.NoError(t, err)
}
serverCert, err := tls.X509KeyPair(serverCertPEM, serverKeyPEM)
require.NoError(t, err)
// Disable keep alive so we force new connections every time, forcing
// certificate validation.
return &tls.Config{
Certificates: []tls.Certificate{serverCert},
Time: clock.Now,
}
}
// newApp creates a types.AppV3 configured with TLS options.
func (m *tlsUpstreamSetup) newApp(t *testing.T, upstreamURL string, publicAddr string, appTLS *types.AppTLS) *types.AppV3 {
t.Helper()
name := uuid.New().String()
app, err := types.NewAppV3(types.Metadata{
Name: name,
Labels: staticLabels,
}, types.AppSpecV3{
URI: upstreamURL,
PublicAddr: publicAddr,
TLS: appTLS,
})
require.NoError(t, err)
return app
}
func generateSVIDCertificate(t *testing.T, ca tlsca.CertAuthority, req tlsca.CertificateRequest, spiffeID spiffeid.ID) []byte {
require.NoError(t, req.CheckAndSetDefaults())
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
require.NoError(t, err)
// Based on Workload idenity issuance service.
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: req.Subject,
NotBefore: req.Clock.Now().UTC().Add(-1 * time.Minute),
NotAfter: req.NotAfter,
// SPEC(X509-SVID) 4.3. Key Usage:
// - Leaf SVIDs MUST NOT set keyCertSign or cRLSign.
// - Leaf SVIDs MUST set digitalSignature
// - They MAY set keyEncipherment and/or keyAgreement;
KeyUsage: x509.KeyUsageDigitalSignature |
x509.KeyUsageKeyEncipherment |
x509.KeyUsageKeyAgreement,
// SPEC(X509-SVID) 4.4. Extended Key Usage:
// - Leaf SVIDs SHOULD include this extension, and it MAY be marked as critical.
// - When included, fields id-kp-serverAuth and id-kp-clientAuth MUST be set.
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth,
},
// SPEC(X509-SVID) 4.1. Basic Constraints:
// - leaf certificates MUST set the cA field to false
BasicConstraintsValid: true,
IsCA: false,
// SPEC(X509-SVID) 2. SPIFFE ID:
// - The corresponding SPIFFE ID is set as a URI type in the Subject Alternative Name extension
// - An X.509 SVID MUST contain exactly one URI SAN, and by extension, exactly one SPIFFE ID.
// - An X.509 SVID MAY contain any number of other SAN field types, including DNS SANs.
URIs: []*url.URL{spiffeID.URL()},
}
for i := range req.DNSNames {
if ip := net.ParseIP(req.DNSNames[i]); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, req.DNSNames[i])
}
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, req.PublicKey, ca.Signer)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})
}
func configureAppIPAndLocalhost(t *testing.T, setup *tlsUpstreamSetup, url string, tlsOptions *types.AppTLS) ([]types.Application, []string) {
randomHex, err := utils.CryptoRandomHex(5)
require.NoError(t, err)
ipPublicAddr := "ip." + randomHex + ".example.com"
localhostPublicAddr := "localhost." + randomHex + ".example.com"
appUsingIP := setup.newApp(t, url, ipPublicAddr, tlsOptions)
appUsingLocalhost := setup.newApp(t, convertToLocalhost(t, url), localhostPublicAddr, tlsOptions)
return []types.Application{appUsingIP, appUsingLocalhost}, []string{ipPublicAddr, localhostPublicAddr}
}
// convertToLocalhost converts a upstream IP address to localhost.
func convertToLocalhost(t *testing.T, rawURL string) string {
t.Helper()
u, err := url.Parse(rawURL)
require.NoError(t, err)
_, port, err := net.SplitHostPort(u.Host)
require.NoError(t, err)
u.Host = net.JoinHostPort("localhost", port)
return u.String()
}
+3
View File
@@ -276,6 +276,9 @@ func (f fakeAccessPoint) GetClusterName(context.Context) (types.ClusterName, err
})
return clusterName, trace.Wrap(err)
}
func (f fakeAccessPoint) GetCertAuthority(context.Context, types.CertAuthID, bool) (types.CertAuthority, error) {
return nil, trace.NotImplemented("not implemented")
}
func checkParamsHaveNameField(t *testing.T, params *apievents.Struct, wantName string) {
t.Helper()
+3 -3
View File
@@ -76,7 +76,7 @@ func (s *Server) handleStreamableHTTP(ctx context.Context, sessionCtx *SessionCt
}
defer session.sessionAuditor.flush(s.cfg.ParentContext)
transport, err := s.makeStreamableHTTPTransport(session)
transport, err := s.makeStreamableHTTPTransport(ctx, session)
if err != nil {
return trace.Wrap(err, "setting up streamable http transport")
}
@@ -108,14 +108,14 @@ func (s *Server) handleStreamableHTTP(ctx context.Context, sessionCtx *SessionCt
return trace.Wrap(s.serveHTTPConn(ctx, sessionCtx.ClientConn, reverseProxy))
}
func (s *Server) makeStreamableHTTPTransport(session *sessionHandler) (http.RoundTripper, error) {
func (s *Server) makeStreamableHTTPTransport(ctx context.Context, session *sessionHandler) (http.RoundTripper, error) {
targetURI, err := url.Parse(session.App.GetURI())
if err != nil {
return nil, trace.Wrap(err)
}
targetURI.Scheme = strings.TrimPrefix(targetURI.Scheme, "mcp+")
targetTransport, err := s.makeBasicHTTPTransport(session.App)
targetTransport, err := s.makeBasicHTTPTransport(ctx, session.App)
if err != nil {
return nil, trace.Wrap(err)
}
+3
View File
@@ -43,6 +43,9 @@ import (
type AccessPoint interface {
services.AuthPreferenceGetter
services.ClusterNameGetter
// GetCertAuthority returns cert authority by id.
GetCertAuthority(context.Context, types.CertAuthID, bool) (types.CertAuthority, error)
}
// AuthClient defines functions that the MCP server requires from the auth
+26 -7
View File
@@ -29,7 +29,7 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/srv/app/upstreamtls"
logutils "github.com/gravitational/teleport/lib/utils/log"
"github.com/gravitational/teleport/lib/utils/mcputils"
)
@@ -55,7 +55,7 @@ func (s *Server) handleStdioToSSE(ctx context.Context, sessionCtx *SessionCtx) e
defer session.close()
// Use custom transport that adds extra headers including JWT.
httpTransport, err := s.makeSSEHTTPTransport(session)
httpTransport, err := s.makeSSEHTTPTransport(ctx, session)
if err != nil {
return trace.Wrap(err, "creating HTTP transport")
}
@@ -124,18 +124,37 @@ func makeSSEBaseURI(app types.Application) (*url.URL, error) {
return baseURL, nil
}
func (s *Server) makeBasicHTTPTransport(app types.Application) (http.RoundTripper, error) {
func (s *Server) makeBasicHTTPTransport(ctx context.Context, app types.Application) (http.RoundTripper, error) {
// Use similar settings from lib/srv/app/transport.go.
tr, err := defaults.Transport()
if err != nil {
return nil, trace.Wrap(err)
}
clusterName, err := s.cfg.AccessPoint.GetClusterName(ctx)
if err != nil {
return nil, trace.Wrap(err)
}
// Add a timeout to control how long it takes to (start) getting a response
// from the target server.
tr.ResponseHeaderTimeout = time.Minute
tr.TLSClientConfig = utils.TLSConfig(s.cfg.CipherSuites)
tr.TLSClientConfig.InsecureSkipVerify = s.cfg.InsecureMode || app.GetInsecureSkipVerify()
// Use app TLS options.
//
// Note: For non-TLS apps (like `mcp+http`) this won't affect the
// connections as the transport won't make used of it.
tr.TLSClientConfig, err = upstreamtls.Configure(ctx, upstreamtls.Options{
Logger: s.cfg.Log,
CAGetter: s.cfg.AccessPoint,
ClusterName: clusterName.GetClusterName(),
App: app,
CipherSuites: s.cfg.CipherSuites,
InsecureMode: s.cfg.InsecureMode,
})
if err != nil {
return nil, trace.Wrap(err)
}
return tr, nil
}
@@ -151,8 +170,8 @@ func (t *sseHTTPTransport) RoundTrip(r *http.Request) (resp *http.Response, err
return t.targetTransport.RoundTrip(r)
}
func (s *Server) makeSSEHTTPTransport(session *sessionHandler) (http.RoundTripper, error) {
targetTransport, err := s.makeBasicHTTPTransport(session.App)
func (s *Server) makeSSEHTTPTransport(ctx context.Context, session *sessionHandler) (http.RoundTripper, error) {
targetTransport, err := s.makeBasicHTTPTransport(ctx, session.App)
if err != nil {
return nil, trace.Wrap(err)
}