Relay peering client and server (#58854)

* proto: relay peering messages

* make grpc

* Relay peering server and client

* fallible client creation, comments

* server comments
This commit is contained in:
Edoardo Spadolini
2025-09-25 12:19:36 +02:00
committed by GitHub
parent e8400418c8
commit e1102466ae
10 changed files with 1092 additions and 0 deletions
@@ -0,0 +1,284 @@
// Teleport
// Copyright (C) 2024 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/>.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.8
// protoc (unknown)
// source: teleport/relaypeering/v1alpha/dial.proto
package relaypeeringv1alpha
import (
status "google.golang.org/genproto/googleapis/rpc/status"
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)
)
// the message sent by the client at the beginning of a connection
type DialRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// the target agent for the connection attempt; should be "<host id>.<cluster name>"
TargetHostId string `protobuf:"bytes,1,opt,name=target_host_id,json=targetHostId,proto3" json:"target_host_id,omitempty"`
// the type of the connection as defined by api/types.TunnelType ("node",
// "app", "kube"...)
ConnectionType string `protobuf:"bytes,2,opt,name=connection_type,json=connectionType,proto3" json:"connection_type,omitempty"`
Source *Addr `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
Destination *Addr `protobuf:"bytes,4,opt,name=destination,proto3" json:"destination,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DialRequest) Reset() {
*x = DialRequest{}
mi := &file_teleport_relaypeering_v1alpha_dial_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DialRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DialRequest) ProtoMessage() {}
func (x *DialRequest) ProtoReflect() protoreflect.Message {
mi := &file_teleport_relaypeering_v1alpha_dial_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 DialRequest.ProtoReflect.Descriptor instead.
func (*DialRequest) Descriptor() ([]byte, []int) {
return file_teleport_relaypeering_v1alpha_dial_proto_rawDescGZIP(), []int{0}
}
func (x *DialRequest) GetTargetHostId() string {
if x != nil {
return x.TargetHostId
}
return ""
}
func (x *DialRequest) GetConnectionType() string {
if x != nil {
return x.ConnectionType
}
return ""
}
func (x *DialRequest) GetSource() *Addr {
if x != nil {
return x.Source
}
return nil
}
func (x *DialRequest) GetDestination() *Addr {
if x != nil {
return x.Destination
}
return nil
}
// the message sent by the server as a response to a DialRequest; in case of
// success (status is missing or reports "ok") the connection data follows on
// the same stream, otherwise the stream ends and any data sent by the client is
// to be treated as silently discarded
type DialResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Status *status.Status `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DialResponse) Reset() {
*x = DialResponse{}
mi := &file_teleport_relaypeering_v1alpha_dial_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DialResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DialResponse) ProtoMessage() {}
func (x *DialResponse) ProtoReflect() protoreflect.Message {
mi := &file_teleport_relaypeering_v1alpha_dial_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 DialResponse.ProtoReflect.Descriptor instead.
func (*DialResponse) Descriptor() ([]byte, []int) {
return file_teleport_relaypeering_v1alpha_dial_proto_rawDescGZIP(), []int{1}
}
func (x *DialResponse) GetStatus() *status.Status {
if x != nil {
return x.Status
}
return nil
}
// a stringy Go net.Addr, usually converted to and from lib/utils.NetAddr
type Addr struct {
state protoimpl.MessageState `protogen:"open.v1"`
// likely always "tcp"
Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"`
// depending on the network, likely "<ip address>:<port>"
Addr string `protobuf:"bytes,2,opt,name=addr,proto3" json:"addr,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Addr) Reset() {
*x = Addr{}
mi := &file_teleport_relaypeering_v1alpha_dial_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Addr) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Addr) ProtoMessage() {}
func (x *Addr) ProtoReflect() protoreflect.Message {
mi := &file_teleport_relaypeering_v1alpha_dial_proto_msgTypes[2]
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 Addr.ProtoReflect.Descriptor instead.
func (*Addr) Descriptor() ([]byte, []int) {
return file_teleport_relaypeering_v1alpha_dial_proto_rawDescGZIP(), []int{2}
}
func (x *Addr) GetNetwork() string {
if x != nil {
return x.Network
}
return ""
}
func (x *Addr) GetAddr() string {
if x != nil {
return x.Addr
}
return ""
}
var File_teleport_relaypeering_v1alpha_dial_proto protoreflect.FileDescriptor
const file_teleport_relaypeering_v1alpha_dial_proto_rawDesc = "" +
"\n" +
"(teleport/relaypeering/v1alpha/dial.proto\x12\x1dteleport.relaypeering.v1alpha\x1a\x17google/rpc/status.proto\"\xe0\x01\n" +
"\vDialRequest\x12$\n" +
"\x0etarget_host_id\x18\x01 \x01(\tR\ftargetHostId\x12'\n" +
"\x0fconnection_type\x18\x02 \x01(\tR\x0econnectionType\x12;\n" +
"\x06source\x18\x03 \x01(\v2#.teleport.relaypeering.v1alpha.AddrR\x06source\x12E\n" +
"\vdestination\x18\x04 \x01(\v2#.teleport.relaypeering.v1alpha.AddrR\vdestination\":\n" +
"\fDialResponse\x12*\n" +
"\x06status\x18\x01 \x01(\v2\x12.google.rpc.StatusR\x06status\"4\n" +
"\x04Addr\x12\x18\n" +
"\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" +
"\x04addr\x18\x02 \x01(\tR\x04addrBbZ`github.com/gravitational/teleport/gen/proto/go/teleport/relaypeering/v1alpha;relaypeeringv1alphab\x06proto3"
var (
file_teleport_relaypeering_v1alpha_dial_proto_rawDescOnce sync.Once
file_teleport_relaypeering_v1alpha_dial_proto_rawDescData []byte
)
func file_teleport_relaypeering_v1alpha_dial_proto_rawDescGZIP() []byte {
file_teleport_relaypeering_v1alpha_dial_proto_rawDescOnce.Do(func() {
file_teleport_relaypeering_v1alpha_dial_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_teleport_relaypeering_v1alpha_dial_proto_rawDesc), len(file_teleport_relaypeering_v1alpha_dial_proto_rawDesc)))
})
return file_teleport_relaypeering_v1alpha_dial_proto_rawDescData
}
var file_teleport_relaypeering_v1alpha_dial_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_teleport_relaypeering_v1alpha_dial_proto_goTypes = []any{
(*DialRequest)(nil), // 0: teleport.relaypeering.v1alpha.DialRequest
(*DialResponse)(nil), // 1: teleport.relaypeering.v1alpha.DialResponse
(*Addr)(nil), // 2: teleport.relaypeering.v1alpha.Addr
(*status.Status)(nil), // 3: google.rpc.Status
}
var file_teleport_relaypeering_v1alpha_dial_proto_depIdxs = []int32{
2, // 0: teleport.relaypeering.v1alpha.DialRequest.source:type_name -> teleport.relaypeering.v1alpha.Addr
2, // 1: teleport.relaypeering.v1alpha.DialRequest.destination:type_name -> teleport.relaypeering.v1alpha.Addr
3, // 2: teleport.relaypeering.v1alpha.DialResponse.status:type_name -> google.rpc.Status
3, // [3:3] is the sub-list for method output_type
3, // [3:3] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_teleport_relaypeering_v1alpha_dial_proto_init() }
func file_teleport_relaypeering_v1alpha_dial_proto_init() {
if File_teleport_relaypeering_v1alpha_dial_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_relaypeering_v1alpha_dial_proto_rawDesc), len(file_teleport_relaypeering_v1alpha_dial_proto_rawDesc)),
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_teleport_relaypeering_v1alpha_dial_proto_goTypes,
DependencyIndexes: file_teleport_relaypeering_v1alpha_dial_proto_depIdxs,
MessageInfos: file_teleport_relaypeering_v1alpha_dial_proto_msgTypes,
}.Build()
File_teleport_relaypeering_v1alpha_dial_proto = out.File
file_teleport_relaypeering_v1alpha_dial_proto_goTypes = nil
file_teleport_relaypeering_v1alpha_dial_proto_depIdxs = nil
}
+8
View File
@@ -3157,6 +3157,14 @@ func applyRelayConfig(fc *FileConfig, cfg *servicecfg.Config) error {
}
cfg.Relay.PeerListenAddr = peerListenAddr
if fc.Relay.PeerPublicAddr != "" {
_, _, err := net.SplitHostPort(fc.Relay.PeerPublicAddr)
if err != nil {
return trace.Wrap(err, "parsing relay_service.peer_public_addr")
}
cfg.Relay.PeerPublicAddr = fc.Relay.PeerPublicAddr
}
if fc.Relay.TunnelListenAddr == "" {
return trace.BadParameter("missing relay_service.tunnel_listen_addr")
}
+4
View File
@@ -2957,6 +2957,10 @@ type Relay struct {
// format.
PeerListenAddr string `yaml:"peer_listen_addr"`
// PeerPublicAddr, if set, is the public address for the peer listener, in
// host:port format.
PeerPublicAddr string `yaml:"peer_public_addr"`
// TunnelListenAddr is the listen address for the tunnel listener, in
// addr:port format.
TunnelListenAddr string `yaml:"tunnel_listen_addr"`
+270
View File
@@ -0,0 +1,270 @@
// 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 relaypeer
import (
"context"
"crypto/tls"
"crypto/x509"
"log/slog"
"net"
"slices"
"time"
"github.com/gravitational/trace"
"google.golang.org/grpc/status"
presencev1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/presence/v1"
"github.com/gravitational/teleport/api/trail"
"github.com/gravitational/teleport/api/types"
relaypeeringv1alpha "github.com/gravitational/teleport/gen/proto/go/teleport/relaypeering/v1alpha"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
)
type ClientAccessPoint interface {
GetRelayServer(ctx context.Context, name string) (*presencev1.RelayServer, error)
}
// ClientConfig contains parameters for [NewClient].
type ClientConfig struct {
// HostID is the host ID of the local machine, to avoid peering with
// ourselves.
HostID string
// ClusterName is the name of the Teleport cluster we belong to.
ClusterName string
// GroupName is the relay group we belong to, to avoid attempting to connect
// to relays from a different group.
GroupName string
// AccessPoint is used to read information about other relays in the same
// group.
AccessPoint ClientAccessPoint
Log *slog.Logger
GetCertificate func() (*tls.Certificate, error)
GetPool func() (*x509.CertPool, error)
Ciphersuites []uint16
}
// NewClient creates a [Client] with a given configuration.
func NewClient(cfg ClientConfig) (*Client, error) {
if cfg.HostID == "" {
return nil, trace.BadParameter("missing HostID")
}
if cfg.ClusterName == "" {
return nil, trace.BadParameter("missing ClusterName")
}
if cfg.GroupName == "" {
return nil, trace.BadParameter("missing GroupName")
}
if cfg.AccessPoint == nil {
return nil, trace.BadParameter("missing AccessPoint")
}
if cfg.Log == nil {
return nil, trace.BadParameter("missing Log")
}
if cfg.GetCertificate == nil {
return nil, trace.BadParameter("missing GetCertificate")
}
if cfg.GetPool == nil {
return nil, trace.BadParameter("missing GetPool")
}
return &Client{
hostID: cfg.HostID,
clusterName: cfg.ClusterName,
groupName: cfg.GroupName,
accessPoint: cfg.AccessPoint,
log: cfg.Log,
getCertificate: cfg.GetCertificate,
getPool: cfg.GetPool,
ciphersuites: cfg.Ciphersuites,
}, nil
}
// Client is used to open tunnel connections to services that are connected with
// relay tunnels to the local relay group, but don't have a tunnel available
// locally, and thus require bouncing the connection to a different peer relay
// in the same relay group. It implements the client side of the relay peer
// dialing protocol, and connects to peer relays through the advertised peer
// address in the relay_server heartbeats.
type Client struct {
hostID string
clusterName string
groupName string
accessPoint ClientAccessPoint
log *slog.Logger
getCertificate func() (*tls.Certificate, error)
getPool func() (*x509.CertPool, error)
ciphersuites []uint16
}
// Dial will try to open a connection to a target host (in "<host id>.<cluster
// name>" format) with a given tunnel type, trying to dial through a list of
// peer relay IDs in random order. The list is assumed to be owned by Dial and
// will be shuffled in place. The returned connection will have the given source
// (remote) and destination (local) addresses.
func (c *Client) Dial(ctx context.Context, dialTarget string, tunnelType types.TunnelType, relayIDs []string, src, dst net.Addr) (net.Conn, error) {
// it's assumed that all peer relays are equivalent
for _, relayID := range utils.ShuffleVisit(relayIDs) {
if relayID == c.hostID {
continue
}
nc, err := c.dialRelay(ctx, dialTarget, tunnelType, relayID, src, dst)
if err == nil {
c.log.DebugContext(ctx, "Successfully dialed through peer relay", "relay_id", relayID)
return nc, nil
}
c.log.DebugContext(ctx, "Failed to dial through peer relay", "relay_id", relayID, "error", err, "target", dialTarget)
}
return nil, trace.ConnectionProblem(nil, "unable to reach dial target through relay peering")
}
// dialRelay tries to open a connection to a target through a specific peer
// relay.
func (c *Client) dialRelay(ctx context.Context, dialTarget string, tunnelType types.TunnelType, relayID string, src net.Addr, dst net.Addr) (net.Conn, error) {
relayServer, err := c.accessPoint.GetRelayServer(ctx, relayID)
if err != nil {
return nil, trace.Wrap(err)
}
if relayServer.GetSpec().GetRelayGroup() != c.groupName {
return nil, trace.BadParameter("peer relay server belongs to different relay group")
}
peerAddr := relayServer.GetSpec().GetPeerAddr()
if peerAddr == "" {
return nil, trace.BadParameter("no peer addr in peer relay server")
}
cert, err := c.getCertificate()
if err != nil {
return nil, trace.Wrap(err)
}
pool, err := c.getPool()
if err != nil {
return nil, trace.Wrap(err)
}
nc, err := new(net.Dialer).DialContext(ctx, "tcp", peerAddr)
if err != nil {
return nil, trace.Wrap(err)
}
serverName, _, err := net.SplitHostPort(peerAddr)
if err != nil {
serverName = peerAddr
}
tlsConfig := &tls.Config{
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return cert, nil
},
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
if cs.NegotiatedProtocol == "" {
return trace.NotImplemented("relay peer protocol not supported")
}
opts := x509.VerifyOptions{
DNSName: "",
Roots: pool,
Intermediates: nil,
KeyUsages: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
}
if len(cs.PeerCertificates) > 1 {
opts.Intermediates = x509.NewCertPool()
for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert)
}
}
if _, err := cs.PeerCertificates[0].Verify(opts); err != nil {
return trace.Wrap(err)
}
id, err := tlsca.FromSubject(cs.PeerCertificates[0].Subject, cs.PeerCertificates[0].NotAfter)
if err != nil {
return trace.Wrap(err)
}
if !slices.Contains(id.Groups, string(types.RoleRelay)) &&
!slices.Contains(id.SystemRoles, string(types.RoleRelay)) {
return trace.BadParameter("dialed server is not a relay (roles %+q, system roles %+q)", id.Groups, id.SystemRoles)
}
if id.Username != relayID+"."+c.clusterName {
return trace.BadParameter("dialed server is the wrong relay (expected %+q, got %+q)", relayID, id.Username)
}
return nil
},
NextProtos: []string{simpleALPN},
ServerName: serverName,
CipherSuites: c.ciphersuites,
MinVersion: tls.VersionTLS12,
}
tc := tls.Client(nc, tlsConfig)
explode := make(chan struct{})
defuse := context.AfterFunc(ctx, func() {
defer close(explode)
tc.SetDeadline(time.Unix(1, 0))
})
defer defuse()
if err := writeProto(tc, &relaypeeringv1alpha.DialRequest{
TargetHostId: dialTarget,
ConnectionType: string(tunnelType),
Source: addrToProto(src),
Destination: addrToProto(dst),
}); err != nil {
defuse()
_ = tc.Close()
return nil, trace.Wrap(err)
}
resp := new(relaypeeringv1alpha.DialResponse)
if err := readProto(tc, resp); err != nil {
defuse()
_ = tc.Close()
return nil, trace.Wrap(err)
}
if !defuse() {
<-explode
}
tc.SetDeadline(time.Time{})
if err := trail.FromGRPC(status.FromProto(resp.GetStatus()).Err()); err != nil {
_ = tc.Close()
return nil, trace.Wrap(err)
}
return tc, nil
}
+110
View File
@@ -0,0 +1,110 @@
// 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 relaypeer
import (
"encoding/binary"
"errors"
"io"
"net"
"github.com/gravitational/trace"
"google.golang.org/protobuf/proto"
relaypeeringv1alpha "github.com/gravitational/teleport/gen/proto/go/teleport/relaypeering/v1alpha"
"github.com/gravitational/teleport/lib/utils"
)
// simpleALPN is the ALPN protocol name for the bespoke protocol used by peer
// connections. Future variants of the protocol should use a different ALPN
// protocol name.
const simpleALPN = "teleport-relaypeer"
// The teleport-relaypeer protocol consists of a DialRequest message sent by the
// client followed by a DialResponse message sent by the server, containing a
// google.rpc.Status. If the status is ok, the data for the connection will then
// follow.
//
// Messages are sent in their protobuf wire format, prefixed by a little endian
// 32 bit size. Messages must be smaller than maxMessageSize (128KiB).
const maxMessageSize = 128 * 1024
func readProto(r io.Reader, m proto.Message) error {
var sizeBuf [4]byte
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
return trace.Wrap(err)
}
size := binary.LittleEndian.Uint32(sizeBuf[:])
if size > maxMessageSize {
return trace.LimitExceeded("bad size")
}
msgBuf := make([]byte, size)
if _, err := io.ReadFull(r, msgBuf); err != nil {
if errors.Is(err, io.EOF) {
return trace.Wrap(io.ErrUnexpectedEOF)
}
return trace.Wrap(err)
}
if err := proto.Unmarshal(msgBuf, m); err != nil {
return trace.Wrap(err)
}
return nil
}
func writeProto(w io.Writer, m proto.Message) error {
msgBuf, err := proto.Marshal(m)
if err != nil {
return err
}
if len(msgBuf) > maxMessageSize {
return trace.LimitExceeded("bad size")
}
var sizeBuf [4]byte
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(msgBuf)))
if _, err := w.Write(sizeBuf[:]); err != nil {
return trace.Wrap(err)
}
if _, err := w.Write(msgBuf); err != nil {
return trace.Wrap(err)
}
return nil
}
func addrToProto(a net.Addr) *relaypeeringv1alpha.Addr {
if a == nil {
return nil
}
return &relaypeeringv1alpha.Addr{
Network: a.Network(),
Addr: a.String(),
}
}
func addrFromProto(a *relaypeeringv1alpha.Addr) net.Addr {
if a == nil {
return nil
}
return &utils.NetAddr{
AddrNetwork: a.GetNetwork(),
Addr: a.GetAddr(),
}
}
+304
View File
@@ -0,0 +1,304 @@
// 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 relaypeer
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"log/slog"
"net"
"slices"
"sync"
"time"
"github.com/gravitational/trace"
"google.golang.org/grpc/status"
"github.com/gravitational/teleport/api/trail"
"github.com/gravitational/teleport/api/types"
relaypeeringv1alpha "github.com/gravitational/teleport/gen/proto/go/teleport/relaypeering/v1alpha"
"github.com/gravitational/teleport/lib/tlsca"
"github.com/gravitational/teleport/lib/utils"
)
type dialFunc = func(ctx context.Context, dialTarget string, tunnelType types.TunnelType, src, dst net.Addr) (net.Conn, error)
// ServerConfig contains parameters for [NewServer].
type ServerConfig struct {
Log *slog.Logger
GetCertificate func(ctx context.Context) (*tls.Certificate, error)
GetPool func(ctx context.Context) (*x509.CertPool, error)
Ciphersuites []uint16
// LocalDial should dial the given target host (in "<host ID>.<cluster
// name>" form) for a given tunnel type, returning a connection with the
// given source (remote) address and destination (local) address.
LocalDial dialFunc
}
// NewServer creates a [Server] with a given configuration.
func NewServer(cfg ServerConfig) (*Server, error) {
if cfg.Log == nil {
return nil, trace.BadParameter("missing Log")
}
if cfg.GetCertificate == nil {
return nil, trace.BadParameter("missing GetCertificate")
}
if cfg.GetPool == nil {
return nil, trace.BadParameter("missing GetPool")
}
if cfg.LocalDial == nil {
return nil, trace.BadParameter("missing LocalDial")
}
ctx, ctxCancel := context.WithCancel(context.Background())
return &Server{
log: cfg.Log,
getCertificate: cfg.GetCertificate,
getPool: cfg.GetPool,
ciphersuites: cfg.Ciphersuites,
localDial: cfg.LocalDial,
ctx: ctx,
ctxCancel: ctxCancel,
}, nil
}
// Server manages listeners and accepts connections for the relay peering dial
// protocol, used by other relays in the same relay group to bounce connections
// for which the local relay hopefully has a tunnel but the peer relay does not.
// It implements the server side of the relay peering dial protocol. The
// relay_server heartbeat for the local relay should be advertising a peer
// address that lets the other relays reach the listener (or listeners) of this
// server.
type Server struct {
log *slog.Logger
getCertificate func(ctx context.Context) (*tls.Certificate, error)
getPool func(ctx context.Context) (*x509.CertPool, error)
ciphersuites []uint16
localDial dialFunc
mu sync.Mutex
wg sync.WaitGroup
// ctx should only be canceled while holding mu to synchronize adding
// connections and listeners to the maps and closing them, as well as adding
// tasks to wg, so it should not be externally cancelable.
ctx context.Context
ctxCancel context.CancelFunc
tlsListeners map[net.Listener]struct{}
conns map[io.Closer]struct{}
}
func (s *Server) ServeTLSListener(l net.Listener) error {
defer l.Close()
s.mu.Lock()
if s.ctx.Err() != nil {
s.mu.Unlock()
return trace.Errorf("server is already terminating")
}
s.wg.Add(1)
defer s.wg.Done()
if s.tlsListeners == nil {
s.tlsListeners = make(map[net.Listener]struct{})
}
s.tlsListeners[l] = struct{}{}
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.tlsListeners, l)
s.mu.Unlock()
}()
var tempDelay time.Duration
for {
if s.ctx.Err() != nil {
s.log.DebugContext(s.ctx, "Exiting due to requested termination")
return nil
}
c, err := l.Accept()
if err != nil {
if s.ctx.Err() != nil {
continue
}
if tempErr := *new(interface{ Temporary() bool }); errors.As(err, &tempErr) && tempErr.Temporary() {
tempDelay = max(5*time.Millisecond, min(2*tempDelay, time.Second))
select {
case <-time.After(tempDelay):
case <-s.ctx.Done():
}
continue
}
return trace.Wrap(err)
}
tempDelay = 0
s.mu.Lock()
if s.ctx.Err() != nil {
// a connection sneaked by right before we closed the listener
s.mu.Unlock()
_ = c.Close()
continue
}
if s.conns == nil {
s.conns = make(map[io.Closer]struct{})
}
s.conns[c] = struct{}{}
s.mu.Unlock()
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer func() {
s.mu.Lock()
delete(s.conns, c)
s.mu.Unlock()
}()
err := s.handleTLSConnection(c)
s.log.DebugContext(context.Background(), "Finished handling peer connection", "error", err)
}()
}
}
func (s *Server) Close() error {
s.mu.Lock()
tlsListeners := s.tlsListeners
s.tlsListeners = nil
conns := s.conns
s.conns = nil
s.mu.Unlock()
for l := range tlsListeners {
_ = l.Close()
}
for c := range conns {
_ = c.Close()
}
s.wg.Wait()
return nil
}
func (s *Server) handleTLSConnection(nc net.Conn) error {
handshakeDeadline := time.Now().Add(30 * time.Second)
handshakeCtx, cancel := context.WithDeadline(context.Background(), handshakeDeadline)
defer cancel()
cert, err := s.getCertificate(handshakeCtx)
if err != nil {
_ = nc.Close()
return trace.Wrap(err)
}
pool, err := s.getPool(handshakeCtx)
if err != nil {
_ = nc.Close()
return trace.Wrap(err)
}
tlsConfig := &tls.Config{
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return cert, nil
},
VerifyConnection: func(cs tls.ConnectionState) error {
if cs.NegotiatedProtocol == "" {
return trace.NotImplemented("relay peering protocol not supported")
}
if len(cs.VerifiedChains) < 1 {
return trace.AccessDenied("missing or invalid client certificate")
}
id, err := tlsca.FromSubject(cs.VerifiedChains[0][0].Subject, cs.VerifiedChains[0][0].NotAfter)
if err != nil {
return trace.Wrap(err)
}
if !slices.Contains(id.Groups, string(types.RoleRelay)) &&
!slices.Contains(id.SystemRoles, string(types.RoleRelay)) {
return trace.BadParameter("client is not a relay (roles %+q, system roles %+q)", id.Groups, id.SystemRoles)
}
return nil
},
NextProtos: []string{simpleALPN},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: pool,
InsecureSkipVerify: false,
MinVersion: tls.VersionTLS12,
CipherSuites: s.ciphersuites,
SessionTicketsDisabled: true,
}
tc := tls.Server(nc, tlsConfig)
defer tc.Close()
if err := tc.HandshakeContext(handshakeCtx); err != nil {
return trace.Wrap(err)
}
// the only possible negotiated protocol is here is the only supported one,
// "teleport-relaypeer"
tc.SetDeadline(handshakeDeadline)
req := new(relaypeeringv1alpha.DialRequest)
if err := readProto(tc, req); err != nil {
return trace.Wrap(err)
}
lc, err := s.localDial(
handshakeCtx,
req.GetTargetHostId(),
types.TunnelType(req.GetConnectionType()),
addrFromProto(req.GetSource()),
addrFromProto(req.GetDestination()),
)
if err != nil {
_ = writeProto(tc, &relaypeeringv1alpha.DialResponse{
Status: status.Convert(trail.ToGRPC(err)).Proto(),
})
return trace.Wrap(err)
}
defer lc.Close()
if err := writeProto(tc, &relaypeeringv1alpha.DialResponse{
Status: nil, // i.e. status.Convert(error(nil)).Proto()
}); err != nil {
return trace.Wrap(err)
}
tc.SetDeadline(time.Time{})
return utils.ProxyConn(context.Background(), lc, tc)
}
+1
View File
@@ -48,6 +48,7 @@ var (
ListenerProxyPeer = ListenerType(teleport.Component(teleport.ComponentProxy, "peer"))
ListenerMetrics = ListenerType(teleport.ComponentMetrics)
ListenerWindowsDesktop = ListenerType(teleport.ComponentWindowsDesktop)
ListenerRelayPeer = ListenerType(teleport.Component(teleport.ComponentRelay, "peer"))
ListenerRelayTunnel = ListenerType(teleport.Component(teleport.ComponentRelay, "tunnel"))
)
+56
View File
@@ -21,6 +21,8 @@ import (
"crypto/tls"
"crypto/x509"
"log/slog"
"net"
"net/netip"
"sync/atomic"
"github.com/google/uuid"
@@ -38,8 +40,10 @@ import (
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/relaypeer"
"github.com/gravitational/teleport/lib/relaytunnel"
"github.com/gravitational/teleport/lib/srv"
"github.com/gravitational/teleport/lib/utils"
)
func (process *TeleportProcess) initRelay() {
@@ -110,6 +114,28 @@ func (process *TeleportProcess) runRelayService() error {
TargetConnectionCount: process.Config.Relay.TargetConnectionCount,
})
peerServer, err := relaypeer.NewServer(relaypeer.ServerConfig{
Log: sublogger("peer_server"),
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,
LocalDial: tunnelServer.Dial,
})
if err != nil {
return trace.Wrap(err)
}
defer peerServer.Close()
tunnelListener, err := process.importOrCreateListener(ListenerRelayTunnel, process.Config.Relay.TunnelListenAddr.String())
if err != nil {
return trace.Wrap(err)
@@ -117,6 +143,29 @@ func (process *TeleportProcess) runRelayService() error {
defer tunnelListener.Close()
go tunnelGRPCServer.Serve(tunnelListener)
peerListener, err := process.importOrCreateListener(ListenerRelayPeer, process.Config.Relay.PeerListenAddr.String())
if err != nil {
return trace.Wrap(err)
}
defer peerListener.Close()
go peerServer.ServeTLSListener(peerListener)
peerPublicAddr := process.Config.Relay.PeerPublicAddr
if peerPublicAddr == "" {
peerListenerPort := process.Config.Relay.PeerListenAddr.Port()
if a, _ := peerListener.Addr().(*net.TCPAddr); a != nil {
// handle the case where the server was configured to bind on port 0
peerListenerPort = uint16(a.Port)
}
hostIP, err := utils.GuessHostIP()
if err != nil {
return trace.Wrap(err)
}
hostNetIP, _ := netip.AddrFromSlice(hostIP)
peerPublicAddr = netip.AddrPortFrom(hostNetIP.Unmap(), peerListenerPort).String()
}
nonce := uuid.NewString()
var relayServer atomic.Pointer[presencev1.RelayServer]
relayServer.Store(&presencev1.RelayServer{
@@ -129,6 +178,7 @@ func (process *TeleportProcess) runRelayService() error {
Spec: &presencev1.RelayServer_Spec{
Hostname: process.Config.Hostname,
RelayGroup: process.Config.Relay.RelayGroup,
PeerAddr: peerPublicAddr,
Nonce: nonce,
},
})
@@ -196,6 +246,12 @@ func (process *TeleportProcess) runRelayService() error {
_ = tunnelServer.Close()
return nil
})
eg.Go(func() error {
// TODO(espadolini): let connections continue (for a time?) before
// abruptly terminating them right after the shutdown delay
_ = peerServer.Close()
return nil
})
warnOnErr(egCtx, eg.Wait(), log)
warnOnErr(ctx, hb.Close(), log)
+4
View File
@@ -44,6 +44,10 @@ type RelayConfig struct {
// PeerListenAddr is the listen address for the peer listener.
PeerListenAddr netip.AddrPort
// PeerPublicAddr, if set, is the public address for the peer listener, in
// host:port format.
PeerPublicAddr string
// TunnelListenAddr is the listen address for the tunnel listener.
TunnelListenAddr netip.AddrPort
@@ -0,0 +1,51 @@
// Teleport
// Copyright (C) 2024 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/>.
syntax = "proto3";
package teleport.relaypeering.v1alpha;
import "google/rpc/status.proto";
option go_package = "github.com/gravitational/teleport/gen/proto/go/teleport/relaypeering/v1alpha;relaypeeringv1alpha";
// the message sent by the client at the beginning of a connection
message DialRequest {
// the target agent for the connection attempt; should be "<host id>.<cluster name>"
string target_host_id = 1;
// the type of the connection as defined by api/types.TunnelType ("node",
// "app", "kube"...)
string connection_type = 2;
Addr source = 3;
Addr destination = 4;
}
// the message sent by the server as a response to a DialRequest; in case of
// success (status is missing or reports "ok") the connection data follows on
// the same stream, otherwise the stream ends and any data sent by the client is
// to be treated as silently discarded
message DialResponse {
google.rpc.Status status = 1;
}
// a stringy Go net.Addr, usually converted to and from lib/utils.NetAddr
message Addr {
// likely always "tcp"
string network = 1;
// depending on the network, likely "<ip address>:<port>"
string addr = 2;
}