mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
co-authored by
Dan Upton
Thijs Cramer
parent
7d09a9f6aa
commit
3294453ac4
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
type: application-proxy
|
||||
name: foo
|
||||
listen: tcp://0.0.0.0:3621
|
||||
credential_lifetime:
|
||||
credential_ttl: 1m0s
|
||||
renewal_interval: 30s
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user