diff --git a/gen/proto/go/teleport/relaytunnel/v1alpha/discovery_service.pb.go b/gen/proto/go/teleport/relaytunnel/v1alpha/discovery_service.pb.go
new file mode 100644
index 00000000000..d35a5a3cc12
--- /dev/null
+++ b/gen/proto/go/teleport/relaytunnel/v1alpha/discovery_service.pb.go
@@ -0,0 +1,192 @@
+// 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 .
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.8
+// protoc (unknown)
+// source: teleport/relaytunnel/v1alpha/discovery_service.proto
+
+package relaytunnelv1alpha
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// request message for DiscoveryService.Discover
+type DiscoverRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DiscoverRequest) Reset() {
+ *x = DiscoverRequest{}
+ mi := &file_teleport_relaytunnel_v1alpha_discovery_service_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DiscoverRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DiscoverRequest) ProtoMessage() {}
+
+func (x *DiscoverRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_relaytunnel_v1alpha_discovery_service_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DiscoverRequest.ProtoReflect.Descriptor instead.
+func (*DiscoverRequest) Descriptor() ([]byte, []int) {
+ return file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescGZIP(), []int{0}
+}
+
+// response message for DiscoveryService.Discover
+type DiscoverResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ RelayGroup string `protobuf:"bytes,1,opt,name=relay_group,json=relayGroup,proto3" json:"relay_group,omitempty"`
+ TargetConnectionCount int32 `protobuf:"varint,2,opt,name=target_connection_count,json=targetConnectionCount,proto3" json:"target_connection_count,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DiscoverResponse) Reset() {
+ *x = DiscoverResponse{}
+ mi := &file_teleport_relaytunnel_v1alpha_discovery_service_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DiscoverResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DiscoverResponse) ProtoMessage() {}
+
+func (x *DiscoverResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_teleport_relaytunnel_v1alpha_discovery_service_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DiscoverResponse.ProtoReflect.Descriptor instead.
+func (*DiscoverResponse) Descriptor() ([]byte, []int) {
+ return file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *DiscoverResponse) GetRelayGroup() string {
+ if x != nil {
+ return x.RelayGroup
+ }
+ return ""
+}
+
+func (x *DiscoverResponse) GetTargetConnectionCount() int32 {
+ if x != nil {
+ return x.TargetConnectionCount
+ }
+ return 0
+}
+
+var File_teleport_relaytunnel_v1alpha_discovery_service_proto protoreflect.FileDescriptor
+
+const file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDesc = "" +
+ "\n" +
+ "4teleport/relaytunnel/v1alpha/discovery_service.proto\x12\x1cteleport.relaytunnel.v1alpha\"\x11\n" +
+ "\x0fDiscoverRequest\"k\n" +
+ "\x10DiscoverResponse\x12\x1f\n" +
+ "\vrelay_group\x18\x01 \x01(\tR\n" +
+ "relayGroup\x126\n" +
+ "\x17target_connection_count\x18\x02 \x01(\x05R\x15targetConnectionCount2}\n" +
+ "\x10DiscoveryService\x12i\n" +
+ "\bDiscover\x12-.teleport.relaytunnel.v1alpha.DiscoverRequest\x1a..teleport.relaytunnel.v1alpha.DiscoverResponseB`Z^github.com/gravitational/teleport/gen/proto/go/teleport/relaytunnel/v1alpha;relaytunnelv1alphab\x06proto3"
+
+var (
+ file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescOnce sync.Once
+ file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescData []byte
+)
+
+func file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescGZIP() []byte {
+ file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescOnce.Do(func() {
+ file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDesc), len(file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDesc)))
+ })
+ return file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDescData
+}
+
+var file_teleport_relaytunnel_v1alpha_discovery_service_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_teleport_relaytunnel_v1alpha_discovery_service_proto_goTypes = []any{
+ (*DiscoverRequest)(nil), // 0: teleport.relaytunnel.v1alpha.DiscoverRequest
+ (*DiscoverResponse)(nil), // 1: teleport.relaytunnel.v1alpha.DiscoverResponse
+}
+var file_teleport_relaytunnel_v1alpha_discovery_service_proto_depIdxs = []int32{
+ 0, // 0: teleport.relaytunnel.v1alpha.DiscoveryService.Discover:input_type -> teleport.relaytunnel.v1alpha.DiscoverRequest
+ 1, // 1: teleport.relaytunnel.v1alpha.DiscoveryService.Discover:output_type -> teleport.relaytunnel.v1alpha.DiscoverResponse
+ 1, // [1:2] is the sub-list for method output_type
+ 0, // [0:1] is the sub-list for method input_type
+ 0, // [0:0] is the sub-list for extension type_name
+ 0, // [0:0] is the sub-list for extension extendee
+ 0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_teleport_relaytunnel_v1alpha_discovery_service_proto_init() }
+func file_teleport_relaytunnel_v1alpha_discovery_service_proto_init() {
+ if File_teleport_relaytunnel_v1alpha_discovery_service_proto != nil {
+ return
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDesc), len(file_teleport_relaytunnel_v1alpha_discovery_service_proto_rawDesc)),
+ NumEnums: 0,
+ NumMessages: 2,
+ NumExtensions: 0,
+ NumServices: 1,
+ },
+ GoTypes: file_teleport_relaytunnel_v1alpha_discovery_service_proto_goTypes,
+ DependencyIndexes: file_teleport_relaytunnel_v1alpha_discovery_service_proto_depIdxs,
+ MessageInfos: file_teleport_relaytunnel_v1alpha_discovery_service_proto_msgTypes,
+ }.Build()
+ File_teleport_relaytunnel_v1alpha_discovery_service_proto = out.File
+ file_teleport_relaytunnel_v1alpha_discovery_service_proto_goTypes = nil
+ file_teleport_relaytunnel_v1alpha_discovery_service_proto_depIdxs = nil
+}
diff --git a/gen/proto/go/teleport/relaytunnel/v1alpha/discovery_service_grpc.pb.go b/gen/proto/go/teleport/relaytunnel/v1alpha/discovery_service_grpc.pb.go
new file mode 100644
index 00000000000..4cea59d3b45
--- /dev/null
+++ b/gen/proto/go/teleport/relaytunnel/v1alpha/discovery_service_grpc.pb.go
@@ -0,0 +1,145 @@
+// 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 .
+
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.5.1
+// - protoc (unknown)
+// source: teleport/relaytunnel/v1alpha/discovery_service.proto
+
+package relaytunnelv1alpha
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.64.0 or later.
+const _ = grpc.SupportPackageIsVersion9
+
+const (
+ DiscoveryService_Discover_FullMethodName = "/teleport.relaytunnel.v1alpha.DiscoveryService/Discover"
+)
+
+// DiscoveryServiceClient is the client API for DiscoveryService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+//
+// rpcs related to the distribution of information about the relay group to
+// agents
+type DiscoveryServiceClient interface {
+ // returns info about the relay group and how agents should behave
+ Discover(ctx context.Context, in *DiscoverRequest, opts ...grpc.CallOption) (*DiscoverResponse, error)
+}
+
+type discoveryServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewDiscoveryServiceClient(cc grpc.ClientConnInterface) DiscoveryServiceClient {
+ return &discoveryServiceClient{cc}
+}
+
+func (c *discoveryServiceClient) Discover(ctx context.Context, in *DiscoverRequest, opts ...grpc.CallOption) (*DiscoverResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(DiscoverResponse)
+ err := c.cc.Invoke(ctx, DiscoveryService_Discover_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// DiscoveryServiceServer is the server API for DiscoveryService service.
+// All implementations must embed UnimplementedDiscoveryServiceServer
+// for forward compatibility.
+//
+// rpcs related to the distribution of information about the relay group to
+// agents
+type DiscoveryServiceServer interface {
+ // returns info about the relay group and how agents should behave
+ Discover(context.Context, *DiscoverRequest) (*DiscoverResponse, error)
+ mustEmbedUnimplementedDiscoveryServiceServer()
+}
+
+// UnimplementedDiscoveryServiceServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedDiscoveryServiceServer struct{}
+
+func (UnimplementedDiscoveryServiceServer) Discover(context.Context, *DiscoverRequest) (*DiscoverResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method Discover not implemented")
+}
+func (UnimplementedDiscoveryServiceServer) mustEmbedUnimplementedDiscoveryServiceServer() {}
+func (UnimplementedDiscoveryServiceServer) testEmbeddedByValue() {}
+
+// UnsafeDiscoveryServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to DiscoveryServiceServer will
+// result in compilation errors.
+type UnsafeDiscoveryServiceServer interface {
+ mustEmbedUnimplementedDiscoveryServiceServer()
+}
+
+func RegisterDiscoveryServiceServer(s grpc.ServiceRegistrar, srv DiscoveryServiceServer) {
+ // If the following call pancis, it indicates UnimplementedDiscoveryServiceServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&DiscoveryService_ServiceDesc, srv)
+}
+
+func _DiscoveryService_Discover_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(DiscoverRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(DiscoveryServiceServer).Discover(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: DiscoveryService_Discover_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(DiscoveryServiceServer).Discover(ctx, req.(*DiscoverRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+// DiscoveryService_ServiceDesc is the grpc.ServiceDesc for DiscoveryService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var DiscoveryService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "teleport.relaytunnel.v1alpha.DiscoveryService",
+ HandlerType: (*DiscoveryServiceServer)(nil),
+ Methods: []grpc.MethodDesc{
+ {
+ MethodName: "Discover",
+ Handler: _DiscoveryService_Discover_Handler,
+ },
+ },
+ Streams: []grpc.StreamDesc{},
+ Metadata: "teleport/relaytunnel/v1alpha/discovery_service.proto",
+}
diff --git a/lib/multiplexer/grpc/proxyline.go b/lib/multiplexer/grpc/proxyline.go
new file mode 100644
index 00000000000..5c87f8b6f4b
--- /dev/null
+++ b/lib/multiplexer/grpc/proxyline.go
@@ -0,0 +1,59 @@
+// 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 .
+
+package grpc
+
+import (
+ "net"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/grpc/credentials"
+
+ "github.com/gravitational/teleport/lib/multiplexer"
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+// PPV2ServerCredentials is a wrapper around a gRPC
+// [credentials.TransportCredentials] that reads a PROXY protocol v2 header
+// before passing the incoming connection to the wrapped credentials. When used
+// as client credentials, the wrapped credentials are used as-is.
+type PPV2ServerCredentials struct {
+ _ struct{}
+
+ credentials.TransportCredentials
+}
+
+var _ credentials.TransportCredentials = PPV2ServerCredentials{}
+
+// Clone implements [credentials.TransportCredentials].
+func (p PPV2ServerCredentials) Clone() credentials.TransportCredentials {
+ return PPV2ServerCredentials{
+ TransportCredentials: p.TransportCredentials.Clone(),
+ }
+}
+
+// ServerHandshake implements [credentials.TransportCredentials].
+func (p PPV2ServerCredentials) ServerHandshake(nc net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ proxyLine, err := multiplexer.ReadProxyLineV2(nc)
+ if err != nil {
+ _ = nc.Close()
+ return nil, nil, trace.Wrap(err)
+ }
+ if proxyLine != nil {
+ nc = utils.NewConnWithAddr(nc, &proxyLine.Destination, &proxyLine.Source)
+ }
+ return p.TransportCredentials.ServerHandshake(nc)
+}
diff --git a/lib/multiplexer/grpc/proxyline_test.go b/lib/multiplexer/grpc/proxyline_test.go
new file mode 100644
index 00000000000..1bcb996dfa3
--- /dev/null
+++ b/lib/multiplexer/grpc/proxyline_test.go
@@ -0,0 +1,74 @@
+// 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 .
+
+package grpc
+
+import (
+ "bytes"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ "github.com/gravitational/teleport/lib/multiplexer"
+)
+
+func TestPPV2ServerCredentials(t *testing.T) {
+ dispatchC := make(chan net.Conn, 1)
+
+ creds := PPV2ServerCredentials{TransportCredentials: dispatchCredentials{dispatchC: dispatchC}}
+
+ srv := grpc.NewServer(grpc.Creds(creds))
+ defer srv.Stop()
+
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ defer lis.Close()
+ go srv.Serve(lis)
+
+ rawConn, err := net.Dial(lis.Addr().Network(), lis.Addr().String())
+ require.NoError(t, err)
+ defer rawConn.Close()
+
+ // source=127.0.0.3:12345 destination=127.0.0.2:42
+ sampleIPv4Addresses := []byte{0x7F, 0x00, 0x00, 0x03, 0x7F, 0x00, 0x00, 0x02, 0x30, 0x39, 0x00, 0x2A}
+ // {0x21, 0x11, 0x00, 0x0C} - 4 bits version, 4 bits command, 4 bits address family, 4 bits protocol, 16 bits length
+ sampleProxyV2Line := bytes.Join([][]byte{multiplexer.ProxyV2Prefix, {0x21, 0x11, 0x00, 0x0C}, sampleIPv4Addresses}, nil)
+
+ require.NotEqual(t, "127.0.0.3:12345", rawConn.LocalAddr().String())
+ require.NotEqual(t, "127.0.0.2:42", rawConn.RemoteAddr().String())
+
+ _, err = rawConn.Write(sampleProxyV2Line)
+ require.NoError(t, err)
+
+ wrappedConn := <-dispatchC
+ defer wrappedConn.Close()
+ require.Equal(t, "127.0.0.2:42", wrappedConn.LocalAddr().String())
+ require.Equal(t, "127.0.0.3:12345", wrappedConn.RemoteAddr().String())
+}
+
+type dispatchCredentials struct {
+ credentials.TransportCredentials
+ dispatchC chan net.Conn
+}
+
+// ServerHandshake implements [credentials.TransportCredentials].
+func (d dispatchCredentials) ServerHandshake(c net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ d.dispatchC <- c
+ return nil, nil, credentials.ErrConnDispatched
+}
diff --git a/lib/relaytunnel/discover.go b/lib/relaytunnel/discover.go
new file mode 100644
index 00000000000..72f1a003ee3
--- /dev/null
+++ b/lib/relaytunnel/discover.go
@@ -0,0 +1,131 @@
+// 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 .
+
+package relaytunnel
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+
+ "github.com/gravitational/trace"
+ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+
+ "github.com/gravitational/teleport/api/metadata"
+ "github.com/gravitational/teleport/api/utils/grpc/interceptors"
+ relaytunnelv1alpha "github.com/gravitational/teleport/gen/proto/go/teleport/relaytunnel/v1alpha"
+)
+
+//nolint:unused // used by StaticDiscoverServiceServer
+type unimplementedDiscoveryServiceServer = relaytunnelv1alpha.UnimplementedDiscoveryServiceServer
+
+// StaticDiscoverServiceServer is a [relaytunnelv1alpha.DiscoveryServiceServer]
+// implementation that responds with fixed data to the Discover rpc.
+type StaticDiscoverServiceServer struct {
+ _ struct{} // prevent unkeyed literals
+
+ unimplementedDiscoveryServiceServer //nolint:unused // required and used by grpc-go
+
+ RelayGroup string
+ TargetConnectionCount int32
+}
+
+var _ relaytunnelv1alpha.DiscoveryServiceServer = (*StaticDiscoverServiceServer)(nil)
+
+// Discover implements [relaytunnelv1alpha.DiscoveryServiceServer].
+func (d *StaticDiscoverServiceServer) Discover(ctx context.Context, req *relaytunnelv1alpha.DiscoverRequest) (*relaytunnelv1alpha.DiscoverResponse, error) {
+ return &relaytunnelv1alpha.DiscoverResponse{
+ RelayGroup: d.RelayGroup,
+ TargetConnectionCount: d.TargetConnectionCount,
+ }, nil
+}
+
+type DiscoverParams struct {
+ GetCertificate func() (*tls.Certificate, error)
+ GetPool func() (*x509.CertPool, error)
+ Ciphersuites []uint16
+
+ Target string
+}
+
+// discover returns configuration and connectivity information from a Relay and
+// its group, given an API endpoint and some cluster authentication data. As the
+// discover API is intended to only be used sporadically and with what should be
+// a fresh server behind a load balancer every time, this function establishes a
+// brand new connection and disposes of it before returning.
+func discover(ctx context.Context, params DiscoverParams) (*relaytunnelv1alpha.DiscoverResponse, error) {
+ if params.GetCertificate == nil {
+ return nil, trace.BadParameter("missing GetCertificate")
+ }
+ if params.GetPool == nil {
+ return nil, trace.BadParameter("missing GetPool")
+ }
+
+ cert, err := params.GetCertificate()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ pool, err := params.GetPool()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ tlsConfig := &tls.Config{
+ GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
+ return cert, nil
+ },
+ RootCAs: pool,
+
+ // the [credentials.NewTLS] transport credentials will take care of SNI
+ // and ALPN
+ NextProtos: nil,
+ ServerName: "",
+
+ CipherSuites: params.Ciphersuites,
+ MinVersion: tls.VersionTLS12,
+ }
+
+ cc, err := grpc.NewClient(params.Target,
+ grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
+ grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
+ grpc.WithChainUnaryInterceptor(
+ metadata.UnaryClientInterceptor,
+ interceptors.GRPCClientUnaryErrorInterceptor,
+ ),
+ grpc.WithChainStreamInterceptor(
+ metadata.StreamClientInterceptor,
+ interceptors.GRPCClientStreamErrorInterceptor,
+ ),
+ )
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ defer cc.Close()
+
+ clt := relaytunnelv1alpha.NewDiscoveryServiceClient(cc)
+
+ resp, err := clt.Discover(ctx, &relaytunnelv1alpha.DiscoverRequest{})
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return resp, nil
+}
+
+// TODO(espadolini): remove once the function is actually used
+var _ = discover
diff --git a/lib/relaytunnel/tunnel_common.go b/lib/relaytunnel/tunnel_common.go
new file mode 100644
index 00000000000..67cd9f9e8ef
--- /dev/null
+++ b/lib/relaytunnel/tunnel_common.go
@@ -0,0 +1,22 @@
+// 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 .
+
+package relaytunnel
+
+// yamuxTunnelALPN is the ALPN protocol name for the bespoke protocol used by
+// tunnel connections. Future variants of the protocol should use a different
+// ALPN protocol name.
+const yamuxTunnelALPN = "teleport-relaytunnel"
diff --git a/lib/relaytunnel/tunnel_server.go b/lib/relaytunnel/tunnel_server.go
new file mode 100644
index 00000000000..5fc312e24ef
--- /dev/null
+++ b/lib/relaytunnel/tunnel_server.go
@@ -0,0 +1,179 @@
+// 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 .
+
+package relaytunnel
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "net"
+ "time"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/grpc/credentials"
+
+ "github.com/gravitational/teleport/lib/tlsca"
+)
+
+type ServerConfig struct {
+ GetCertificate func(ctx context.Context) (*tls.Certificate, error)
+ GetPool func(ctx context.Context) (*x509.CertPool, error)
+ Ciphersuites []uint16
+}
+
+func NewServer(cfg ServerConfig) (*Server, error) {
+ if cfg.GetCertificate == nil {
+ return nil, trace.BadParameter("missing GetCertificate")
+ }
+ if cfg.GetPool == nil {
+ return nil, trace.BadParameter("missing GetPool")
+ }
+ return &Server{
+ getCertificate: cfg.GetCertificate,
+ getPool: cfg.GetPool,
+ ciphersuites: cfg.Ciphersuites,
+ }, nil
+}
+
+type Server struct {
+ getCertificate func(ctx context.Context) (*tls.Certificate, error)
+ getPool func(ctx context.Context) (*x509.CertPool, error)
+ ciphersuites []uint16
+}
+
+func (s *Server) GRPCServerCredentials() credentials.TransportCredentials {
+ return &grpcServerCredentials{
+ tunnelServer: s,
+
+ getCertificate: s.getCertificate,
+ getPool: s.getPool,
+ ciphersuites: s.ciphersuites,
+ }
+}
+
+type grpcServerCredentials struct {
+ tunnelServer *Server
+
+ getCertificate func(ctx context.Context) (*tls.Certificate, error)
+ getPool func(ctx context.Context) (*x509.CertPool, error)
+ ciphersuites []uint16
+}
+
+// ClientHandshake implements [credentials.TransportCredentials].
+func (*grpcServerCredentials) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ _ = rawConn.Close()
+ return nil, nil, trace.NotImplemented("these transport credentials can only be used as a server")
+}
+
+// OverrideServerName implements implements [credentials.TransportCredentials].
+func (*grpcServerCredentials) OverrideServerName(string) error {
+ return nil
+}
+
+// Clone implements implements [credentials.TransportCredentials].
+func (s *grpcServerCredentials) Clone() credentials.TransportCredentials {
+ // s is immutable so there's no need to copy anything
+ return s
+}
+
+// Info implements implements [credentials.TransportCredentials].
+func (s *grpcServerCredentials) Info() credentials.ProtocolInfo {
+ return credentials.ProtocolInfo{
+ SecurityProtocol: "tls",
+ SecurityVersion: "1.2",
+ }
+}
+
+// ServerHandshake implements implements [credentials.TransportCredentials].
+func (s *grpcServerCredentials) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.AuthInfo, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ cert, err := s.getCertificate(ctx)
+ if err != nil {
+ _ = rawConn.Close()
+ return nil, nil, trace.Wrap(err)
+ }
+ pool, err := s.getPool(ctx)
+ if err != nil {
+ _ = rawConn.Close()
+ return nil, nil, trace.Wrap(err)
+ }
+
+ var clientID *tlsca.Identity
+ tlsConfig := &tls.Config{
+ GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
+ return cert, nil
+ },
+ VerifyConnection: func(cs tls.ConnectionState) error {
+ if cs.NegotiatedProtocol == "" {
+ // client tried to connect with no ALPN (or with http/1.1 in its
+ // protocol list because of an undocumented behavior of the
+ // crypto/tls server handshake)
+ return trace.NotImplemented("missing ALPN in TLS ClientHello")
+ }
+ if len(cs.VerifiedChains) < 1 {
+ return trace.AccessDenied("missing or invalid client certificate")
+ }
+
+ if cs.NegotiatedProtocol == "h2" {
+ return nil
+ }
+
+ id, err := tlsca.FromSubject(cs.VerifiedChains[0][0].Subject, cs.VerifiedChains[0][0].NotAfter)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ clientID = id
+
+ return nil
+ },
+ NextProtos: []string{yamuxTunnelALPN, "h2"},
+
+ ClientAuth: tls.RequireAndVerifyClientCert,
+ ClientCAs: pool,
+
+ InsecureSkipVerify: false,
+
+ MinVersion: tls.VersionTLS12,
+ CipherSuites: s.ciphersuites,
+ SessionTicketsDisabled: true,
+ }
+
+ tlsConn := tls.Server(rawConn, tlsConfig)
+ if err := tlsConn.Handshake(); err != nil {
+ _ = tlsConn.Close()
+ return nil, nil, trace.Wrap(err)
+ }
+
+ cs := tlsConn.ConnectionState()
+ if cs.NegotiatedProtocol == yamuxTunnelALPN {
+ // TODO(espadolini): handle the actual tunnel connection, using clientID
+ // for authz
+ _ = tlsConn.Close()
+ _ = clientID
+ return nil, nil, credentials.ErrConnDispatched
+ }
+ tlsInfo := credentials.TLSInfo{
+ State: cs,
+ CommonAuthInfo: credentials.CommonAuthInfo{
+ SecurityLevel: credentials.PrivacyAndIntegrity,
+ },
+ }
+
+ return tlsConn, tlsInfo, nil
+}
diff --git a/lib/service/listeners.go b/lib/service/listeners.go
index 1a2e7094654..24b0a677819 100644
--- a/lib/service/listeners.go
+++ b/lib/service/listeners.go
@@ -48,6 +48,7 @@ var (
ListenerProxyPeer = ListenerType(teleport.Component(teleport.ComponentProxy, "peer"))
ListenerMetrics = ListenerType(teleport.ComponentMetrics)
ListenerWindowsDesktop = ListenerType(teleport.ComponentWindowsDesktop)
+ ListenerRelayTunnel = ListenerType(teleport.Component(teleport.ComponentRelay, "tunnel"))
)
// Network returns the network used by the listener.
diff --git a/lib/service/relay.go b/lib/service/relay.go
index b4dc61adfbb..56579079037 100644
--- a/lib/service/relay.go
+++ b/lib/service/relay.go
@@ -18,17 +18,27 @@ package service
import (
"context"
+ "crypto/tls"
+ "crypto/x509"
"log/slog"
"sync/atomic"
"github.com/google/uuid"
"github.com/gravitational/trace"
+ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+ "golang.org/x/sync/errgroup"
+ "google.golang.org/grpc"
"google.golang.org/protobuf/proto"
"github.com/gravitational/teleport"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
presencev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/presence/v1"
apitypes "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/api/utils/grpc/interceptors"
+ relaytunnelv1alpha "github.com/gravitational/teleport/gen/proto/go/teleport/relaytunnel/v1alpha"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+ multiplexergrpc "github.com/gravitational/teleport/lib/multiplexer/grpc"
+ "github.com/gravitational/teleport/lib/relaytunnel"
"github.com/gravitational/teleport/lib/srv"
)
@@ -60,8 +70,50 @@ func (process *TeleportProcess) runRelayService() error {
return err
}
- // TODO(espadolini): use the access point
- _ = accessPoint
+ tunnelServer, err := relaytunnel.NewServer(relaytunnel.ServerConfig{
+ GetCertificate: func(ctx context.Context) (*tls.Certificate, error) {
+ return conn.serverGetCertificate()
+ },
+ GetPool: func(ctx context.Context) (*x509.CertPool, error) {
+ pool, _, err := authclient.ClientCertPool(ctx, accessPoint, conn.clusterName, apitypes.HostCA)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return pool, nil
+ },
+ Ciphersuites: process.Config.CipherSuites,
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ tunnelCreds := tunnelServer.GRPCServerCredentials()
+ if process.Config.Relay.TunnelPROXYProtocol {
+ tunnelCreds = multiplexergrpc.PPV2ServerCredentials{TransportCredentials: tunnelCreds}
+ }
+ tunnelGRPCServer := grpc.NewServer(
+ grpc.StatsHandler(otelgrpc.NewServerHandler()),
+ grpc.ChainUnaryInterceptor(
+ interceptors.GRPCServerUnaryErrorInterceptor,
+ ),
+ grpc.ChainStreamInterceptor(
+ interceptors.GRPCServerStreamErrorInterceptor,
+ ),
+ grpc.Creds(tunnelCreds),
+ )
+ defer tunnelGRPCServer.Stop()
+
+ relaytunnelv1alpha.RegisterDiscoveryServiceServer(tunnelGRPCServer, &relaytunnel.StaticDiscoverServiceServer{
+ RelayGroup: process.Config.Relay.RelayGroup,
+ TargetConnectionCount: process.Config.Relay.TargetConnectionCount,
+ })
+
+ tunnelListener, err := process.importOrCreateListener(ListenerRelayTunnel, process.Config.Relay.TunnelListenAddr.String())
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer tunnelListener.Close()
+ go tunnelGRPCServer.Serve(tunnelListener)
nonce := uuid.NewString()
var relayServer atomic.Pointer[presencev1.RelayServer]
@@ -127,6 +179,15 @@ func (process *TeleportProcess) runRelayService() error {
log.InfoContext(ctx, "Stopping the relay service")
}
+ log.DebugContext(ctx, "Stopping servers")
+ eg, egCtx := errgroup.WithContext(ctx)
+ eg.Go(func() error {
+ defer context.AfterFunc(egCtx, tunnelGRPCServer.Stop)()
+ tunnelGRPCServer.GracefulStop()
+ return nil
+ })
+ warnOnErr(egCtx, eg.Wait(), log)
+
warnOnErr(ctx, hb.Close(), log)
warnOnErr(ctx, conn.Close(), log)
diff --git a/proto/teleport/relaytunnel/v1alpha/discovery_service.proto b/proto/teleport/relaytunnel/v1alpha/discovery_service.proto
new file mode 100644
index 00000000000..a16fcd67787
--- /dev/null
+++ b/proto/teleport/relaytunnel/v1alpha/discovery_service.proto
@@ -0,0 +1,38 @@
+// 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 .
+
+syntax = "proto3";
+
+package teleport.relaytunnel.v1alpha;
+
+option go_package = "github.com/gravitational/teleport/gen/proto/go/teleport/relaytunnel/v1alpha;relaytunnelv1alpha";
+
+// rpcs related to the distribution of information about the relay group to
+// agents
+service DiscoveryService {
+ // returns info about the relay group and how agents should behave
+ rpc Discover(DiscoverRequest) returns (DiscoverResponse);
+}
+
+// request message for DiscoveryService.Discover
+message DiscoverRequest {}
+
+// response message for DiscoveryService.Discover
+message DiscoverResponse {
+ string relay_group = 1;
+
+ int32 target_connection_count = 2;
+}