MWI: HTTP Proxying App Access service (#59064)

* Initial implementation of an HTTP Proxy implementation for TBot

* Added caching feature, caching config option & tests

* Rework httpClient; Rework insecureSkipVerify; Remove whitelist; Improved Error Handling;

* Move proxyPing to startProxy

* Remove old Application whitelist reference

* Review comments

* fix header issue, improve tests a bit

* Fix passing path; Improve testing

* add query passing; add tests for it

* Merge contexts of request

* Use service ctx as base context for http server

* Simplify creation of upstream request

* Add TODO around determing correct URL to use when connecting upstream

* Add TODO on checking ALPN upgrade

* Block CONNECT requests

* Add TODO around caching whole http client

* Add note on public address of app

* Fix upstream request host/scheme

* Tidy

* Fix logging to guidelines

* Add support for ALPN upgrade

* A little tidying

* Add GoDocs for the service config

* Setup fncache with correct interval

* Add GoDoc

* Remove redundant otel tracer

* Add tests for config

* Fix lint issues

* Update config golden file

* Fix test

* Add license headers

* proxy_service_config.go -> proxy_config.go

* Update lib/tbot/services/application/proxy_service_test.go

Co-authored-by: Dan Upton <[email protected]>

* Add note on h2c/http2

* Remove  spurious line

* Add godocs

* Replace context.Background() with t.Context() in test

* Add GoDOc for ProxyServiceType

* Update ports in example to not clash with postgres

* Add CLI command

---------

Co-authored-by: Thijs Cramer <[email protected]>
Co-authored-by: Dan Upton <[email protected]>
This commit is contained in:
Noah Stride
2025-09-25 13:08:12 +00:00
committed by GitHub
co-authored by Dan Upton Thijs Cramer
parent 7d09a9f6aa
commit 3294453ac4
12 changed files with 993 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
/*
* Teleport
* Copyright (C) 2025 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 cli
import (
"fmt"
"log/slog"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/services/application"
)
// ApplicationProxyCommand implements `tbot start application-proxy` and
// `tbot configure application-proxy`.
type ApplicationProxyCommand struct {
*sharedStartArgs
*genericMutatorHandler
Listen string
}
// NewApplicationProxyCommand initializes flags for an app proxy command and
// returns a struct to contain the parse result.
func NewApplicationProxyCommand(parentCmd *kingpin.CmdClause, action MutatorAction, mode CommandMode) *ApplicationProxyCommand {
cmd := parentCmd.Command("application-proxy", fmt.Sprintf("%s tbot with an application proxy.", mode)).Alias("app-proxy")
c := &ApplicationProxyCommand{}
c.sharedStartArgs = newSharedStartArgs(cmd)
c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
cmd.Flag("listen", "A socket URI, such as tcp://0.0.0.0:8080").Required().StringVar(&c.Listen)
return c
}
func (c *ApplicationProxyCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
return trace.Wrap(err)
}
cfg.Services = append(cfg.Services, &application.ProxyServiceConfig{
Listen: c.Listen,
})
return nil
}
@@ -0,0 +1,56 @@
/*
* Teleport
* Copyright (C) 2025 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 cli
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/services/application"
)
// TestApplicationTunnelCommand tests that the ApplicationProxyCommand
// properly parses its arguments and applies as expected onto a BotConfig.
func TestApplicationProxyCommand(t *testing.T) {
testStartConfigureCommand(t, NewApplicationProxyCommand, []startConfigureTestCase{
{
name: "success",
args: []string{
"start",
"application-proxy",
"--token=foo",
"--join-method=github",
"--proxy-server=example.com:443",
"--listen=tcp://0.0.0.0:8000",
},
assertConfig: func(t *testing.T, cfg *config.BotConfig) {
require.Len(t, cfg.Services, 1)
// It must configure an app tunnel service
svc := cfg.Services[0]
appSvc, ok := svc.(*application.ProxyServiceConfig)
require.True(t, ok)
require.Equal(t, "tcp://0.0.0.0:8000", appSvc.Listen)
},
},
})
}
+6
View File
@@ -441,6 +441,12 @@ func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error {
return trace.Wrap(err)
}
out = append(out, v)
case application.ProxyServiceType:
v := &application.ProxyServiceConfig{}
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)
}
+7
View File
@@ -268,6 +268,13 @@ func TestBotConfig_YAML(t *testing.T) {
RenewalInterval: 15 * time.Second,
},
},
&application.ProxyServiceConfig{
Listen: "tcp://127.0.0.1:8080",
CredentialLifetime: bot.CredentialLifetime{
TTL: 30 * time.Second,
RenewalInterval: 15 * time.Second,
},
},
&workloadidentity.X509OutputConfig{
Destination: &destination.Directory{
Path: "/an/output/path",
@@ -44,6 +44,11 @@ services:
app_name: my-app
credential_ttl: 30s
renewal_interval: 15s
- type: application-proxy
listen: tcp://127.0.0.1:8080
credential_lifetime:
credential_ttl: 30s
renewal_interval: 15s
- type: workload-identity-x509
selector:
name: my-workload-identity
@@ -0,0 +1,98 @@
/*
* Teleport
* Copyright (C) 2025 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 application
import (
"net"
"net/url"
"github.com/gravitational/trace"
"gopkg.in/yaml.v3"
"github.com/gravitational/teleport/lib/tbot/bot"
"github.com/gravitational/teleport/lib/tbot/internal/encoding"
)
// ProxyServiceType is the type string for the ProxyService, used within the
// service header to indicate the service type.
const ProxyServiceType = "application-proxy"
// ProxyServiceConfig is the configuration for the ProxyService.
type ProxyServiceConfig struct {
// Name of the service for logs and the /readyz endpoint.
// Optional.
Name string `yaml:"name,omitempty"`
// Listen is the address on which application proxy should listen. Example:
// - "tcp://127.0.0.1:8080"
// - "tcp://0.0.0.0:8080"
Listen string `yaml:"listen"`
// CredentialLifetime contains configuration for how long credentials will
// last and the frequency at which they'll be renewed. For the application
// proxy, this is primarily an internal detail.
CredentialLifetime bot.CredentialLifetime `yaml:"credential_lifetime,omitempty"`
// Listener overrides "listen" and directly provides an opened listener to
// use. Primarily used for testing.
Listener net.Listener `yaml:"-"`
}
// GetName returns the user-given name of the service for reporting.
func (c *ProxyServiceConfig) GetName() string {
return c.Name
}
// Type returns the type of the service.
func (c *ProxyServiceConfig) Type() string {
return ProxyServiceType
}
// MarshalYAML overrides the YAML representation of the service.
func (c *ProxyServiceConfig) MarshalYAML() (any, error) {
type raw ProxyServiceConfig
return encoding.WithTypeHeader((*raw)(c), ProxyServiceType)
}
// UnmarshalYAML is used to override the YAML unmarshaling of the service.
func (c *ProxyServiceConfig) UnmarshalYAML(node *yaml.Node) error {
// Alias type to remove UnmarshalYAML to avoid recursion
type raw ProxyServiceConfig
if err := node.Decode((*raw)(c)); err != nil {
return trace.Wrap(err)
}
return nil
}
// CheckAndSetDefaults checks the user-provided configuration against validation
// rules and sets any default values.
func (c *ProxyServiceConfig) CheckAndSetDefaults() error {
switch {
case c.Listen == "" && c.Listener == nil:
return trace.BadParameter("listen: should not be empty")
}
if _, err := url.Parse(c.Listen); err != nil {
return trace.Wrap(err, "parsing listen")
}
return nil
}
// GetCredentialLifetime returns the embedded CredentialLifetime.
func (c *ProxyServiceConfig) GetCredentialLifetime() bot.CredentialLifetime {
return c.CredentialLifetime
}
@@ -0,0 +1,78 @@
/*
* Teleport
* Copyright (C) 2025 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 application
import (
"testing"
"time"
"github.com/gravitational/teleport/lib/tbot/bot"
)
func TestProxyServiceConfig_YAML(t *testing.T) {
t.Parallel()
tests := []testYAMLCase[ProxyServiceConfig]{
{
name: "full",
in: ProxyServiceConfig{
Name: "foo",
Listen: "tcp://0.0.0.0:3621",
CredentialLifetime: bot.CredentialLifetime{
TTL: 1 * time.Minute,
RenewalInterval: 30 * time.Second,
},
},
},
}
testYAML(t, tests)
}
func TestProxyServiceConfig_CheckAndSetDefaults(t *testing.T) {
t.Parallel()
tests := []testCheckAndSetDefaultsCase[*ProxyServiceConfig]{
{
name: "valid",
in: func() *ProxyServiceConfig {
return &ProxyServiceConfig{
Listen: "tcp://0.0.0.0:3621",
}
},
wantErr: "",
},
{
name: "missing listen",
in: func() *ProxyServiceConfig {
return &ProxyServiceConfig{}
},
wantErr: "listen: should not be empty",
},
{
name: "listen not url",
in: func() *ProxyServiceConfig {
return &ProxyServiceConfig{
Listen: "\x00",
}
},
wantErr: "parsing listen",
},
}
testCheckAndSetDefaults(t, tests)
}
@@ -0,0 +1,377 @@
/*
* Teleport
* Copyright (C) 2025 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 application
import (
"cmp"
"context"
"crypto/tls"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"github.com/gravitational/trace"
apiclient "github.com/gravitational/teleport/api/client"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/srv/alpnproxy/common"
"github.com/gravitational/teleport/lib/tbot/bot"
"github.com/gravitational/teleport/lib/tbot/bot/connection"
"github.com/gravitational/teleport/lib/tbot/client"
"github.com/gravitational/teleport/lib/tbot/identity"
"github.com/gravitational/teleport/lib/tbot/internal"
"github.com/gravitational/teleport/lib/tbot/readyz"
"github.com/gravitational/teleport/lib/utils"
)
// ProxyServiceBuilder returns the service builder used to construct the
// ProxyService during bot startup.
func ProxyServiceBuilder(
cfg *ProxyServiceConfig,
connCfg connection.Config,
defaultCredentialLifetime bot.CredentialLifetime,
alpnUpgradeCache *internal.ALPNUpgradeCache,
) bot.ServiceBuilder {
return func(deps bot.ServiceDependencies) (bot.Service, error) {
if err := cfg.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
svc := &ProxyService{
connCfg: connCfg,
defaultCredentialLifetime: defaultCredentialLifetime,
getBotIdentity: deps.BotIdentity,
botIdentityReadyCh: deps.BotIdentityReadyCh,
proxyPinger: deps.ProxyPinger,
botClient: deps.Client,
cfg: cfg,
identityGenerator: deps.IdentityGenerator,
clientBuilder: deps.ClientBuilder,
alpnUpgradeCache: alpnUpgradeCache,
}
svc.log = deps.LoggerForService(svc)
svc.statusReporter = deps.StatusRegistry.AddService(svc.String())
return svc, nil
}
}
// ProxyService presents a http_proxy compatible proxy on a listener which will
// forward traffic to applications through Teleport. Unlike the TunnelService
// it is protocol aware and does not support TCP applications.
type ProxyService struct {
connCfg connection.Config
defaultCredentialLifetime bot.CredentialLifetime
cfg *ProxyServiceConfig
proxyPinger connection.ProxyPinger
log *slog.Logger
botClient *apiclient.Client
getBotIdentity func() *identity.Identity
botIdentityReadyCh <-chan struct{}
statusReporter readyz.Reporter
identityGenerator *identity.Generator
clientBuilder *client.Builder
alpnUpgradeCache *internal.ALPNUpgradeCache
cache *utils.FnCache
proxyAddr string
alpnUpgradeRequired bool
}
// Run runs the service until the context is closed or an error occurs.
func (s *ProxyService) Run(ctx context.Context) error {
ctx, span := tracer.Start(ctx, "ProxyService/Run")
defer span.End()
l := s.cfg.Listener
if l == nil {
s.log.DebugContext(
ctx, "Opening listener for application proxy",
"listen", s.cfg.Listen,
)
var err error
l, err = internal.CreateListener(ctx, s.log, s.cfg.Listen)
if err != nil {
return trace.Wrap(err, "opening listener")
}
defer func() {
if err := l.Close(); err != nil && !utils.IsUseOfClosedNetworkError(err) {
s.log.ErrorContext(ctx, "Failed to close listener", "error", err)
}
}()
}
// Initialize the fnCache
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
fnCache, err := utils.NewFnCache(utils.FnCacheConfig{
TTL: effectiveLifetime.RenewalInterval,
})
if err != nil {
return trace.Wrap(err, "initializing cache")
}
s.cache = fnCache
// Ping the Teleport Proxy
proxyPing, err := s.proxyPinger.Ping(ctx)
if err != nil {
return trace.Wrap(err, "pinging upstream proxy")
}
// Retrieve the Proxy Address to use for the upstream request
proxyAddr, err := proxyPing.ProxyWebAddr()
if err != nil {
return trace.Wrap(err, "determining proxy address from ping response")
}
s.proxyAddr = proxyAddr
// Check if ALPN upgrade will be required to pass client certificate to the
// proxy.
s.alpnUpgradeRequired, err = s.alpnUpgradeCache.IsUpgradeRequired(
ctx, proxyAddr, s.connCfg.Insecure,
)
if err != nil {
return trace.Wrap(err, "testing if ALPN connection upgrade is required")
}
httpSrv := http.Server{
ReadTimeout: apidefaults.DefaultIOTimeout,
ReadHeaderTimeout: defaults.ReadHeadersTimeout,
WriteTimeout: apidefaults.DefaultIOTimeout,
IdleTimeout: apidefaults.DefaultIdleTimeout,
BaseContext: func(net.Listener) context.Context {
// Use the main context which controls the service being stopped as
// the base context for all incoming requests.
return ctx
},
}
httpSrv.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
err := s.handleProxyRequest(w, req)
if err != nil {
trace.WriteError(w, err)
s.log.ErrorContext(
req.Context(), "Encountered an error while proxying request",
"error", err,
)
return
}
})
s.log.InfoContext(ctx, "Finished initializing")
var errCh = make(chan error, 1)
go func() {
s.log.DebugContext(ctx, "Starting proxy request handler goroutine")
errCh <- httpSrv.Serve(l)
}()
s.log.InfoContext(
ctx, "Listening for proxy connections",
"address", l.Addr().String(),
)
s.statusReporter.Report(readyz.Healthy)
select {
case <-ctx.Done():
return trace.Wrap(httpSrv.Close(), "closing http server")
case err := <-errCh:
s.statusReporter.ReportReason(readyz.Unhealthy, err.Error())
return trace.Wrap(err, "local proxy failed")
}
}
// String returns a human friendly representation of the service.
func (s *ProxyService) String() string {
return cmp.Or(
s.cfg.Name,
fmt.Sprintf("%s:%s", ProxyServiceType, s.cfg.Listen),
)
}
// issueCert issues a role-impersonated app-routed X509 certificate
func (s *ProxyService) issueCert(
ctx context.Context,
appName string,
) (*tls.Certificate, types.Application, error) {
ctx, span := tracer.Start(ctx, "ProxyService/issueCert")
defer span.End()
// TODO(noah): At a later date, we should consider running a background
// goroutine that maintains a role-impersonated client. We should probably
// make this a generic helper since we have a few services that do this now.
// This will avoid the need to create and destroy a client for each new
// upstream.
// Right now we have to redetermine the route to app each time as the
// session ID may need to change. Once v17 hits, this will be automagically
// calculated by the auth server on cert generation, and we can fetch the
// routeToApp once.
effectiveLifetime := cmp.Or(s.cfg.CredentialLifetime, s.defaultCredentialLifetime)
identityOpts := []identity.GenerateOption{
identity.WithLifetime(effectiveLifetime.TTL, effectiveLifetime.RenewalInterval),
identity.WithLogger(s.log),
}
impersonatedIdentity, err := s.identityGenerator.GenerateFacade(ctx, identityOpts...)
if err != nil {
return nil, nil, trace.Wrap(err)
}
impersonatedClient, err := s.clientBuilder.Build(ctx, impersonatedIdentity)
if err != nil {
return nil, nil, trace.Wrap(err)
}
defer func() {
if err := impersonatedClient.Close(); err != nil {
s.log.ErrorContext(
ctx, "Failed to close impersonated client",
"error", err,
)
}
}()
route, app, err := getRouteToApp(
ctx, s.getBotIdentity(), impersonatedClient, appName,
)
if err != nil {
return nil, nil, trace.Wrap(err)
}
routedIdent, err := s.identityGenerator.Generate(
ctx, append(identityOpts, identity.WithRouteToApp(route))...,
)
if err != nil {
return nil, nil, trace.Wrap(err)
}
return routedIdent.TLSCert, app, nil
}
// handleProxyRequest handles incoming HTTP requests to the server.
//
// This roughly implements HTTP proxying as defined within RFC2616.
// On receiving a HTTP request, we determine the upstream target application
// from the Host header, and issue a certificate for that application. The proxy
// then makes the upstream request to the Teleport Proxy for that application.
//
// It does not support HTTP "tunneling" as defined by RFC2616 via the CONNECT
// method. CONNECT requests are rejected with a 501 Not Implemented response.
//
// We currently do not account for HTTP/2 support because we have an unencrypted
// listener. By default, http.Server will only support HTTP/2 if TLS is enabled.
// At a later date, we could add support for h2c with prior-knowledge, but this
// would introduce significant additional complexity to this proxy - so we shall
// defer until there is a demonstrated need.
func (s *ProxyService) handleProxyRequest(w http.ResponseWriter, req *http.Request) error {
ctx := req.Context()
s.log.DebugContext(
ctx, "Received request to proxy",
"url", req.URL.String(),
"host", req.Host,
"method", req.Method,
"remote_addr", req.RemoteAddr,
)
// Pre-emptively block CONNECT requests as we do not currently support them
// but, proxying them forward as normal requests would make it difficult for
// us to introduce CONNECT support later without breaking compat.
if req.Method == http.MethodConnect {
return trace.NotImplemented("proxy does not support CONNECT method")
}
// net/http implements RFC7230 5.3 correctly, and will prefer the host
// specified within an absolute-form request-target over the Host header
// when setting req.Host - which means we can safely use req.Host here.
appName := req.Host
var appCert *tls.Certificate
var err error
appCert, err = utils.FnCacheGet(
ctx,
s.cache,
appName,
func(ctx context.Context) (*tls.Certificate, error) {
s.log.InfoContext(
ctx, "Issuing app cert",
"app", appName,
)
cert, _, err := s.issueCert(ctx, appName)
return cert, err
})
if err != nil {
return trace.Wrap(err, "fetching certificate")
}
// TODO(noah): We could cache the httpClient itself for each upstream, this
// would potentially allow performance improvements by caching connections.
transport := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{*appCert},
InsecureSkipVerify: s.botClient.Config().InsecureSkipVerify,
},
}
// Inject the ALPN upgrade dialer if required.
if s.alpnUpgradeRequired {
transport.DialContext = apiclient.NewALPNDialer(apiclient.ALPNDialerConfig{
ALPNConnUpgradeRequired: true,
TLSConfig: &tls.Config{
InsecureSkipVerify: s.botClient.Config().InsecureSkipVerify,
NextProtos: []string{string(common.ProtocolHTTP)},
},
}).DialContext
}
httpClient := &http.Client{
Transport: transport,
}
// Build the Application Request
upstreamReq := req.Clone(req.Context())
// For now, we redirect all requests to the proxy web's address. However,
// it would be a potential improvement for us to switch to fetching the
// public address from the App resource. This would reduce the chance of
// breakage if the Proxy is eventually modified to consider SNI. The problem
// with doing this is that the public address does not include a port, which
// we could guess from the Proxy's public web address.
upstreamReq.Host = s.proxyAddr
upstreamReq.URL.Host = s.proxyAddr
upstreamReq.URL.Scheme = "https"
// RequestURI must be empty when making client requests.
upstreamReq.RequestURI = ""
// TODO: Are there any headers we should override, add, or remove on the
// upstream request?
// Execute the upstream request
resp, err := httpClient.Do(upstreamReq)
if err != nil {
return err
}
defer resp.Body.Close()
// Prepare and send the response back to the client
// Copy headers first.
for key, values := range resp.Header {
for _, value := range values {
w.Header().Set(key, value)
}
}
// Send status code and then copy the body.
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
return trace.Wrap(err, "copying response body")
}
return nil
}
@@ -0,0 +1,290 @@
/*
* Teleport
* Copyright (C) 2025 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 application
import (
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/lib/service/servicecfg"
"github.com/gravitational/teleport/lib/tbot/bot"
"github.com/gravitational/teleport/lib/tbot/bot/connection"
"github.com/gravitational/teleport/lib/tbot/internal"
"github.com/gravitational/teleport/lib/utils/log/logtest"
"github.com/gravitational/teleport/tool/teleport/testenv"
)
// Struct to encapsulate the request the HTTP Test Server got.
// It contains the request and the extracted body, since the default golang HTTP Server closes the body io stream.
type proxyReqRes struct {
request *http.Request
body []byte
}
func TestE2E_ApplicationProxyService(t *testing.T) {
t.Parallel()
ctx := t.Context()
log := logtest.NewLogger()
// Spin up a test HTTP server
receivedRequestsCh := make(chan proxyReqRes, 1)
// Spin up 2 servers
httpSrvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture the requestBody
requestBody, err := io.ReadAll(r.Body)
if err != nil {
return
}
// Copy the request and the received body into the channel.
// We do this because the default http server closes the body io stream to clear the memory.
receivedRequestsCh <- proxyReqRes{
request: r,
body: requestBody,
}
w.Header().Set("X-From-Server", "server-a-header-value")
w.WriteHeader(http.StatusTeapot)
w.Write([]byte("hello from server a"))
}))
t.Cleanup(httpSrvA.Close)
httpSrvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture the requestBody
requestBody, err := io.ReadAll(r.Body)
if err != nil {
return
}
// Copy the request and the received body into the channel.
// We do this because the default http server closes the body io stream to clear the memory.
receivedRequestsCh <- proxyReqRes{
request: r,
body: requestBody,
}
w.Header().Set("X-From-Server", "server-b-header-value")
w.WriteHeader(http.StatusTeapot)
w.Write([]byte("hello from server b"))
}))
t.Cleanup(httpSrvB.Close)
// Make a new auth server.
appNameA := "app-a"
appNameB := "app-b"
process, err := testenv.NewTeleportProcess(
t.TempDir(),
defaultTestServerOpts(log),
testenv.WithConfig(func(cfg *servicecfg.Config) {
cfg.Apps.Enabled = true
cfg.Apps.Apps = []servicecfg.App{
{
Name: appNameA,
URI: httpSrvA.URL,
},
{
Name: appNameB,
URI: httpSrvB.URL,
},
}
}),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, process.Close())
require.NoError(t, process.Wait())
})
rootClient, err := testenv.NewDefaultAuthClient(process)
require.NoError(t, err)
t.Cleanup(func() { _ = rootClient.Close() })
// Create role that allows the bot to access the app.
role, err := types.NewRole("app-access", types.RoleSpecV6{
Allow: types.RoleConditions{
AppLabels: types.Labels{
"*": apiutils.Strings{"*"},
},
},
})
require.NoError(t, err)
role, err = rootClient.UpsertRole(t.Context(), role)
require.NoError(t, err)
botListener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() {
botListener.Close()
})
onboarding, _ := makeBot(t, rootClient, "test", role.GetName())
proxyAddr, err := process.ProxyWebAddr()
require.NoError(t, err)
proxyServiceConfig := &ProxyServiceConfig{
Listen: "localhost:12345",
Listener: botListener,
}
connCfg := connection.Config{
Address: proxyAddr.Addr,
AddressKind: connection.AddressKindProxy,
Insecure: true,
}
alpnUpgradeCache := internal.NewALPNUpgradeCache(log)
b, err := bot.New(bot.Config{
Connection: connCfg,
Logger: log,
Onboarding: *onboarding,
Services: []bot.ServiceBuilder{
ProxyServiceBuilder(
proxyServiceConfig,
connCfg,
bot.DefaultCredentialLifetime,
alpnUpgradeCache,
),
},
})
require.NoError(t, err)
// Spin up goroutine for bot to run in
ctx, cancel := context.WithCancel(ctx)
wg := sync.WaitGroup{}
wg.Go(func() {
err := b.Run(ctx)
assert.NoError(t, err, "bot should not exit with error")
cancel()
})
t.Cleanup(func() {
// Shut down bot and make sure it exits.
cancel()
wg.Wait()
})
httpClient := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(&url.URL{
Scheme: "http",
Host: botListener.Addr().String(),
}),
},
}
// We can't predict exactly when the tunnel will be ready so we use
// EventuallyWithT to retry.
require.EventuallyWithT(t, func(t *assert.CollectT) {
requestUrl, _ := url.Parse("http://" + appNameA)
request := &http.Request{
URL: requestUrl,
}
resp, err := httpClient.Do(request)
if !assert.NoError(t, err) {
return
}
defer resp.Body.Close()
// Drain channel since we don't care to much about this first request.
<-receivedRequestsCh
}, 10*time.Second, 1*time.Second)
// Request A
outgoingReqA := &http.Request{
Method: http.MethodPost,
Body: io.NopCloser(bytes.NewReader([]byte("hello from client"))),
Header: http.Header{
"X-From-Client": []string{"client-header-value"},
},
URL: &url.URL{
Scheme: "http",
Host: appNameA,
Path: "/some/path",
RawQuery: "queryParam=value-a",
},
}
outgoingReqA = outgoingReqA.WithContext(ctx)
resp, err := httpClient.Do(outgoingReqA)
require.NoError(t, err)
defer resp.Body.Close()
// Assert server received the request we sent
proxyRequestResponseA := <-receivedRequestsCh
serverGotReqA := proxyRequestResponseA.request
require.NotNil(t, serverGotReqA)
require.Equal(t, http.MethodPost, serverGotReqA.Method)
require.Equal(t, "client-header-value", serverGotReqA.Header.Get("X-From-Client"))
require.Equal(t, outgoingReqA.URL.Path, serverGotReqA.URL.Path)
require.Equal(t, "hello from client", string(proxyRequestResponseA.body))
require.Equal(t, "value-a", serverGotReqA.URL.Query().Get("queryParam"))
// Assert client receives the response the server sent
require.Equal(t, http.StatusTeapot, resp.StatusCode)
require.Equal(t, "server-a-header-value", resp.Header.Get("X-From-Server"))
respBody, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "hello from server a", string(respBody))
// Request B
outgoingReqB := &http.Request{
Method: http.MethodPost,
Body: io.NopCloser(bytes.NewReader([]byte("hello from client"))),
Header: http.Header{
"X-From-Client": []string{"client-header-value"},
},
URL: &url.URL{
Scheme: "http",
Host: appNameB,
Path: "/some/path",
RawQuery: "queryParam=value-b",
},
}
outgoingReqB = outgoingReqB.WithContext(ctx)
resp, err = httpClient.Do(outgoingReqB)
require.NoError(t, err)
defer resp.Body.Close()
// Assert server received the request we sent
proxyRequestResponseB := <-receivedRequestsCh
serverGotReqB := proxyRequestResponseB.request
require.NotNil(t, serverGotReqB)
require.Equal(t, http.MethodPost, serverGotReqB.Method)
require.Equal(t, "client-header-value", serverGotReqB.Header.Get("X-From-Client"))
require.Equal(t, outgoingReqB.URL.Path, serverGotReqB.URL.Path)
require.Equal(t, "hello from client", string(proxyRequestResponseB.body))
require.Equal(t, "value-b", serverGotReqB.URL.Query().Get("queryParam"))
// Assert client receives the response the server sent
require.Equal(t, http.StatusTeapot, resp.StatusCode)
require.Equal(t, "server-b-header-value", resp.Header.Get("X-From-Server"))
respBody, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "hello from server b", string(respBody))
}
@@ -0,0 +1,6 @@
type: application-proxy
name: foo
listen: tcp://0.0.0.0:3621
credential_lifetime:
credential_ttl: 1m0s
renewal_interval: 30s
+2
View File
@@ -243,6 +243,8 @@ func (b *Bot) Run(ctx context.Context) (err error) {
services = append(services, clientcredentials.ServiceBuilder(svcCfg, b.cfg.CredentialLifetime))
case *application.TunnelConfig:
services = append(services, application.TunnelServiceBuilder(svcCfg, b.cfg.ConnectionConfig(), b.cfg.CredentialLifetime))
case *application.ProxyServiceConfig:
services = append(services, application.ProxyServiceBuilder(svcCfg, b.cfg.ConnectionConfig(), b.cfg.CredentialLifetime, alpnUpgradeCache))
case *workloadidentitysvc.X509OutputConfig:
services = append(services, workloadidentitysvc.X509OutputServiceBuilder(svcCfg, setupTrustBundleCache(), setupCRLCache(), b.cfg.CredentialLifetime))
case *workloadidentitysvc.JWTOutputConfig:
+3
View File
@@ -163,6 +163,9 @@ func Run(args []string, stdout io.Writer) error {
cli.NewWorkloadIdentityAWSRACommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart),
cli.NewWorkloadIdentityAWSRACommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure),
cli.NewApplicationProxyCommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart),
cli.NewApplicationProxyCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure),
)
// Initialize legacy-style commands. These are simple enough to not really