mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 01:58:44 +08:00
Merge branch 'master' of github.com:gravitational/teleport into STeve/vnet_process_app_https_tunnel (#66451)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -383,44 +383,7 @@ func (v *vnetApplicationService) ResolveFQDN(ctx context.Context, fqdn string) (
|
||||
if !ok {
|
||||
return nil, trace.BadParameter("expected *types.AppV3, got %T", rsp.Resources[0].GetApp())
|
||||
}
|
||||
if !vnet.IsVNetApp(app) {
|
||||
v.logger.DebugContext(ctx, "Application protocol not supported by VNet",
|
||||
"fqdn", fqdn,
|
||||
"app_name", app.GetName(),
|
||||
"app_uri", app.GetURI(),
|
||||
"app_protocol", app.GetProtocol(),
|
||||
)
|
||||
return &vnetv1.ResolveFQDNResponse{}, nil
|
||||
}
|
||||
|
||||
// VNet intentionally doesn't support HTTP apps for a number of reasons.
|
||||
//
|
||||
// One such reason is the security risk of untrusted code (e.g. JavaScript
|
||||
// in a web browser) being able to access arbitrary local services. Browsers
|
||||
// help to some extent here via the same-origin policy, but cannot reliably
|
||||
// prevent DNS rebinding attacks for plain HTTP apps.
|
||||
//
|
||||
// While the underlying issue remains in the beam sandbox, the risk is more
|
||||
// acceptable because (1) you can restrict the beam's access to a subset of
|
||||
// your application via Delegation Sessions, and (2) allowing untrusted code
|
||||
// and agents to access your Teleport-protected resources is the entire point
|
||||
// of Beams! by using them you're already accepting a larger security trade-
|
||||
// off than the browser sandbox normally would.
|
||||
//
|
||||
// We make it work by pretending they're actually plain TCP apps:
|
||||
//
|
||||
// - The local ALPN proxy will advertise support for the "teleport-tcp"
|
||||
// protocol in the TLS handshake.
|
||||
//
|
||||
// - On the Teleport proxy-side, this protocol is routed to the web server's
|
||||
// HandleConnection method.
|
||||
//
|
||||
// - From there, the connection is handed off to the app handler, which
|
||||
// determines the protocol from the application *resource* not the ALPN
|
||||
// protocol.
|
||||
//
|
||||
// TODO(boxofrad): Replace this with HTTPS-in-mTLS once RFD 0035e is approved
|
||||
// and implemented.
|
||||
ca, err := v.clusterAccess(osConfig)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
@@ -436,13 +399,35 @@ func (v *vnetApplicationService) ResolveFQDN(ctx context.Context, fqdn string) (
|
||||
DialOptions: ca.dialOptions,
|
||||
}
|
||||
|
||||
return &vnetv1.ResolveFQDNResponse{
|
||||
Match: &vnetv1.ResolveFQDNResponse_MatchedTcpApp{
|
||||
MatchedTcpApp: &vnetv1.MatchedTCPApp{
|
||||
AppInfo: appInfo,
|
||||
switch {
|
||||
case app.IsTCP():
|
||||
return &vnetv1.ResolveFQDNResponse{
|
||||
Match: &vnetv1.ResolveFQDNResponse_MatchedTcpApp{
|
||||
MatchedTcpApp: &vnetv1.MatchedTCPApp{
|
||||
AppInfo: appInfo,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}, nil
|
||||
case vnet.IsHTTPSTunnelApp(app):
|
||||
// HTTP and LLM apps are tunneled via the HTTPS-in-mTLS ALPN protocol.
|
||||
// Browser access via this tunnel is currently disabled on the web app
|
||||
// handler, which should be fine for common use cases inside beams.
|
||||
return &vnetv1.ResolveFQDNResponse{
|
||||
Match: &vnetv1.ResolveFQDNResponse_MatchedHttpsTunnelApp{
|
||||
MatchedHttpsTunnelApp: &vnetv1.MatchedHTTPSTunnelApp{
|
||||
AppInfo: appInfo,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
v.logger.DebugContext(ctx, "Application protocol not supported by VNet",
|
||||
"fqdn", fqdn,
|
||||
"app_name", app.GetName(),
|
||||
"app_uri", app.GetURI(),
|
||||
"app_protocol", app.GetProtocol(),
|
||||
)
|
||||
return &vnetv1.ResolveFQDNResponse{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetAppCert issues a TLS certificate for the given application.
|
||||
|
||||
@@ -246,9 +246,11 @@ func TestVNetService(t *testing.T) {
|
||||
t.Fatal("timeout waiting for host network to be configured")
|
||||
}
|
||||
|
||||
// Call the HTTP app over VNet.
|
||||
client := &http.Client{Transport: hostNetwork.HTTPTransport()}
|
||||
rsp, err := client.Get("http://intranet.dunder-mifflin.com")
|
||||
// Call the HTTP app over VNet via the HTTPS tunnel.
|
||||
transport := hostNetwork.HTTPTransport()
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
client := &http.Client{Transport: transport}
|
||||
rsp, err := client.Get("https://intranet.dunder-mifflin.com")
|
||||
require.NoError(t, err)
|
||||
defer rsp.Body.Close()
|
||||
|
||||
|
||||
+17
-17
@@ -37,8 +37,8 @@ import (
|
||||
alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common"
|
||||
)
|
||||
|
||||
type tcpAppHandler struct {
|
||||
cfg *tcpAppHandlerConfig
|
||||
type appHandler struct {
|
||||
cfg *appHandlerConfig
|
||||
log *slog.Logger
|
||||
|
||||
// mu guards access to portToLocalProxy.
|
||||
@@ -46,8 +46,10 @@ type tcpAppHandler struct {
|
||||
portToLocalProxy map[uint16]*alpnproxy.LocalProxy
|
||||
}
|
||||
|
||||
type tcpAppHandlerConfig struct {
|
||||
appInfo *vnetv1.AppInfo
|
||||
type appHandlerConfig struct {
|
||||
appInfo *vnetv1.AppInfo
|
||||
protocol alpncommon.Protocol
|
||||
|
||||
appProvider *appProvider
|
||||
clock clockwork.Clock
|
||||
// alwaysTrustRootClusterCA can be set in tests so that TLS dials to the
|
||||
@@ -56,11 +58,11 @@ type tcpAppHandlerConfig struct {
|
||||
alwaysTrustRootClusterCA bool
|
||||
}
|
||||
|
||||
func newTCPAppHandler(cfg *tcpAppHandlerConfig) *tcpAppHandler {
|
||||
return &tcpAppHandler{
|
||||
func newAppHandler(cfg *appHandlerConfig) *appHandler {
|
||||
return &appHandler{
|
||||
cfg: cfg,
|
||||
log: log.With(
|
||||
teleport.ComponentKey, teleport.Component("vnet", "tcp-app-handler"),
|
||||
teleport.ComponentKey, teleport.Component("vnet", "app-handler"),
|
||||
"profile", cfg.appInfo.GetAppKey().GetProfile(),
|
||||
"leaf_cluster", cfg.appInfo.GetAppKey().GetLeafCluster(),
|
||||
"fqdn", cfg.appInfo.GetApp().GetPublicAddr()),
|
||||
@@ -70,7 +72,7 @@ func newTCPAppHandler(cfg *tcpAppHandlerConfig) *tcpAppHandler {
|
||||
|
||||
// getOrInitializeLocalProxy returns a separate local proxy for each port for multi-port apps. For
|
||||
// single-port apps, it returns the same local proxy no matter the port.
|
||||
func (h *tcpAppHandler) getOrInitializeLocalProxy(ctx context.Context, localPort uint16) (*alpnproxy.LocalProxy, error) {
|
||||
func (h *appHandler) getOrInitializeLocalProxy(ctx context.Context, localPort uint16) (*alpnproxy.LocalProxy, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
// Connections to single-port apps need to go through a local proxy that has a cert with TargetPort
|
||||
@@ -97,10 +99,10 @@ func (h *tcpAppHandler) getOrInitializeLocalProxy(ctx context.Context, localPort
|
||||
return h.cfg.appProvider.OnNewAppConnection(ctx, h.cfg.appInfo.GetAppKey())
|
||||
},
|
||||
}
|
||||
h.log.DebugContext(ctx, "Creating local proxy", "target_port", localPort)
|
||||
h.log.DebugContext(ctx, "Creating local proxy", "target_port", localPort, "protocol", h.cfg.protocol)
|
||||
newLP, err := newLocalProxy(localProxyConfig{
|
||||
dialOptions: h.cfg.appInfo.GetDialOptions(),
|
||||
protocols: []alpncommon.Protocol{alpncommon.ProtocolTCP},
|
||||
protocols: []alpncommon.Protocol{h.cfg.protocol},
|
||||
parentContext: ctx,
|
||||
middleware: middleware,
|
||||
clock: h.cfg.clock,
|
||||
@@ -115,7 +117,7 @@ func (h *tcpAppHandler) getOrInitializeLocalProxy(ctx context.Context, localPort
|
||||
|
||||
// handleTCPConnector handles an incoming TCP connection from VNet by passing it to the local alpn proxy,
|
||||
// which is set up with middleware to automatically handle certificate renewal and re-logins.
|
||||
func (h *tcpAppHandler) handleTCPConnector(ctx context.Context, localPort uint16, connector func() (net.Conn, error)) error {
|
||||
func (h *appHandler) handleTCPConnector(ctx context.Context, localPort uint16, connector func() (net.Conn, error)) error {
|
||||
app := h.cfg.appInfo.GetApp()
|
||||
if len(app.GetTCPPorts()) > 0 {
|
||||
if !app.GetTCPPorts().Contains(int(localPort)) {
|
||||
@@ -151,12 +153,10 @@ func (i *appCertIssuer) IssueCert(ctx context.Context) (tls.Certificate, error)
|
||||
return cert.(tls.Certificate), trace.Wrap(err)
|
||||
}
|
||||
|
||||
// IsVNetApp returns true if the app type is supported by VNet.
|
||||
func IsVNetApp(app types.Application) bool {
|
||||
return app.IsTCP() ||
|
||||
app.GetProtocol() == "HTTP" ||
|
||||
app.IsLLM() ||
|
||||
types.GetMCPServerTransportType(app.GetURI()) == types.MCPTransportHTTP
|
||||
// IsHTTPSTunnelApp returns true if the app should be proxied through the
|
||||
// HTTPS-in-mTLS tunnel. Currently this includes HTTP and LLM apps.
|
||||
func IsHTTPSTunnelApp(app types.Application) bool {
|
||||
return app.IsLLM() || app.GetProtocol() == types.ApplicationProtocolHTTP
|
||||
}
|
||||
|
||||
// RouteToApp returns a *proto.RouteToApp populated from appInfo and targetPort.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 vnet
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gravitational/teleport/api/types"
|
||||
)
|
||||
|
||||
func TestIsHTTPSTunnelApp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
expect require.BoolAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "TCP app",
|
||||
uri: "tcp://localhost:5432",
|
||||
expect: require.False,
|
||||
},
|
||||
{
|
||||
name: "HTTP app",
|
||||
uri: "http://localhost:8080",
|
||||
expect: require.True,
|
||||
},
|
||||
{
|
||||
name: "HTTPS app",
|
||||
uri: "https://localhost:8443",
|
||||
expect: require.True,
|
||||
},
|
||||
{
|
||||
name: "LLM app",
|
||||
uri: "llm://",
|
||||
expect: require.True,
|
||||
},
|
||||
{
|
||||
name: "MCP app",
|
||||
uri: "mcp+http://localhost:8080",
|
||||
expect: require.False,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app := &types.AppV3{Spec: types.AppSpecV3{URI: tc.uri}}
|
||||
tc.expect(t, IsHTTPSTunnelApp(app))
|
||||
})
|
||||
}
|
||||
}
|
||||
+43
-15
@@ -47,6 +47,7 @@ type fqdnResolverConfig struct {
|
||||
leafClusterCache *leafClusterCache
|
||||
// allowDatabaseAccess gates VNet database FQDN resolution for tsh/Connect.
|
||||
allowDatabaseAccess bool
|
||||
allowAppHTTPSTunnel bool
|
||||
}
|
||||
|
||||
func newFQDNResolver(cfg *fqdnResolverConfig) *fqdnResolver {
|
||||
@@ -305,13 +306,13 @@ func (r *fqdnResolver) resolveAppInfoForCluster(
|
||||
"name", app.GetName(), "public_addr", app.GetPublicAddr())
|
||||
continue
|
||||
}
|
||||
if app.IsTCP() {
|
||||
if app.IsTCP() || r.shouldUseAppHTTPSTunnel(app) {
|
||||
if matchedByPublicAddr {
|
||||
// Greedily prefer to match an arbitrary TCP app by public addr.
|
||||
// Greedily prefer to match a VNet-handled app by public addr.
|
||||
return app, nil
|
||||
}
|
||||
// Skip TCP apps that only matched by name, VNet only handles
|
||||
// TCP apps that match a public addr.
|
||||
// Skip apps that only matched by name, VNet only handles
|
||||
// apps that match a public addr.
|
||||
} else {
|
||||
matchedWebApp = app
|
||||
}
|
||||
@@ -331,15 +332,46 @@ func (r *fqdnResolver) resolveAppInfoForCluster(
|
||||
// At this point we have found a matching app in the cluster, any error is
|
||||
// unexpected and is preventing access to the app and should be returned to
|
||||
// the user.
|
||||
if !app.IsTCP() {
|
||||
switch {
|
||||
case app.IsTCP():
|
||||
log.InfoContext(ctx, "Query matched a TCP app")
|
||||
appInfo, err := r.makeAppInfo(ctx, candidate, app)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return &vnetv1.ResolveFQDNResponse{
|
||||
Match: &vnetv1.ResolveFQDNResponse_MatchedTcpApp{
|
||||
MatchedTcpApp: &vnetv1.MatchedTCPApp{
|
||||
AppInfo: appInfo,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
|
||||
case r.shouldUseAppHTTPSTunnel(app):
|
||||
log.InfoContext(ctx, "Query matched an HTTPS tunnel app", "protocol", app.GetProtocol())
|
||||
appInfo, err := r.makeAppInfo(ctx, candidate, app)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
return &vnetv1.ResolveFQDNResponse{
|
||||
Match: &vnetv1.ResolveFQDNResponse_MatchedHttpsTunnelApp{
|
||||
MatchedHttpsTunnelApp: &vnetv1.MatchedHTTPSTunnelApp{
|
||||
AppInfo: appInfo,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
|
||||
default:
|
||||
log.InfoContext(ctx, "Query matched a web app")
|
||||
// If not a TCP app this must be a web app and we can return early.
|
||||
return &vnetv1.ResolveFQDNResponse{
|
||||
Match: &vnetv1.ResolveFQDNResponse_MatchedWebApp{
|
||||
MatchedWebApp: &vnetv1.MatchedWebApp{},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fqdnResolver) makeAppInfo(ctx context.Context, candidate clusterResolutionCandidate, app *types.AppV3) (*vnetv1.AppInfo, error) {
|
||||
clusterConfig, err := r.cfg.clusterConfigCache.GetClusterConfig(ctx, candidate.client)
|
||||
if err != nil {
|
||||
log.ErrorContext(ctx, "Failed to get cluster VNet config for matching app", "error", err)
|
||||
@@ -350,8 +382,7 @@ func (r *fqdnResolver) resolveAppInfoForCluster(
|
||||
log.ErrorContext(ctx, "Failed to get cluster dial options", "error", err)
|
||||
return nil, trace.Wrap(err, "getting dial options for matching app")
|
||||
}
|
||||
log.InfoContext(ctx, "Query matched a TCP app")
|
||||
appInfo := &vnetv1.AppInfo{
|
||||
return &vnetv1.AppInfo{
|
||||
AppKey: &vnetv1.AppKey{
|
||||
Profile: candidate.profileName,
|
||||
LeafCluster: candidate.leafClusterName,
|
||||
@@ -361,13 +392,6 @@ func (r *fqdnResolver) resolveAppInfoForCluster(
|
||||
App: app,
|
||||
Ipv4CidrRange: clusterConfig.IPv4CIDRRange,
|
||||
DialOptions: dialOpts,
|
||||
}
|
||||
return &vnetv1.ResolveFQDNResponse{
|
||||
Match: &vnetv1.ResolveFQDNResponse_MatchedTcpApp{
|
||||
MatchedTcpApp: &vnetv1.MatchedTCPApp{
|
||||
AppInfo: appInfo,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -496,3 +520,7 @@ func isDirectSubdomain(fqdn, zone string) bool {
|
||||
}
|
||||
return !strings.ContainsRune(trimmed, '.')
|
||||
}
|
||||
|
||||
func (r *fqdnResolver) shouldUseAppHTTPSTunnel(app types.Application) bool {
|
||||
return r.cfg.allowAppHTTPSTunnel && IsHTTPSTunnelApp(app)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/jonboulle/clockwork"
|
||||
|
||||
"github.com/gravitational/teleport/api/defaults"
|
||||
alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common"
|
||||
"github.com/gravitational/teleport/lib/utils"
|
||||
)
|
||||
|
||||
@@ -71,7 +72,21 @@ func (r *tcpHandlerResolver) resolveTCPHandler(ctx context.Context, fqdn string)
|
||||
appInfo := matchedTCPApp.GetAppInfo()
|
||||
return &tcpHandlerSpec{
|
||||
ipv4CIDRRange: appInfo.GetIpv4CidrRange(),
|
||||
tcpHandler: newTCPAppHandler(&tcpAppHandlerConfig{
|
||||
tcpHandler: newAppHandler(&appHandlerConfig{
|
||||
protocol: alpncommon.ProtocolTCP,
|
||||
appInfo: appInfo,
|
||||
appProvider: r.cfg.appProvider,
|
||||
clock: r.cfg.clock,
|
||||
alwaysTrustRootClusterCA: r.cfg.alwaysTrustRootClusterCA,
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
if matchedHTTPSTunnelApp := resp.GetMatchedHttpsTunnelApp(); matchedHTTPSTunnelApp != nil {
|
||||
appInfo := matchedHTTPSTunnelApp.GetAppInfo()
|
||||
return &tcpHandlerSpec{
|
||||
ipv4CIDRRange: appInfo.GetIpv4CidrRange(),
|
||||
tcpHandler: newAppHandler(&appHandlerConfig{
|
||||
protocol: alpncommon.ProtocolAppHTTPS,
|
||||
appInfo: appInfo,
|
||||
appProvider: r.cfg.appProvider,
|
||||
clock: r.cfg.clock,
|
||||
@@ -222,10 +237,11 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin
|
||||
}
|
||||
log := log.With("fqdn", h.cfg.fqdn, "local_port", localPort)
|
||||
if matchedTCPApp := resp.GetMatchedTcpApp(); matchedTCPApp != nil {
|
||||
// If matched a TCP app, build a tcpAppHandler that will be used for this
|
||||
// If matched a TCP app, build an appHandler that will be used for this
|
||||
// and all subsequent connections to this address.
|
||||
log.DebugContext(ctx, "Resolved FQDN to a matched TCP app")
|
||||
tcpAppHandler := newTCPAppHandler(&tcpAppHandlerConfig{
|
||||
tcpAppHandler := newAppHandler(&appHandlerConfig{
|
||||
protocol: alpncommon.ProtocolTCP,
|
||||
appInfo: matchedTCPApp.GetAppInfo(),
|
||||
appProvider: h.cfg.appProvider,
|
||||
clock: h.cfg.clock,
|
||||
@@ -234,6 +250,18 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin
|
||||
h.setDecidedHandler(tcpAppHandler)
|
||||
return tcpAppHandler.handleTCPConnector(ctx, localPort, connector)
|
||||
}
|
||||
if matchedHTTPSTunnelApp := resp.GetMatchedHttpsTunnelApp(); matchedHTTPSTunnelApp != nil {
|
||||
log.DebugContext(ctx, "Resolved FQDN to a matched HTTPS tunnel app")
|
||||
handler := newAppHandler(&appHandlerConfig{
|
||||
protocol: alpncommon.ProtocolAppHTTPS,
|
||||
appInfo: matchedHTTPSTunnelApp.GetAppInfo(),
|
||||
appProvider: h.cfg.appProvider,
|
||||
clock: h.cfg.clock,
|
||||
alwaysTrustRootClusterCA: h.cfg.alwaysTrustRootClusterCA,
|
||||
})
|
||||
h.setDecidedHandler(handler)
|
||||
return handler.handleTCPConnector(ctx, localPort, connector)
|
||||
}
|
||||
if matchedDB := resp.GetMatchedDatabase(); matchedDB != nil {
|
||||
// If matched a database, build a dbHandler that will be used for this
|
||||
// and all subsequent connections to this address.
|
||||
|
||||
@@ -19,10 +19,12 @@ package vnet
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"os"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/jonboulle/clockwork"
|
||||
|
||||
apiutils "github.com/gravitational/teleport/api/utils"
|
||||
vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1"
|
||||
"github.com/gravitational/teleport/lib/auth/authclient"
|
||||
"github.com/gravitational/teleport/lib/client"
|
||||
@@ -108,6 +110,14 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (*
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// TODO(greedy52) VNet config may have a flag like `allow_app_https_tunnel`
|
||||
// to opt-in this feature once browser support is added for app HTTPS
|
||||
// tunnel. Using an unstable env var for testing purpose for now.
|
||||
allowAppHTTPSTunnel, _ := apiutils.ParseBool(os.Getenv("TELEPORT_UNSTABLE_VNET_APP_HTTPS_TUNNEL"))
|
||||
if allowAppHTTPSTunnel {
|
||||
log.InfoContext(ctx, "App HTTPS tunnel is enabled")
|
||||
}
|
||||
fqdnResolver := newFQDNResolver(&fqdnResolverConfig{
|
||||
clientApplication: clientApplication,
|
||||
clusterConfigCache: clusterConfigCache,
|
||||
@@ -116,6 +126,7 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (*
|
||||
// disabled for tsh/Connect by default. flip to true to enable DB access via VNet
|
||||
// for tsh/Connect to validate locally.
|
||||
allowDatabaseAccess: false,
|
||||
allowAppHTTPSTunnel: allowAppHTTPSTunnel,
|
||||
})
|
||||
unifiedClusterConfigProvider := NewUnifiedClusterConfigProvider(&UnifiedClusterConfigProviderConfig{
|
||||
clientApplication: clientApplication,
|
||||
|
||||
+131
-26
@@ -83,9 +83,10 @@ type testPack struct {
|
||||
}
|
||||
|
||||
type testPackConfig struct {
|
||||
clock clockwork.Clock
|
||||
fakeClientApp *fakeClientApp
|
||||
homePath string
|
||||
clock clockwork.Clock
|
||||
fakeClientApp *fakeClientApp
|
||||
homePath string
|
||||
allowAppHTTPSTunnel bool
|
||||
}
|
||||
|
||||
func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPack {
|
||||
@@ -166,6 +167,14 @@ func (p *testPack) lookupHost(ctx context.Context, host string) ([]string, error
|
||||
return p.hostNetwork.DNSResolver().LookupHost(ctx, host)
|
||||
}
|
||||
|
||||
func (p *testPack) lookupHostShouldFail(t *testing.T, host string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := p.lookupHost(ctx, host)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func (p *testPack) dialHost(ctx context.Context, host string, port int) (net.Conn, error) {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
if net.ParseIP(host) != nil {
|
||||
@@ -200,6 +209,7 @@ func runTestClientApplicationService(t *testing.T, ctx context.Context, cfg test
|
||||
clusterConfigCache: clusterConfigCache,
|
||||
leafClusterCache: leafClusterCache,
|
||||
allowDatabaseAccess: true,
|
||||
allowAppHTTPSTunnel: cfg.allowAppHTTPSTunnel,
|
||||
})
|
||||
clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{
|
||||
clientApplication: cfg.fakeClientApp,
|
||||
@@ -258,6 +268,7 @@ type appSpec struct {
|
||||
name string
|
||||
publicAddr string
|
||||
isWebApp bool
|
||||
isLLMApp bool
|
||||
tcpPorts []*types.PortRange
|
||||
}
|
||||
|
||||
@@ -266,10 +277,14 @@ func (s *appSpec) getName() string {
|
||||
}
|
||||
|
||||
func (s *appSpec) getURI() string {
|
||||
if s.isWebApp {
|
||||
switch {
|
||||
case s.isLLMApp:
|
||||
return types.SchemeLLMEndpoint + "://"
|
||||
case s.isWebApp:
|
||||
return "http://" + s.publicAddr
|
||||
default:
|
||||
return "tcp://" + s.publicAddr
|
||||
}
|
||||
return "tcp://" + s.publicAddr
|
||||
}
|
||||
|
||||
type dbSpec struct {
|
||||
@@ -1028,14 +1043,6 @@ func TestDialFakeApp(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
lookupShouldFailFast := func(t *testing.T, host string) {
|
||||
t.Helper()
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := p.lookupHost(lookupCtx, host)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
t.Run("invalid FQDN", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
invalidTestCases := []string{
|
||||
@@ -1045,7 +1052,7 @@ func TestDialFakeApp(t *testing.T) {
|
||||
for _, fqdn := range invalidTestCases {
|
||||
t.Run(fqdn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lookupShouldFailFast(t, fqdn)
|
||||
p.lookupHostShouldFail(t, fqdn)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1083,9 +1090,107 @@ func TestDialFakeApp(t *testing.T) {
|
||||
|
||||
// For the test we've configured VNet with no upstream
|
||||
// nameservers, so we expect the DNS lookup to fail.
|
||||
// net.Resolver.LookupHost takes a while to fail unless we
|
||||
// provide a short context.
|
||||
lookupShouldFailFast(t, addr)
|
||||
p.lookupHostShouldFail(t, addr)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDialHTTPSTunnelApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
clock := clockwork.NewFakeClockAt(time.Now())
|
||||
|
||||
clusterSpec := map[string]testClusterSpec{
|
||||
"root.example.com": {
|
||||
apps: []appSpec{
|
||||
{publicAddr: "tcp-app.root.example.com"},
|
||||
{publicAddr: "http-app.root.example.com", isWebApp: true},
|
||||
{publicAddr: "llm-app.root.example.com", isLLMApp: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("enabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
clientApp := newFakeClientApp(ctx, t, &fakeClientAppConfig{
|
||||
clusters: clusterSpec,
|
||||
clock: clock,
|
||||
signatureAlgorithmSuite: types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1,
|
||||
})
|
||||
p := newTestPack(t, ctx, testPackConfig{
|
||||
fakeClientApp: clientApp,
|
||||
clock: clock,
|
||||
allowAppHTTPSTunnel: true,
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
app string
|
||||
}{
|
||||
{
|
||||
name: "TCP app",
|
||||
app: "tcp-app.root.example.com",
|
||||
},
|
||||
{
|
||||
name: "HTTP app",
|
||||
app: "http-app.root.example.com",
|
||||
},
|
||||
{
|
||||
name: "LLM app",
|
||||
app: "llm-app.root.example.com",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
conn, err := p.dialHost(ctx, tc.app, 443)
|
||||
require.NoError(t, err)
|
||||
testEchoConnection(t, conn)
|
||||
require.NoError(t, conn.Close())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
clientApp := newFakeClientApp(ctx, t, &fakeClientAppConfig{
|
||||
clusters: clusterSpec,
|
||||
clock: clock,
|
||||
signatureAlgorithmSuite: types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1,
|
||||
})
|
||||
p := newTestPack(t, ctx, testPackConfig{
|
||||
fakeClientApp: clientApp,
|
||||
clock: clock,
|
||||
allowAppHTTPSTunnel: false,
|
||||
})
|
||||
|
||||
t.Run("TCP app still works", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
conn, err := p.dialHost(ctx, "tcp-app.root.example.com", 443)
|
||||
require.NoError(t, err)
|
||||
testEchoConnection(t, conn)
|
||||
require.NoError(t, conn.Close())
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
app string
|
||||
}{
|
||||
{
|
||||
name: "HTTP app not resolved",
|
||||
app: "http-app.root.example.com",
|
||||
},
|
||||
{
|
||||
name: "LLM app not resolved",
|
||||
app: "llm-app.root.example.com",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p.lookupHostShouldFail(t, tc.app)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1817,14 +1922,6 @@ func TestPriority(t *testing.T) {
|
||||
webProxyPort, err := strconv.Atoi(webProxyPortString)
|
||||
require.NoError(t, err)
|
||||
|
||||
lookupShouldFailFast := func(t *testing.T, host string) {
|
||||
t.Helper()
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := p.lookupHost(lookupCtx, host)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
knownHosts, err := os.ReadFile(keypaths.VNetKnownHostsPath(homePath))
|
||||
require.NoError(t, err)
|
||||
marker, hosts, hostCAPubKey, _, _, err := ssh.ParseKnownHosts(knownHosts)
|
||||
@@ -1859,7 +1956,7 @@ func TestPriority(t *testing.T) {
|
||||
t.Run("web app beats SSH cluster match", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lookupShouldFailFast(t, "webwins.leaf.example.com")
|
||||
p.lookupHostShouldFail(t, "webwins.leaf.example.com")
|
||||
assert.Empty(t, clientApp.RequestedRouteToApps("webwins.leaf.example.com"))
|
||||
})
|
||||
|
||||
@@ -2106,9 +2203,16 @@ func mustStartFakeWebProxy(
|
||||
return trace.Wrap(runTestSSHServerInstance(conn, serverConfig))
|
||||
}
|
||||
|
||||
httpsTunnelAppHandler := func(conn net.Conn) error {
|
||||
// HTTPS tunnel apps use the same echo handler as TCP apps for testing.
|
||||
_, err := io.Copy(conn, conn)
|
||||
return trace.Wrap(err, "io.Copy error in HTTPS tunnel echo server")
|
||||
}
|
||||
|
||||
// Run a simplified TLS router for the test.
|
||||
protocolHandlers := map[alpncommon.Protocol]func(net.Conn) error{
|
||||
alpncommon.ProtocolTCP: tcpAppHandler,
|
||||
alpncommon.ProtocolAppHTTPS: httpsTunnelAppHandler,
|
||||
alpncommon.ProtocolProxySSH: sshHandler,
|
||||
}
|
||||
for _, dbProto := range alpncommon.DatabaseProtocols {
|
||||
@@ -2193,6 +2297,7 @@ func fakeWebProxyALPNProtocols() []string {
|
||||
protos := []string{
|
||||
string(alpncommon.ProtocolProxySSH),
|
||||
string(alpncommon.ProtocolTCP),
|
||||
string(alpncommon.ProtocolAppHTTPS),
|
||||
}
|
||||
for _, dbProto := range alpncommon.DatabaseProtocols {
|
||||
protos = append(protos, string(dbProto))
|
||||
|
||||
@@ -127,7 +127,11 @@ message ResolveFQDNResponse {
|
||||
oneof match {
|
||||
// MatchedTcpApp will be set when the query matched a TCP app.
|
||||
MatchedTCPApp matched_tcp_app = 1;
|
||||
// MatchedWebApp will be set when the query matched a web app.
|
||||
// MatchedWebApp will be set when the query matched a web app and when app
|
||||
// HTTPS tunnel is not used. MatchedWebApp signifies that the query matched
|
||||
// a web app that VNet should not handle. DNS will be forwarded upstream so
|
||||
// the browser resolves to the proxy's real address and goes through the
|
||||
// normal web app login flow.
|
||||
MatchedWebApp matched_web_app = 2;
|
||||
// MatchedCluster will be set when the query did not match any app, but did
|
||||
// match a subdomain of a proxy address. VNet will resolve the DNS query to
|
||||
@@ -135,6 +139,9 @@ message ResolveFQDNResponse {
|
||||
MatchedCluster matched_cluster = 3;
|
||||
// MatchedDatabase will be set when the query matched a database resource.
|
||||
MatchedDatabase matched_database = 4;
|
||||
// MatchedHTTPSTunnelApp will be set when the query matched an app that
|
||||
// should be tunneled via the HTTPS-in-mTLS ALPN protocol.
|
||||
MatchedHTTPSTunnelApp matched_https_tunnel_app = 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +154,13 @@ message MatchedTCPApp {
|
||||
// MatchedTCPApp is a placeholder to signify that the query matched a web app.
|
||||
message MatchedWebApp {}
|
||||
|
||||
// MatchedHTTPSTunnelApp holds info about an app that should be proxied through
|
||||
// the HTTPS-in-mTLS tunnel.
|
||||
message MatchedHTTPSTunnelApp {
|
||||
// AppInfo holds all necessary info for making connections to the resolved app.
|
||||
AppInfo app_info = 1;
|
||||
}
|
||||
|
||||
// MatchedCluster holds info about a cluster that a query matched.
|
||||
message MatchedCluster {
|
||||
// Ipv4CidrRange is the CIDR range from which an IPv4 address should be assigned
|
||||
|
||||
Reference in New Issue
Block a user