Merge branch 'master' of github.com:gravitational/teleport into STeve/vnet_process_app_https_tunnel (#66451)

This commit is contained in:
STeve (Xin) Huang
2026-05-27 14:44:32 +00:00
committed by GitHub
parent ae4ea74ad8
commit c2307ccd04
10 changed files with 643 additions and 329 deletions
File diff suppressed because it is too large Load Diff
+28 -43
View File
@@ -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.
+5 -3
View File
@@ -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
View File
@@ -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.
+65
View File
@@ -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
View File
@@ -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)
}
+31 -3
View File
@@ -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.
+11
View File
@@ -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
View File
@@ -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