From 3e8233ea5374c9f6ac4bc51167408c47c313a2bc Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Fri, 30 May 2025 18:55:19 -0700 Subject: [PATCH 01/25] [v18][vnet] feat: TCP dial to SSH targets Backport #55087 to branch/v18 --- .../vnet/v1/client_application_service.pb.go | 483 ++++++++++++++---- .../v1/client_application_service_grpc.pb.go | 80 +++ lib/teleterm/vnet/service.go | 45 +- lib/vnet/admin_process_common.go | 2 + lib/vnet/app_handler.go | 12 +- lib/vnet/app_provider.go | 62 +-- lib/vnet/client_application_service.go | 86 +++- lib/vnet/client_application_service_client.go | 54 ++ lib/vnet/fqdn_resolver.go | 5 + lib/vnet/ssh_handler.go | 73 +++ lib/vnet/ssh_provider.go | 182 +++++++ lib/vnet/tcp_handler_resolver.go | 65 ++- lib/vnet/user_process.go | 3 + lib/vnet/vnet_test.go | 133 ++++- .../vnet/v1/client_application_service.proto | 61 ++- tool/tsh/common/vnet_client_application.go | 24 +- 16 files changed, 1157 insertions(+), 213 deletions(-) create mode 100644 lib/vnet/ssh_handler.go create mode 100644 lib/vnet/ssh_provider.go diff --git a/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go index 685a31f2b4358..1869b69ce9159 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go @@ -649,7 +649,15 @@ type MatchedCluster struct { Ipv4CidrRange string `protobuf:"bytes,1,opt,name=ipv4_cidr_range,json=ipv4CidrRange,proto3" json:"ipv4_cidr_range,omitempty"` // WebProxyAddr is the web proxy address of the root cluster that matched the // query. - WebProxyAddr string `protobuf:"bytes,2,opt,name=web_proxy_addr,json=webProxyAddr,proto3" json:"web_proxy_addr,omitempty"` + WebProxyAddr string `protobuf:"bytes,2,opt,name=web_proxy_addr,json=webProxyAddr,proto3" json:"web_proxy_addr,omitempty"` + // Profile is the profile the matched cluster was found in. + Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"` + // RootCluster will always be set to the name of the root cluster that matched + // the query. + RootCluster string `protobuf:"bytes,4,opt,name=root_cluster,json=rootCluster,proto3" json:"root_cluster,omitempty"` + // LeafCluster will be set only when the query matched a leaf cluster of + // RootCluster, or else it will be empty. + LeafCluster string `protobuf:"bytes,5,opt,name=leaf_cluster,json=leafCluster,proto3" json:"leaf_cluster,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -698,6 +706,27 @@ func (x *MatchedCluster) GetWebProxyAddr() string { return "" } +func (x *MatchedCluster) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *MatchedCluster) GetRootCluster() string { + if x != nil { + return x.RootCluster + } + return "" +} + +func (x *MatchedCluster) GetLeafCluster() string { + if x != nil { + return x.LeafCluster + } + return "" +} + // AppInfo holds all necessary info for making connections to VNet TCP apps. type AppInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -860,8 +889,10 @@ type DialOptions struct { Sni string `protobuf:"bytes,3,opt,name=sni,proto3" json:"sni,omitempty"` // InsecureSkipVerify turns off verification for x509 upstream ALPN proxy service certificate. InsecureSkipVerify bool `protobuf:"varint,4,opt,name=insecure_skip_verify,json=insecureSkipVerify,proto3" json:"insecure_skip_verify,omitempty"` - // RootClusterCaCertPool overrides the x509 certificate pool used to verify the server. - // It is a PEM-encoded X509 certificate pool. + // RootClusterCaCertPool is the host CA TLS certificate pool for the root + // cluster. It is a PEM-encoded X509 certificate pool. It should be used when + // dialing the proxy and AlpnConnUpgradeRequired is true or when dialing the + // transport service. RootClusterCaCertPool []byte `protobuf:"bytes,5,opt,name=root_cluster_ca_cert_pool,json=rootClusterCaCertPool,proto3" json:"root_cluster_ca_cert_pool,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1048,13 +1079,8 @@ type SignForAppRequest struct { // TargetPort of a previous successful call to ReissueAppCert for an app // matching AppKey. TargetPort uint32 `protobuf:"varint,2,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` - // Digest is the bytes to sign. - Digest []byte `protobuf:"bytes,3,opt,name=digest,proto3" json:"digest,omitempty"` - // Hash is the hash function used to compute digest. - Hash Hash `protobuf:"varint,4,opt,name=hash,proto3,enum=teleport.lib.vnet.v1.Hash" json:"hash,omitempty"` - // PssSaltLength specifies the length of the salt added to the digest before a - // signature. Only used and required for RSA PSS signatures. - PssSaltLength *int32 `protobuf:"varint,5,opt,name=pss_salt_length,json=pssSaltLength,proto3,oneof" json:"pss_salt_length,omitempty"` + // Sign holds signature request details. + Sign *SignRequest `protobuf:"bytes,6,opt,name=sign,proto3" json:"sign,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1103,21 +1129,72 @@ func (x *SignForAppRequest) GetTargetPort() uint32 { return 0 } -func (x *SignForAppRequest) GetDigest() []byte { +func (x *SignForAppRequest) GetSign() *SignRequest { + if x != nil { + return x.Sign + } + return nil +} + +// SignRequest holds signature request details. +type SignRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Digest is the bytes to sign. + Digest []byte `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"` + // Hash is the hash function used to compute digest. + Hash Hash `protobuf:"varint,2,opt,name=hash,proto3,enum=teleport.lib.vnet.v1.Hash" json:"hash,omitempty"` + // PssSaltLength specifies the length of the salt added to the digest before a + // signature. Only used and required for RSA PSS signatures. + PssSaltLength *int32 `protobuf:"varint,3,opt,name=pss_salt_length,json=pssSaltLength,proto3,oneof" json:"pss_salt_length,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignRequest) Reset() { + *x = SignRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignRequest) ProtoMessage() {} + +func (x *SignRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + 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 SignRequest.ProtoReflect.Descriptor instead. +func (*SignRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{18} +} + +func (x *SignRequest) GetDigest() []byte { if x != nil { return x.Digest } return nil } -func (x *SignForAppRequest) GetHash() Hash { +func (x *SignRequest) GetHash() Hash { if x != nil { return x.Hash } return Hash_HASH_UNSPECIFIED } -func (x *SignForAppRequest) GetPssSaltLength() int32 { +func (x *SignRequest) GetPssSaltLength() int32 { if x != nil && x.PssSaltLength != nil { return *x.PssSaltLength } @@ -1135,7 +1212,7 @@ type SignForAppResponse struct { func (x *SignForAppResponse) Reset() { *x = SignForAppResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1147,7 +1224,7 @@ func (x *SignForAppResponse) String() string { func (*SignForAppResponse) ProtoMessage() {} func (x *SignForAppResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1160,7 +1237,7 @@ func (x *SignForAppResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SignForAppResponse.ProtoReflect.Descriptor instead. func (*SignForAppResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{18} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{19} } func (x *SignForAppResponse) GetSignature() []byte { @@ -1181,7 +1258,7 @@ type OnNewConnectionRequest struct { func (x *OnNewConnectionRequest) Reset() { *x = OnNewConnectionRequest{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1193,7 +1270,7 @@ func (x *OnNewConnectionRequest) String() string { func (*OnNewConnectionRequest) ProtoMessage() {} func (x *OnNewConnectionRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1206,7 +1283,7 @@ func (x *OnNewConnectionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use OnNewConnectionRequest.ProtoReflect.Descriptor instead. func (*OnNewConnectionRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{19} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{20} } func (x *OnNewConnectionRequest) GetAppKey() *AppKey { @@ -1225,7 +1302,7 @@ type OnNewConnectionResponse struct { func (x *OnNewConnectionResponse) Reset() { *x = OnNewConnectionResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1237,7 +1314,7 @@ func (x *OnNewConnectionResponse) String() string { func (*OnNewConnectionResponse) ProtoMessage() {} func (x *OnNewConnectionResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1250,7 +1327,7 @@ func (x *OnNewConnectionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use OnNewConnectionResponse.ProtoReflect.Descriptor instead. func (*OnNewConnectionResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{20} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{21} } // OnInvalidLocalPortRequest is a request for OnInvalidLocalPort. @@ -1269,7 +1346,7 @@ type OnInvalidLocalPortRequest struct { func (x *OnInvalidLocalPortRequest) Reset() { *x = OnInvalidLocalPortRequest{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1281,7 +1358,7 @@ func (x *OnInvalidLocalPortRequest) String() string { func (*OnInvalidLocalPortRequest) ProtoMessage() {} func (x *OnInvalidLocalPortRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1294,7 +1371,7 @@ func (x *OnInvalidLocalPortRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use OnInvalidLocalPortRequest.ProtoReflect.Descriptor instead. func (*OnInvalidLocalPortRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{21} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{22} } func (x *OnInvalidLocalPortRequest) GetAppInfo() *AppInfo { @@ -1320,7 +1397,7 @@ type OnInvalidLocalPortResponse struct { func (x *OnInvalidLocalPortResponse) Reset() { *x = OnInvalidLocalPortResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1332,7 +1409,7 @@ func (x *OnInvalidLocalPortResponse) String() string { func (*OnInvalidLocalPortResponse) ProtoMessage() {} func (x *OnInvalidLocalPortResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1345,7 +1422,7 @@ func (x *OnInvalidLocalPortResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use OnInvalidLocalPortResponse.ProtoReflect.Descriptor instead. func (*OnInvalidLocalPortResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{22} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{23} } // GetTargetOSConfigurationRequest is a request for the target host OS configuration. @@ -1357,7 +1434,7 @@ type GetTargetOSConfigurationRequest struct { func (x *GetTargetOSConfigurationRequest) Reset() { *x = GetTargetOSConfigurationRequest{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1369,7 +1446,7 @@ func (x *GetTargetOSConfigurationRequest) String() string { func (*GetTargetOSConfigurationRequest) ProtoMessage() {} func (x *GetTargetOSConfigurationRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1382,7 +1459,7 @@ func (x *GetTargetOSConfigurationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTargetOSConfigurationRequest.ProtoReflect.Descriptor instead. func (*GetTargetOSConfigurationRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{23} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{24} } // GetTargetOSConfigurationResponse is a response including the target host OS configuration. @@ -1396,7 +1473,7 @@ type GetTargetOSConfigurationResponse struct { func (x *GetTargetOSConfigurationResponse) Reset() { *x = GetTargetOSConfigurationResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1408,7 +1485,7 @@ func (x *GetTargetOSConfigurationResponse) String() string { func (*GetTargetOSConfigurationResponse) ProtoMessage() {} func (x *GetTargetOSConfigurationResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1421,7 +1498,7 @@ func (x *GetTargetOSConfigurationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTargetOSConfigurationResponse.ProtoReflect.Descriptor instead. func (*GetTargetOSConfigurationResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{24} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{25} } func (x *GetTargetOSConfigurationResponse) GetTargetOsConfiguration() *TargetOSConfiguration { @@ -1451,7 +1528,7 @@ type TargetOSConfiguration struct { func (x *TargetOSConfiguration) Reset() { *x = TargetOSConfiguration{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1463,7 +1540,7 @@ func (x *TargetOSConfiguration) String() string { func (*TargetOSConfiguration) ProtoMessage() {} func (x *TargetOSConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1476,7 +1553,7 @@ func (x *TargetOSConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use TargetOSConfiguration.ProtoReflect.Descriptor instead. func (*TargetOSConfiguration) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{25} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{26} } func (x *TargetOSConfiguration) GetDnsZones() []string { @@ -1493,6 +1570,209 @@ func (x *TargetOSConfiguration) GetIpv4CidrRanges() []string { return nil } +// UserTLSCertRequest is a request for UserTLSCert. +type UserTLSCertRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Profile is the profile to retrieve the certificate for. + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserTLSCertRequest) Reset() { + *x = UserTLSCertRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserTLSCertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserTLSCertRequest) ProtoMessage() {} + +func (x *UserTLSCertRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[27] + 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 UserTLSCertRequest.ProtoReflect.Descriptor instead. +func (*UserTLSCertRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{27} +} + +func (x *UserTLSCertRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +// UserTLSCertResponse is a response for UserTLSCert. +type UserTLSCertResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Cert is the user TLS certificate in X.509 ASN.1 DER format. + Cert []byte `protobuf:"bytes,1,opt,name=cert,proto3" json:"cert,omitempty"` + // DialOptions holds options that should be used when dialing the root cluster + // proxy. + DialOptions *DialOptions `protobuf:"bytes,2,opt,name=dial_options,json=dialOptions,proto3" json:"dial_options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserTLSCertResponse) Reset() { + *x = UserTLSCertResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserTLSCertResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserTLSCertResponse) ProtoMessage() {} + +func (x *UserTLSCertResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[28] + 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 UserTLSCertResponse.ProtoReflect.Descriptor instead. +func (*UserTLSCertResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{28} +} + +func (x *UserTLSCertResponse) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +func (x *UserTLSCertResponse) GetDialOptions() *DialOptions { + if x != nil { + return x.DialOptions + } + return nil +} + +// SignForUserTLSRequest is a request for SignForUserTLS. +type SignForUserTLSRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Profile is the user profile to sign for. + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // Sign holds signature request details. + Sign *SignRequest `protobuf:"bytes,2,opt,name=sign,proto3" json:"sign,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForUserTLSRequest) Reset() { + *x = SignForUserTLSRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForUserTLSRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForUserTLSRequest) ProtoMessage() {} + +func (x *SignForUserTLSRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[29] + 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 SignForUserTLSRequest.ProtoReflect.Descriptor instead. +func (*SignForUserTLSRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{29} +} + +func (x *SignForUserTLSRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SignForUserTLSRequest) GetSign() *SignRequest { + if x != nil { + return x.Sign + } + return nil +} + +// SignForUserTLSResponse is a response for SignForUserTLS. +type SignForUserTLSResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Signature is the signature. + Signature []byte `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForUserTLSResponse) Reset() { + *x = SignForUserTLSResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForUserTLSResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForUserTLSResponse) ProtoMessage() {} + +func (x *SignForUserTLSResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[30] + 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 SignForUserTLSResponse.ProtoReflect.Descriptor instead. +func (*SignForUserTLSResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{30} +} + +func (x *SignForUserTLSResponse) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + var File_teleport_lib_vnet_v1_client_application_service_proto protoreflect.FileDescriptor const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + @@ -1521,10 +1801,13 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\x05match\"I\n" + "\rMatchedTCPApp\x128\n" + "\bapp_info\x18\x01 \x01(\v2\x1d.teleport.lib.vnet.v1.AppInfoR\aappInfo\"\x0f\n" + - "\rMatchedWebApp\"^\n" + + "\rMatchedWebApp\"\xbe\x01\n" + "\x0eMatchedCluster\x12&\n" + "\x0fipv4_cidr_range\x18\x01 \x01(\tR\ripv4CidrRange\x12$\n" + - "\x0eweb_proxy_addr\x18\x02 \x01(\tR\fwebProxyAddr\"\xe8\x01\n" + + "\x0eweb_proxy_addr\x18\x02 \x01(\tR\fwebProxyAddr\x12\x18\n" + + "\aprofile\x18\x03 \x01(\tR\aprofile\x12!\n" + + "\froot_cluster\x18\x04 \x01(\tR\vrootCluster\x12!\n" + + "\fleaf_cluster\x18\x05 \x01(\tR\vleafCluster\"\xe8\x01\n" + "\aAppInfo\x125\n" + "\aapp_key\x18\x01 \x01(\v2\x1c.teleport.lib.vnet.v1.AppKeyR\x06appKey\x12\x18\n" + "\acluster\x18\x02 \x01(\tR\acluster\x12\x1e\n" + @@ -1546,14 +1829,16 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\vtarget_port\x18\x02 \x01(\rR\n" + "targetPort\",\n" + "\x16ReissueAppCertResponse\x12\x12\n" + - "\x04cert\x18\x01 \x01(\fR\x04cert\"\xf4\x01\n" + + "\x04cert\x18\x01 \x01(\fR\x04cert\"\xd3\x01\n" + "\x11SignForAppRequest\x125\n" + "\aapp_key\x18\x01 \x01(\v2\x1c.teleport.lib.vnet.v1.AppKeyR\x06appKey\x12\x1f\n" + "\vtarget_port\x18\x02 \x01(\rR\n" + - "targetPort\x12\x16\n" + - "\x06digest\x18\x03 \x01(\fR\x06digest\x12.\n" + - "\x04hash\x18\x04 \x01(\x0e2\x1a.teleport.lib.vnet.v1.HashR\x04hash\x12+\n" + - "\x0fpss_salt_length\x18\x05 \x01(\x05H\x00R\rpssSaltLength\x88\x01\x01B\x12\n" + + "targetPort\x125\n" + + "\x04sign\x18\x06 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04signJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x06digestR\x04hashR\x0fpss_salt_length\"\x96\x01\n" + + "\vSignRequest\x12\x16\n" + + "\x06digest\x18\x01 \x01(\fR\x06digest\x12.\n" + + "\x04hash\x18\x02 \x01(\x0e2\x1a.teleport.lib.vnet.v1.HashR\x04hash\x12+\n" + + "\x0fpss_salt_length\x18\x03 \x01(\x05H\x00R\rpssSaltLength\x88\x01\x01B\x12\n" + "\x10_pss_salt_length\"2\n" + "\x12SignForAppResponse\x12\x1c\n" + "\tsignature\x18\x01 \x01(\fR\tsignature\"O\n" + @@ -1570,11 +1855,21 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\x17target_os_configuration\x18\x01 \x01(\v2+.teleport.lib.vnet.v1.TargetOSConfigurationR\x15targetOsConfiguration\"^\n" + "\x15TargetOSConfiguration\x12\x1b\n" + "\tdns_zones\x18\x01 \x03(\tR\bdnsZones\x12(\n" + - "\x10ipv4_cidr_ranges\x18\x02 \x03(\tR\x0eipv4CidrRanges*<\n" + + "\x10ipv4_cidr_ranges\x18\x02 \x03(\tR\x0eipv4CidrRanges\".\n" + + "\x12UserTLSCertRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\"o\n" + + "\x13UserTLSCertResponse\x12\x12\n" + + "\x04cert\x18\x01 \x01(\fR\x04cert\x12D\n" + + "\fdial_options\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.DialOptionsR\vdialOptions\"h\n" + + "\x15SignForUserTLSRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\x125\n" + + "\x04sign\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04sign\"6\n" + + "\x16SignForUserTLSResponse\x12\x1c\n" + + "\tsignature\x18\x01 \x01(\fR\tsignature*<\n" + "\x04Hash\x12\x14\n" + "\x10HASH_UNSPECIFIED\x10\x00\x12\r\n" + "\tHASH_NONE\x10\x01\x12\x0f\n" + - "\vHASH_SHA256\x10\x022\x92\b\n" + + "\vHASH_SHA256\x10\x022\xe3\t\n" + "\x18ClientApplicationService\x12z\n" + "\x13AuthenticateProcess\x120.teleport.lib.vnet.v1.AuthenticateProcessRequest\x1a1.teleport.lib.vnet.v1.AuthenticateProcessResponse\x12\x83\x01\n" + "\x16ReportNetworkStackInfo\x123.teleport.lib.vnet.v1.ReportNetworkStackInfoRequest\x1a4.teleport.lib.vnet.v1.ReportNetworkStackInfoResponse\x12M\n" + @@ -1585,7 +1880,9 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "SignForApp\x12'.teleport.lib.vnet.v1.SignForAppRequest\x1a(.teleport.lib.vnet.v1.SignForAppResponse\x12n\n" + "\x0fOnNewConnection\x12,.teleport.lib.vnet.v1.OnNewConnectionRequest\x1a-.teleport.lib.vnet.v1.OnNewConnectionResponse\x12w\n" + "\x12OnInvalidLocalPort\x12/.teleport.lib.vnet.v1.OnInvalidLocalPortRequest\x1a0.teleport.lib.vnet.v1.OnInvalidLocalPortResponse\x12\x89\x01\n" + - "\x18GetTargetOSConfiguration\x125.teleport.lib.vnet.v1.GetTargetOSConfigurationRequest\x1a6.teleport.lib.vnet.v1.GetTargetOSConfigurationResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" + "\x18GetTargetOSConfiguration\x125.teleport.lib.vnet.v1.GetTargetOSConfigurationRequest\x1a6.teleport.lib.vnet.v1.GetTargetOSConfigurationResponse\x12b\n" + + "\vUserTLSCert\x12(.teleport.lib.vnet.v1.UserTLSCertRequest\x1a).teleport.lib.vnet.v1.UserTLSCertResponse\x12k\n" + + "\x0eSignForUserTLS\x12+.teleport.lib.vnet.v1.SignForUserTLSRequest\x1a,.teleport.lib.vnet.v1.SignForUserTLSResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" var ( file_teleport_lib_vnet_v1_client_application_service_proto_rawDescOnce sync.Once @@ -1600,7 +1897,7 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP() [] } var file_teleport_lib_vnet_v1_client_application_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 31) var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (Hash)(0), // 0: teleport.lib.vnet.v1.Hash (*AuthenticateProcessRequest)(nil), // 1: teleport.lib.vnet.v1.AuthenticateProcessRequest @@ -1621,15 +1918,20 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (*ReissueAppCertRequest)(nil), // 16: teleport.lib.vnet.v1.ReissueAppCertRequest (*ReissueAppCertResponse)(nil), // 17: teleport.lib.vnet.v1.ReissueAppCertResponse (*SignForAppRequest)(nil), // 18: teleport.lib.vnet.v1.SignForAppRequest - (*SignForAppResponse)(nil), // 19: teleport.lib.vnet.v1.SignForAppResponse - (*OnNewConnectionRequest)(nil), // 20: teleport.lib.vnet.v1.OnNewConnectionRequest - (*OnNewConnectionResponse)(nil), // 21: teleport.lib.vnet.v1.OnNewConnectionResponse - (*OnInvalidLocalPortRequest)(nil), // 22: teleport.lib.vnet.v1.OnInvalidLocalPortRequest - (*OnInvalidLocalPortResponse)(nil), // 23: teleport.lib.vnet.v1.OnInvalidLocalPortResponse - (*GetTargetOSConfigurationRequest)(nil), // 24: teleport.lib.vnet.v1.GetTargetOSConfigurationRequest - (*GetTargetOSConfigurationResponse)(nil), // 25: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse - (*TargetOSConfiguration)(nil), // 26: teleport.lib.vnet.v1.TargetOSConfiguration - (*types.AppV3)(nil), // 27: types.AppV3 + (*SignRequest)(nil), // 19: teleport.lib.vnet.v1.SignRequest + (*SignForAppResponse)(nil), // 20: teleport.lib.vnet.v1.SignForAppResponse + (*OnNewConnectionRequest)(nil), // 21: teleport.lib.vnet.v1.OnNewConnectionRequest + (*OnNewConnectionResponse)(nil), // 22: teleport.lib.vnet.v1.OnNewConnectionResponse + (*OnInvalidLocalPortRequest)(nil), // 23: teleport.lib.vnet.v1.OnInvalidLocalPortRequest + (*OnInvalidLocalPortResponse)(nil), // 24: teleport.lib.vnet.v1.OnInvalidLocalPortResponse + (*GetTargetOSConfigurationRequest)(nil), // 25: teleport.lib.vnet.v1.GetTargetOSConfigurationRequest + (*GetTargetOSConfigurationResponse)(nil), // 26: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse + (*TargetOSConfiguration)(nil), // 27: teleport.lib.vnet.v1.TargetOSConfiguration + (*UserTLSCertRequest)(nil), // 28: teleport.lib.vnet.v1.UserTLSCertRequest + (*UserTLSCertResponse)(nil), // 29: teleport.lib.vnet.v1.UserTLSCertResponse + (*SignForUserTLSRequest)(nil), // 30: teleport.lib.vnet.v1.SignForUserTLSRequest + (*SignForUserTLSResponse)(nil), // 31: teleport.lib.vnet.v1.SignForUserTLSResponse + (*types.AppV3)(nil), // 32: types.AppV3 } var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32{ 4, // 0: teleport.lib.vnet.v1.ReportNetworkStackInfoRequest.network_stack_info:type_name -> teleport.lib.vnet.v1.NetworkStackInfo @@ -1638,37 +1940,44 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32 12, // 3: teleport.lib.vnet.v1.ResolveFQDNResponse.matched_cluster:type_name -> teleport.lib.vnet.v1.MatchedCluster 13, // 4: teleport.lib.vnet.v1.MatchedTCPApp.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 5: teleport.lib.vnet.v1.AppInfo.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 27, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 + 32, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 15, // 7: teleport.lib.vnet.v1.AppInfo.dial_options:type_name -> teleport.lib.vnet.v1.DialOptions 13, // 8: teleport.lib.vnet.v1.ReissueAppCertRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 9: teleport.lib.vnet.v1.SignForAppRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 0, // 10: teleport.lib.vnet.v1.SignForAppRequest.hash:type_name -> teleport.lib.vnet.v1.Hash - 14, // 11: teleport.lib.vnet.v1.OnNewConnectionRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 13, // 12: teleport.lib.vnet.v1.OnInvalidLocalPortRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo - 26, // 13: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse.target_os_configuration:type_name -> teleport.lib.vnet.v1.TargetOSConfiguration - 1, // 14: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:input_type -> teleport.lib.vnet.v1.AuthenticateProcessRequest - 3, // 15: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:input_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoRequest - 6, // 16: teleport.lib.vnet.v1.ClientApplicationService.Ping:input_type -> teleport.lib.vnet.v1.PingRequest - 8, // 17: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:input_type -> teleport.lib.vnet.v1.ResolveFQDNRequest - 16, // 18: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:input_type -> teleport.lib.vnet.v1.ReissueAppCertRequest - 18, // 19: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:input_type -> teleport.lib.vnet.v1.SignForAppRequest - 20, // 20: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:input_type -> teleport.lib.vnet.v1.OnNewConnectionRequest - 22, // 21: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:input_type -> teleport.lib.vnet.v1.OnInvalidLocalPortRequest - 24, // 22: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:input_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationRequest - 2, // 23: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse - 5, // 24: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse - 7, // 25: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse - 9, // 26: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse - 17, // 27: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse - 19, // 28: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse - 21, // 29: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:output_type -> teleport.lib.vnet.v1.OnNewConnectionResponse - 23, // 30: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse - 25, // 31: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse - 23, // [23:32] is the sub-list for method output_type - 14, // [14:23] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 19, // 10: teleport.lib.vnet.v1.SignForAppRequest.sign:type_name -> teleport.lib.vnet.v1.SignRequest + 0, // 11: teleport.lib.vnet.v1.SignRequest.hash:type_name -> teleport.lib.vnet.v1.Hash + 14, // 12: teleport.lib.vnet.v1.OnNewConnectionRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey + 13, // 13: teleport.lib.vnet.v1.OnInvalidLocalPortRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo + 27, // 14: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse.target_os_configuration:type_name -> teleport.lib.vnet.v1.TargetOSConfiguration + 15, // 15: teleport.lib.vnet.v1.UserTLSCertResponse.dial_options:type_name -> teleport.lib.vnet.v1.DialOptions + 19, // 16: teleport.lib.vnet.v1.SignForUserTLSRequest.sign:type_name -> teleport.lib.vnet.v1.SignRequest + 1, // 17: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:input_type -> teleport.lib.vnet.v1.AuthenticateProcessRequest + 3, // 18: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:input_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoRequest + 6, // 19: teleport.lib.vnet.v1.ClientApplicationService.Ping:input_type -> teleport.lib.vnet.v1.PingRequest + 8, // 20: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:input_type -> teleport.lib.vnet.v1.ResolveFQDNRequest + 16, // 21: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:input_type -> teleport.lib.vnet.v1.ReissueAppCertRequest + 18, // 22: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:input_type -> teleport.lib.vnet.v1.SignForAppRequest + 21, // 23: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:input_type -> teleport.lib.vnet.v1.OnNewConnectionRequest + 23, // 24: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:input_type -> teleport.lib.vnet.v1.OnInvalidLocalPortRequest + 25, // 25: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:input_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationRequest + 28, // 26: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:input_type -> teleport.lib.vnet.v1.UserTLSCertRequest + 30, // 27: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:input_type -> teleport.lib.vnet.v1.SignForUserTLSRequest + 2, // 28: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse + 5, // 29: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse + 7, // 30: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse + 9, // 31: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse + 17, // 32: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse + 20, // 33: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse + 22, // 34: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:output_type -> teleport.lib.vnet.v1.OnNewConnectionResponse + 24, // 35: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse + 26, // 36: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse + 29, // 37: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:output_type -> teleport.lib.vnet.v1.UserTLSCertResponse + 31, // 38: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:output_type -> teleport.lib.vnet.v1.SignForUserTLSResponse + 28, // [28:39] is the sub-list for method output_type + 17, // [17:28] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name } func init() { file_teleport_lib_vnet_v1_client_application_service_proto_init() } @@ -1681,14 +1990,14 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_init() { (*ResolveFQDNResponse_MatchedWebApp)(nil), (*ResolveFQDNResponse_MatchedCluster)(nil), } - file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[17].OneofWrappers = []any{} + file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc), len(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 26, + NumMessages: 31, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go index 57af12d216082..8af306472da9f 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go @@ -44,6 +44,8 @@ const ( ClientApplicationService_OnNewConnection_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/OnNewConnection" ClientApplicationService_OnInvalidLocalPort_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/OnInvalidLocalPort" ClientApplicationService_GetTargetOSConfiguration_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/GetTargetOSConfiguration" + ClientApplicationService_UserTLSCert_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/UserTLSCert" + ClientApplicationService_SignForUserTLS_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForUserTLS" ) // ClientApplicationServiceClient is the client API for ClientApplicationService service. @@ -81,6 +83,10 @@ type ClientApplicationServiceClient interface { OnInvalidLocalPort(ctx context.Context, in *OnInvalidLocalPortRequest, opts ...grpc.CallOption) (*OnInvalidLocalPortResponse, error) // GetTargetOSConfiguration gets the target OS configuration. GetTargetOSConfiguration(ctx context.Context, in *GetTargetOSConfigurationRequest, opts ...grpc.CallOption) (*GetTargetOSConfigurationResponse, error) + // UserTLSCert returns the user TLS certificate for a specific profile. + UserTLSCert(ctx context.Context, in *UserTLSCertRequest, opts ...grpc.CallOption) (*UserTLSCertResponse, error) + // SignForUserTLS signs a digest with the user TLS private key. + SignForUserTLS(ctx context.Context, in *SignForUserTLSRequest, opts ...grpc.CallOption) (*SignForUserTLSResponse, error) } type clientApplicationServiceClient struct { @@ -181,6 +187,26 @@ func (c *clientApplicationServiceClient) GetTargetOSConfiguration(ctx context.Co return out, nil } +func (c *clientApplicationServiceClient) UserTLSCert(ctx context.Context, in *UserTLSCertRequest, opts ...grpc.CallOption) (*UserTLSCertResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UserTLSCertResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_UserTLSCert_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *clientApplicationServiceClient) SignForUserTLS(ctx context.Context, in *SignForUserTLSRequest, opts ...grpc.CallOption) (*SignForUserTLSResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SignForUserTLSResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_SignForUserTLS_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ClientApplicationServiceServer is the server API for ClientApplicationService service. // All implementations must embed UnimplementedClientApplicationServiceServer // for forward compatibility. @@ -216,6 +242,10 @@ type ClientApplicationServiceServer interface { OnInvalidLocalPort(context.Context, *OnInvalidLocalPortRequest) (*OnInvalidLocalPortResponse, error) // GetTargetOSConfiguration gets the target OS configuration. GetTargetOSConfiguration(context.Context, *GetTargetOSConfigurationRequest) (*GetTargetOSConfigurationResponse, error) + // UserTLSCert returns the user TLS certificate for a specific profile. + UserTLSCert(context.Context, *UserTLSCertRequest) (*UserTLSCertResponse, error) + // SignForUserTLS signs a digest with the user TLS private key. + SignForUserTLS(context.Context, *SignForUserTLSRequest) (*SignForUserTLSResponse, error) mustEmbedUnimplementedClientApplicationServiceServer() } @@ -253,6 +283,12 @@ func (UnimplementedClientApplicationServiceServer) OnInvalidLocalPort(context.Co func (UnimplementedClientApplicationServiceServer) GetTargetOSConfiguration(context.Context, *GetTargetOSConfigurationRequest) (*GetTargetOSConfigurationResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetTargetOSConfiguration not implemented") } +func (UnimplementedClientApplicationServiceServer) UserTLSCert(context.Context, *UserTLSCertRequest) (*UserTLSCertResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UserTLSCert not implemented") +} +func (UnimplementedClientApplicationServiceServer) SignForUserTLS(context.Context, *SignForUserTLSRequest) (*SignForUserTLSResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SignForUserTLS not implemented") +} func (UnimplementedClientApplicationServiceServer) mustEmbedUnimplementedClientApplicationServiceServer() { } func (UnimplementedClientApplicationServiceServer) testEmbeddedByValue() {} @@ -437,6 +473,42 @@ func _ClientApplicationService_GetTargetOSConfiguration_Handler(srv interface{}, return interceptor(ctx, in, info, handler) } +func _ClientApplicationService_UserTLSCert_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserTLSCertRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).UserTLSCert(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_UserTLSCert_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).UserTLSCert(ctx, req.(*UserTLSCertRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClientApplicationService_SignForUserTLS_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SignForUserTLSRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).SignForUserTLS(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_SignForUserTLS_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).SignForUserTLS(ctx, req.(*SignForUserTLSRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ClientApplicationService_ServiceDesc is the grpc.ServiceDesc for ClientApplicationService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -480,6 +552,14 @@ var ClientApplicationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetTargetOSConfiguration", Handler: _ClientApplicationService_GetTargetOSConfiguration_Handler, }, + { + MethodName: "UserTLSCert", + Handler: _ClientApplicationService_UserTLSCert_Handler, + }, + { + MethodName: "SignForUserTLS", + Handler: _ClientApplicationService_SignForUserTLS_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "teleport/lib/vnet/v1/client_application_service.proto", diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index 0233a2c6da798..cef2749ee8ab4 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -390,6 +390,43 @@ func (p *clientApplication) ReissueAppCert(ctx context.Context, appInfo *vnetv1. return cert, nil } +// UserTLSCert returns the user TLS certificate for the given profile. +func (p *clientApplication) UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) { + // We don't have easy access to the user TLS cert from here, the only way + // I've found is to reach through the ProxyClient as this does below. + clusterClient, err := p.getCachedClient(ctx, profileName, "") + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + clientConfig, err := clusterClient.ProxyClient.ClientConfig(ctx, "") + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "getting user client config") + } + if len(clientConfig.Credentials) < 1 { + return tls.Certificate{}, trace.Errorf("user client config has no credentials") + } + cred := clientConfig.Credentials[0] + tlsConfig, err := cred.TLSConfig() + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "getting user TLS config") + } + switch { + case len(tlsConfig.Certificates) > 0: + return tlsConfig.Certificates[0], nil + case tlsConfig.GetClientCertificate != nil: + // This is the actual path we currently take at the time of writing, + // api/client.configureTLS always sets tlsConfig.GetClientCertificate + // and unsets tlsConfig.Certificates. + tlsCert, err := tlsConfig.GetClientCertificate(nil) + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "getting client TLS certificate") + } + return *tlsCert, nil + default: + return tls.Certificate{}, trace.Errorf("user TLS config has no certificates") + } +} + // GetDialOptions returns ALPN dial options for the profile. func (p *clientApplication) GetDialOptions(ctx context.Context, profileName string) (*vnetv1.DialOptions, error) { cluster, tc, err := p.daemonService.ResolveClusterURI(uri.NewClusterURI(profileName)) @@ -402,11 +439,9 @@ func (p *clientApplication) GetDialOptions(ctx context.Context, profileName stri AlpnConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired, InsecureSkipVerify: p.insecureSkipVerify, } - if dialOpts.AlpnConnUpgradeRequired { - dialOpts.RootClusterCaCertPool, err = tc.RootClusterCACertPoolPEM(ctx) - if err != nil { - return nil, trace.Wrap(err, "loading root cluster CA cert pool") - } + dialOpts.RootClusterCaCertPool, err = tc.RootClusterCACertPoolPEM(ctx) + if err != nil { + return nil, trace.Wrap(err, "loading root cluster CA cert pool") } return dialOpts, nil } diff --git a/lib/vnet/admin_process_common.go b/lib/vnet/admin_process_common.go index bf4574aae01f1..47c1793dbe70f 100644 --- a/lib/vnet/admin_process_common.go +++ b/lib/vnet/admin_process_common.go @@ -22,9 +22,11 @@ import ( ) func newNetworkStackConfig(tun tunDevice, clt *clientApplicationServiceClient) (*networkStackConfig, error) { + sshProvider := newSSHProvider(sshProviderConfig{clt: clt}) tcpHandlerResolver := newTCPHandlerResolver(&tcpHandlerResolverConfig{ clt: clt, appProvider: newAppProvider(clt), + sshProvider: sshProvider, clock: clockwork.NewRealClock(), }) ipv6Prefix, err := newIPv6Prefix() diff --git a/lib/vnet/app_handler.go b/lib/vnet/app_handler.go index 09322d52bb264..aa0e2d7fee8b7 100644 --- a/lib/vnet/app_handler.go +++ b/lib/vnet/app_handler.go @@ -49,6 +49,10 @@ type tcpAppHandlerConfig struct { appInfo *vnetv1.AppInfo appProvider *appProvider clock clockwork.Clock + // alwaysTrustRootClusterCA can be set in tests so that TLS dials to the + // proxy always trust the root cluster CA rather than the system cert pool, + // even when ALPN conn upgrades are not required. + alwaysTrustRootClusterCA bool } func newTCPAppHandler(cfg *tcpAppHandlerConfig) *tcpAppHandler { @@ -102,9 +106,13 @@ func (h *tcpAppHandler) getOrInitializeLocalProxy(ctx context.Context, localPort InsecureSkipVerify: dialOptions.GetInsecureSkipVerify(), Clock: h.cfg.clock, } - if certPoolPEM := dialOptions.GetRootClusterCaCertPool(); len(certPoolPEM) > 0 { + if dialOptions.GetAlpnConnUpgradeRequired() || h.cfg.alwaysTrustRootClusterCA { + certPoolPEM := dialOptions.GetRootClusterCaCertPool() + if len(certPoolPEM) == 0 { + return nil, trace.BadParameter("ALPN conn upgrade required but no root CA cert pool provided") + } caPool := x509.NewCertPool() - if !caPool.AppendCertsFromPEM(dialOptions.GetRootClusterCaCertPool()) { + if !caPool.AppendCertsFromPEM(certPoolPEM) { return nil, trace.Errorf("failed to parse root cluster CA certs") } localProxyConfig.RootCAs = caPool diff --git a/lib/vnet/app_provider.go b/lib/vnet/app_provider.go index e4828d99ff38f..113b0cbafc38d 100644 --- a/lib/vnet/app_provider.go +++ b/lib/vnet/app_provider.go @@ -18,11 +18,8 @@ package vnet import ( "context" - "crypto" - "crypto/rsa" "crypto/tls" "crypto/x509" - "io" "github.com/gravitational/trace" @@ -59,61 +56,24 @@ func (p *appProvider) ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppInf return tlsCert, nil } -func (p *appProvider) newAppCertSigner(cert []byte, appKey *vnetv1.AppKey, targetPort uint16) (*rpcAppCertSigner, error) { +func (p *appProvider) newAppCertSigner(cert []byte, appKey *vnetv1.AppKey, targetPort uint16) (*rpcSigner, error) { x509Cert, err := x509.ParseCertificate(cert) if err != nil { return nil, trace.Wrap(err, "parsing x509 certificate") } - return &rpcAppCertSigner{ - clt: p.clt, - pub: x509Cert.PublicKey, - appKey: appKey, - targetPort: targetPort, + pub := x509Cert.PublicKey + return &rpcSigner{ + pub: pub, + sendRequest: func(req *vnetv1.SignRequest) ([]byte, error) { + return p.clt.SignForApp(context.TODO(), &vnetv1.SignForAppRequest{ + AppKey: appKey, + TargetPort: uint32(targetPort), + Sign: req, + }) + }, }, nil } -// rpcAppCertSigner implements [crypto.Signer] for app TLS signatures that are -// issued by the client application over gRPC. -type rpcAppCertSigner struct { - clt *clientApplicationServiceClient - pub crypto.PublicKey - appKey *vnetv1.AppKey - targetPort uint16 -} - -// Public implements [crypto.Signer.Public] and returns the public key -// associated with the signer. -func (s *rpcAppCertSigner) Public() crypto.PublicKey { - return s.pub -} - -// Sign implements [crypto.Signer.Sign] and issues a signature over digest for -// the associated app. -func (s *rpcAppCertSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { - req := &vnetv1.SignForAppRequest{ - AppKey: s.appKey, - TargetPort: uint32(s.targetPort), - Digest: digest, - } - switch opts.HashFunc() { - case 0: - req.Hash = vnetv1.Hash_HASH_NONE - case crypto.SHA256: - req.Hash = vnetv1.Hash_HASH_SHA256 - default: - return nil, trace.BadParameter("unsupported signature hash func %v", opts.HashFunc()) - } - if pssOpts, ok := opts.(*rsa.PSSOptions); ok { - saltLen := int32(pssOpts.SaltLength) - req.PssSaltLength = &saltLen - } - signature, err := s.clt.SignForApp(context.TODO(), req) - if err != nil { - return nil, trace.Wrap(err) - } - return signature, nil -} - // OnNewConnection reports a new TCP connection to the target app. func (p *appProvider) OnNewConnection(ctx context.Context, appKey *vnetv1.AppKey) error { if err := p.clt.OnNewConnection(ctx, appKey); err != nil { diff --git a/lib/vnet/client_application_service.go b/lib/vnet/client_application_service.go index 1f1b17a0e35d8..898089ebc3a6d 100644 --- a/lib/vnet/client_application_service.go +++ b/lib/vnet/client_application_service.go @@ -132,29 +132,14 @@ func (s *clientApplicationService) ReissueAppCert(ctx context.Context, req *vnet // It uses a cached signer for the requested app, which must have previously // been issued a certificate via [clientApplicationService.ReissueAppCert]. func (s *clientApplicationService) SignForApp(ctx context.Context, req *vnetv1.SignForAppRequest) (*vnetv1.SignForAppResponse, error) { + signReq := req.GetSign() log.DebugContext(ctx, "Got SignForApp request", "app", req.GetAppKey(), - "hash", req.GetHash(), - "is_rsa_pss", req.PssSaltLength != nil, - "pss_salt_len", req.GetPssSaltLength(), - "digest_len", len(req.GetDigest()), + "hash", signReq.GetHash(), + "is_rsa_pss", signReq.PssSaltLength != nil, + "pss_salt_len", signReq.GetPssSaltLength(), + "digest_len", len(signReq.GetDigest()), ) - var hash crypto.Hash - switch req.GetHash() { - case vnetv1.Hash_HASH_NONE: - hash = crypto.Hash(0) - case vnetv1.Hash_HASH_SHA256: - hash = crypto.SHA256 - default: - return nil, trace.BadParameter("unsupported hash %v", req.GetHash()) - } - opts := crypto.SignerOpts(hash) - if req.PssSaltLength != nil { - opts = &rsa.PSSOptions{ - Hash: hash, - SaltLength: int(*req.PssSaltLength), - } - } appKey := req.GetAppKey() if err := checkAppKey(appKey); err != nil { @@ -165,7 +150,7 @@ func (s *clientApplicationService) SignForApp(ctx context.Context, req *vnetv1.S return nil, trace.BadParameter("no signer for app %v", appKey) } - signature, err := signer.Sign(rand.Reader, req.GetDigest(), opts) + signature, err := sign(signer, signReq) if err != nil { return nil, trace.Wrap(err, "signing for app %v", appKey) } @@ -174,6 +159,27 @@ func (s *clientApplicationService) SignForApp(ctx context.Context, req *vnetv1.S }, nil } +func sign(signer crypto.Signer, signReq *vnetv1.SignRequest) ([]byte, error) { + var hash crypto.Hash + switch signReq.GetHash() { + case vnetv1.Hash_HASH_NONE: + hash = crypto.Hash(0) + case vnetv1.Hash_HASH_SHA256: + hash = crypto.SHA256 + default: + return nil, trace.BadParameter("unsupported hash %v", signReq.GetHash()) + } + opts := crypto.SignerOpts(hash) + if signReq.PssSaltLength != nil { + opts = &rsa.PSSOptions{ + Hash: hash, + SaltLength: int(*signReq.PssSaltLength), + } + } + signature, err := signer.Sign(rand.Reader, signReq.GetDigest(), opts) + return signature, trace.Wrap(err) +} + func (s *clientApplicationService) setSignerForApp(appKey *vnetv1.AppKey, targetPort uint16, signer crypto.Signer) { s.mu.Lock() defer s.mu.Unlock() @@ -234,6 +240,44 @@ func (s *clientApplicationService) GetTargetOSConfiguration(ctx context.Context, }, nil } +// UserTLSCert returns the user TLS certificate for a specific profile. +func (s *clientApplicationService) UserTLSCert(ctx context.Context, req *vnetv1.UserTLSCertRequest) (*vnetv1.UserTLSCertResponse, error) { + tlsCert, err := s.cfg.clientApplication.UserTLSCert(ctx, req.GetProfile()) + if err != nil { + return nil, trace.Wrap(err, "getting user TLS cert") + } + if len(tlsCert.Certificate) == 0 { + return nil, trace.Errorf("user TLS cert has no certificate") + } + dialOpts, err := s.cfg.clientApplication.GetDialOptions(ctx, req.GetProfile()) + if err != nil { + return nil, trace.Wrap(err, "getting TLS dial options") + } + return &vnetv1.UserTLSCertResponse{ + Cert: tlsCert.Certificate[0], + DialOptions: dialOpts, + }, nil +} + +// SignForUserTLS signs a digest with the user TLS private key. +func (s *clientApplicationService) SignForUserTLS(ctx context.Context, req *vnetv1.SignForUserTLSRequest) (*vnetv1.SignForUserTLSResponse, error) { + tlsCert, err := s.cfg.clientApplication.UserTLSCert(ctx, req.GetProfile()) + if err != nil { + return nil, trace.Wrap(err, "getting user TLS config") + } + signer, ok := tlsCert.PrivateKey.(crypto.Signer) + if !ok { + return nil, trace.Errorf("user TLS private key does not implement crypto.Signer") + } + signature, err := sign(signer, req.GetSign()) + if err != nil { + return nil, trace.Wrap(err, "signing for user TLS certificate") + } + return &vnetv1.SignForUserTLSResponse{ + Signature: signature, + }, nil +} + // checkAppKey checks that at least the app profile and name are set, which are // necessary to to disambiguate apps. LeafCluster is expected to be empty if the // app is in a root cluster. diff --git a/lib/vnet/client_application_service_client.go b/lib/vnet/client_application_service_client.go index 06b8d109b8f9b..394e327a28158 100644 --- a/lib/vnet/client_application_service_client.go +++ b/lib/vnet/client_application_service_client.go @@ -18,6 +18,9 @@ package vnet import ( "context" + "crypto" + "crypto/rsa" + "io" "github.com/gravitational/trace" "google.golang.org/grpc" @@ -165,3 +168,54 @@ func (c *clientApplicationServiceClient) GetTargetOSConfiguration(ctx context.Co } return resp.GetTargetOsConfiguration(), nil } + +// UserTLSCert returns the user TLS certificate for the given profile. +func (c *clientApplicationServiceClient) UserTLSCert(ctx context.Context, profileName string) (*vnetv1.UserTLSCertResponse, error) { + resp, err := c.clt.UserTLSCert(ctx, &vnetv1.UserTLSCertRequest{ + Profile: profileName, + }) + return resp, trace.Wrap(err, "calling UserTLSCert rpc") +} + +// SignForUserTLS returns a cryptographic signature with the key associated with +// the user TLS key for the requested profile. +func (c *clientApplicationServiceClient) SignForUserTLS(ctx context.Context, req *vnetv1.SignForUserTLSRequest) ([]byte, error) { + resp, err := c.clt.SignForUserTLS(ctx, req) + if err != nil { + return nil, trace.Wrap(err, "calling SignForUserTLS rpc") + } + return resp.GetSignature(), nil +} + +// rpcSigner implements [crypto.Signer] for signatures that are issued by the +// client application over gRPC. +type rpcSigner struct { + pub crypto.PublicKey + sendRequest func(signReq *vnetv1.SignRequest) ([]byte, error) +} + +// Public implements [crypto.Signer.Public] and returns the public key +// associated with the signer. +func (s *rpcSigner) Public() crypto.PublicKey { + return s.pub +} + +// Sign implements [crypto.Signer.Sign] and issues a signature over digest. +func (s *rpcSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + req := &vnetv1.SignRequest{ + Digest: digest, + } + switch opts.HashFunc() { + case 0: + req.Hash = vnetv1.Hash_HASH_NONE + case crypto.SHA256: + req.Hash = vnetv1.Hash_HASH_SHA256 + default: + return nil, trace.BadParameter("unsupported signature hash func %v", opts.HashFunc()) + } + if pssOpts, ok := opts.(*rsa.PSSOptions); ok { + saltLen := int32(pssOpts.SaltLength) + req.PssSaltLength = &saltLen + } + return s.sendRequest(req) +} diff --git a/lib/vnet/fqdn_resolver.go b/lib/vnet/fqdn_resolver.go index 553cf97e1ebbf..c204ae7047f1e 100644 --- a/lib/vnet/fqdn_resolver.go +++ b/lib/vnet/fqdn_resolver.go @@ -275,6 +275,9 @@ func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, MatchedCluster: &vnetv1.MatchedCluster{ WebProxyAddr: rootDialOpts.GetWebProxyAddr(), Ipv4CidrRange: clusterConfig.IPv4CIDRRange, + Profile: profileName, + RootCluster: rootClusterName, + LeafCluster: leafClusterName, }, }, }, nil @@ -292,6 +295,8 @@ func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, MatchedCluster: &vnetv1.MatchedCluster{ WebProxyAddr: rootDialOpts.GetWebProxyAddr(), Ipv4CidrRange: clusterConfig.IPv4CIDRRange, + Profile: profileName, + RootCluster: rootClusterName, }, }, }, nil diff --git a/lib/vnet/ssh_handler.go b/lib/vnet/ssh_handler.go new file mode 100644 index 0000000000000..627665913abaf --- /dev/null +++ b/lib/vnet/ssh_handler.go @@ -0,0 +1,73 @@ +// 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 vnet + +import ( + "context" + "net" + + "github.com/gravitational/trace" +) + +// sshHandler handles incoming VNet SSH connections. +type sshHandler struct { + cfg sshHandlerConfig +} + +type sshHandlerConfig struct { + sshProvider *sshProvider + target dialTarget +} + +func newSSHHandler(cfg sshHandlerConfig) *sshHandler { + return &sshHandler{ + cfg: cfg, + } +} + +// handleTCPConnector handles an incoming TCP connection from VNet and proxies +// the connection to a target SSH node. +func (h *sshHandler) handleTCPConnector(ctx context.Context, localPort uint16, connector func() (net.Conn, error)) error { + if localPort != 22 { + return trace.BadParameter("SSH is only handled on port 22") + } + targetConn, err := h.cfg.sshProvider.dial(ctx, h.cfg.target) + if err != nil { + return trace.Wrap(err) + } + defer targetConn.Close() + return trace.Wrap(h.handleTCPConnectorWithTargetConn(ctx, localPort, connector, targetConn)) +} + +// handleTCPConnectorWithTargetTCPConn handles an incoming TCP connection from +// VNet when a TCP connection to the target host has already been established. +func (h *sshHandler) handleTCPConnectorWithTargetConn( + ctx context.Context, + localPort uint16, + connector func() (net.Conn, error), + targetConn net.Conn, +) error { + // For now we accept the incoming TCP conn to indicate that the node exists, + // but SSH connection forwarding is not implemented yet so we immediately + // close it. + localConn, err := connector() + if err != nil { + return trace.Wrap(err) + } + localConn.Close() + return trace.NotImplemented("VNet SSH connection forwarding is not yet implemented") +} diff --git a/lib/vnet/ssh_provider.go b/lib/vnet/ssh_provider.go new file mode 100644 index 0000000000000..ace2db9bf17a5 --- /dev/null +++ b/lib/vnet/ssh_provider.go @@ -0,0 +1,182 @@ +// 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 vnet + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net" + "strings" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + proxyclient "github.com/gravitational/teleport/api/client/proxy" + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" +) + +// sshProvider provides methods necessary for VNet SSH access. +type sshProvider struct { + cfg sshProviderConfig +} + +type sshProviderConfig struct { + clt *clientApplicationServiceClient + // overrideNodeDialer can be used in tests to dial SSH nodes with the real + // TLS configuration but without setting up the proxy transport service. + overrideNodeDialer func( + ctx context.Context, + target dialTarget, + tlsConfig *tls.Config, + dialOpts *vnetv1.DialOptions, + ) (net.Conn, error) +} + +func newSSHProvider(cfg sshProviderConfig) *sshProvider { + return &sshProvider{ + cfg: cfg, + } +} + +// dial dials the target SSH host. +func (p *sshProvider) dial(ctx context.Context, target dialTarget) (net.Conn, error) { + userTLSCertResp, err := p.cfg.clt.UserTLSCert(ctx, target.profile) + if err != nil { + return nil, trace.Wrap(err) + } + rawCert := userTLSCertResp.GetCert() + dialOpts := userTLSCertResp.GetDialOptions() + tlsConfig, err := p.userTLSConfig(ctx, target.profile, rawCert, dialOpts) + if err != nil { + return nil, trace.Wrap(err) + } + if p.cfg.overrideNodeDialer != nil { + return p.cfg.overrideNodeDialer(ctx, target, tlsConfig, dialOpts) + } + return p.dialViaProxy(ctx, target, tlsConfig, dialOpts) +} + +// dialViaProxy dials the target SSH host via the proxy transport service. +func (p *sshProvider) dialViaProxy( + ctx context.Context, + target dialTarget, + tlsConfig *tls.Config, + dialOpts *vnetv1.DialOptions, +) (net.Conn, error) { + // TODO(nklaassen): consider reusing proxy clients, need to figure out when + // it's necessary to make a new client e.g. if the user's TLS credentials + // are replaced by a relogin. For now it's simpler to make a new client for + // every SSH dial. + pclt, err := proxyclient.NewClient(ctx, proxyclient.ClientConfig{ + ProxyAddress: dialOpts.GetWebProxyAddr(), + TLSConfigFunc: func(cluster string) (*tls.Config, error) { return tlsConfig, nil }, + ALPNConnUpgradeRequired: dialOpts.GetAlpnConnUpgradeRequired(), + InsecureSkipVerify: dialOpts.GetInsecureSkipVerify(), + // This empty SSH client config should never be used, we dial to the + // proxy over TLS only. + SSHConfig: &ssh.ClientConfig{}, + }) + if err != nil { + return nil, trace.Wrap(err, "building proxy client") + } + // TODO(nklaassen): pass an SSH keyring to support proxy recording mode. + conn, _, err := pclt.DialHost(ctx, target.host, target.cluster, nil /*keyRing*/) + if err != nil { + pclt.Close() + return nil, trace.Wrap(err, "dialing target via proxy") + } + // Make sure to close the proxy client, but not until we're done with the + // target connection or else it would close the underlying gRPC stream. + conn = newConnWithExtraCloser(conn, pclt.Close) + return conn, nil +} + +func (p *sshProvider) userTLSConfig( + ctx context.Context, + profile string, + rawCert []byte, + dialOpts *vnetv1.DialOptions, +) (*tls.Config, error) { + parsedCert, err := x509.ParseCertificate(rawCert) + if err != nil { + return nil, trace.Wrap(err, "parsing user TLS certificate") + } + signer := &rpcSigner{ + pub: parsedCert.PublicKey, + sendRequest: func(req *vnetv1.SignRequest) ([]byte, error) { + return p.cfg.clt.SignForUserTLS(ctx, &vnetv1.SignForUserTLSRequest{ + Profile: profile, + Sign: req, + }) + }, + } + tlsCert := tls.Certificate{ + Certificate: [][]byte{rawCert}, + PrivateKey: signer, + } + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM(dialOpts.GetRootClusterCaCertPool()) { + return nil, trace.Errorf("failed to parse root cluster CA cert pool") + } + return &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + RootCAs: caPool, + ServerName: dialOpts.GetSni(), + InsecureSkipVerify: dialOpts.GetInsecureSkipVerify(), + }, nil +} + +type dialTarget struct { + profile, cluster, host string +} + +func computeDialTarget(matchedCluster *vnetv1.MatchedCluster, fqdn string) dialTarget { + targetProfile := matchedCluster.GetProfile() + targetCluster := matchedCluster.GetRootCluster() + targetHost := strings.TrimSuffix(fqdn, "."+matchedCluster.GetRootCluster()+".") + if leafCluster := matchedCluster.GetLeafCluster(); leafCluster != "" { + targetCluster = leafCluster + targetHost = strings.TrimSuffix(targetHost, "."+leafCluster) + } + targetHost = targetHost + ":0" + return dialTarget{ + profile: targetProfile, + cluster: targetCluster, + host: targetHost, + } +} + +// connWithExtraCloser embeds a net.Conn and overrides the Close method to close +// an extra closer. Useful when the lifetime of a client providing the net.Conn +// must be tied to the lifetime of the Conn. +type connWithExtraCloser struct { + net.Conn + extraCloser func() error +} + +func newConnWithExtraCloser(conn net.Conn, extraCloser func() error) *connWithExtraCloser { + return &connWithExtraCloser{ + Conn: conn, + extraCloser: extraCloser, + } +} + +// Close closes the net.Conn and the extra closer. +func (c *connWithExtraCloser) Close() error { + return trace.NewAggregate(c.Conn.Close(), c.extraCloser()) +} diff --git a/lib/vnet/tcp_handler_resolver.go b/lib/vnet/tcp_handler_resolver.go index 6274e42eb19f2..47791a6b4e30c 100644 --- a/lib/vnet/tcp_handler_resolver.go +++ b/lib/vnet/tcp_handler_resolver.go @@ -37,9 +37,11 @@ type tcpHandlerResolver struct { } type tcpHandlerResolverConfig struct { - clt *clientApplicationServiceClient - appProvider *appProvider - clock clockwork.Clock + clt *clientApplicationServiceClient + appProvider *appProvider + sshProvider *sshProvider + clock clockwork.Clock + alwaysTrustRootClusterCA bool } func newTCPHandlerResolver(cfg *tcpHandlerResolverConfig) *tcpHandlerResolver { @@ -67,9 +69,10 @@ func (r *tcpHandlerResolver) resolveTCPHandler(ctx context.Context, fqdn string) return &tcpHandlerSpec{ ipv4CIDRRange: appInfo.GetIpv4CidrRange(), tcpHandler: newTCPAppHandler(&tcpAppHandlerConfig{ - appInfo: appInfo, - appProvider: r.cfg.appProvider, - clock: r.cfg.clock, + appInfo: appInfo, + appProvider: r.cfg.appProvider, + clock: r.cfg.clock, + alwaysTrustRootClusterCA: r.cfg.alwaysTrustRootClusterCA, }), }, nil } @@ -85,11 +88,9 @@ func (r *tcpHandlerResolver) resolveTCPHandler(ctx context.Context, fqdn string) // TCP connection if this may match an SSH node or an app that may be // added later so we return an undecidedHandler. handler, err := newUndecidedHandler(&undecidedHandlerConfig{ - clt: r.cfg.clt, - appProvider: r.cfg.appProvider, - clock: r.cfg.clock, - fqdn: fqdn, - webProxyAddr: matchedCluster.GetWebProxyAddr(), + tcpHandlerResolverConfig: r.cfg, + fqdn: fqdn, + webProxyAddr: matchedCluster.GetWebProxyAddr(), }) if err != nil { return nil, trace.Wrap(err) @@ -118,9 +119,7 @@ type undecidedHandler struct { } type undecidedHandlerConfig struct { - clt *clientApplicationServiceClient - appProvider *appProvider - clock clockwork.Clock + *tcpHandlerResolverConfig fqdn string webProxyAddr string } @@ -163,14 +162,16 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin if err != nil { return trace.Wrap(err, "resolving target in undecidedHandler") } + log := log.With("fqdn", h.cfg.fqdn) if matchedTCPApp := resp.GetMatchedTcpApp(); matchedTCPApp != nil { // If matched a TCP app, build a tcpAppHandler that will be used for this // and all subsequent connections to this address. - log.DebugContext(ctx, "Resolved FQDN to a matched TCP app", "fqdn", h.cfg.fqdn) + log.DebugContext(ctx, "Resolved FQDN to a matched TCP app") tcpAppHandler := newTCPAppHandler(&tcpAppHandlerConfig{ - appInfo: matchedTCPApp.GetAppInfo(), - appProvider: h.cfg.appProvider, - clock: h.cfg.clock, + appInfo: matchedTCPApp.GetAppInfo(), + appProvider: h.cfg.appProvider, + clock: h.cfg.clock, + alwaysTrustRootClusterCA: h.cfg.alwaysTrustRootClusterCA, }) h.setDecidedHandler(tcpAppHandler) return tcpAppHandler.handleTCPConnector(ctx, localPort, connector) @@ -178,13 +179,37 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin if matchedWebApp := resp.GetMatchedWebApp(); matchedWebApp != nil && localPort == h.webProxyPort { // If matched a web app, build a webAppHandler that will be used for this // and all subsequent connections to this address. - log.DebugContext(ctx, "Resolved FQDN to a matched web app", "fqdn", h.cfg.fqdn) + log.DebugContext(ctx, "Resolved FQDN to a matched web app") webAppHandler := newWebAppHandler(h.cfg.webProxyAddr, h.webProxyPort) h.setDecidedHandler(webAppHandler) return webAppHandler.handleTCPConnector(ctx, localPort, connector) } if matchedCluster := resp.GetMatchedCluster(); matchedCluster != nil && localPort == 22 { - return trace.NotImplemented("SSH connection forwarding not yet implemented") + // Matched a cluster, this FQDN could potentially match an SSH node. + log.DebugContext(ctx, "Resolved FQDN to a matched cluster") + // Attempt a dial to the target SSH node to see if it exists. + target := computeDialTarget(matchedCluster, h.cfg.fqdn) + targetConn, err := h.cfg.sshProvider.dial(ctx, target) + if err != nil { + if trace.IsConnectionProblem(err) { + log.DebugContext(ctx, "Failed TCP dial to target, node might be offline") + return nil + } + return trace.Wrap(err, "unexpected error TCP dialing to target node at %s", h.cfg.fqdn) + } + defer targetConn.Close() + log.DebugContext(ctx, "TCP dial to target SSH node succeeded", "fqdn", h.cfg.fqdn) + // Now that we know there is a matching SSH node, this handler will + // permanently handle SSH connections at this address and avoid app + // queries on subsequent connections. + sshHandler := newSSHHandler(sshHandlerConfig{ + sshProvider: h.cfg.sshProvider, + target: target, + }) + h.setDecidedHandler(sshHandler) + // Handle the incoming connection with the TCP connection to the target + // SSH node that has already been established. + return sshHandler.handleTCPConnectorWithTargetConn(ctx, localPort, connector, targetConn) } return trace.Errorf("rejecting connection to %s:%d", h.cfg.fqdn, localPort) } diff --git a/lib/vnet/user_process.go b/lib/vnet/user_process.go index e490a8c639405..acaa234a7cd1f 100644 --- a/lib/vnet/user_process.go +++ b/lib/vnet/user_process.go @@ -45,6 +45,9 @@ type ClientApplication interface { // ReissueAppCert issues a new cert for the target app. ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppInfo, targetPort uint16) (tls.Certificate, error) + // UserTLSCert returns the user TLS certificate for the given profile. + UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) + // GetDialOptions returns ALPN dial options for the profile. GetDialOptions(ctx context.Context, profileName string) (*vnetv1.DialOptions, error) diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index 004f017eea66b..d5a64a67a3bea 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -52,6 +52,7 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/stack" "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/defaults" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" "github.com/gravitational/teleport/api/types" @@ -144,10 +145,16 @@ func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPac // interface with fakeClientApp via the gRPC client. clt := runTestClientApplicationService(t, ctx, cfg.clock, cfg.fakeClientApp) appProvider := newAppProvider(clt) + sshProvider := newSSHProvider(sshProviderConfig{ + clt: clt, + overrideNodeDialer: cfg.fakeClientApp.dialSSHNode, + }) tcpHandlerResolver := newTCPHandlerResolver(&tcpHandlerResolverConfig{ - clt: clt, - appProvider: appProvider, - clock: cfg.clock, + clt: clt, + appProvider: appProvider, + sshProvider: sshProvider, + clock: cfg.clock, + alwaysTrustRootClusterCA: true, }) // Create the VNet and connect it to the other side of the TUN. @@ -318,8 +325,11 @@ type appSpec struct { tcpPorts []*types.PortRange } +type nodeSpec struct{} + type testClusterSpec struct { apps []appSpec + nodes map[string]nodeSpec cidrRange string customDNSZones []string leafClusters map[string]testClusterSpec @@ -328,8 +338,9 @@ type testClusterSpec struct { type fakeClientApp struct { cfg *fakeClientAppConfig - tlsCA tls.Certificate - dialOpts *vnetv1.DialOptions + tlsCA tls.Certificate + userTLSCert tls.Certificate + dialOpts *vnetv1.DialOptions onNewConnectionCallCount atomic.Uint32 onInvalidLocalPortCallCount atomic.Uint32 @@ -351,9 +362,17 @@ type fakeClientAppConfig struct { func newFakeClientApp(ctx context.Context, t *testing.T, cfg *fakeClientAppConfig) *fakeClientApp { tlsCA := newSelfSignedCA(t) dialOpts := mustStartFakeWebProxy(ctx, t, tlsCA, cfg.clock, cfg.signatureAlgorithmSuite) + userTLSCert, err := newClientCert(ctx, + tlsCA, + "testuser", + cfg.clock.Now().Add(defaults.CertDuration), + cfg.signatureAlgorithmSuite, + cryptosuites.UserTLS) + require.NoError(t, err) return &fakeClientApp{ cfg: cfg, tlsCA: tlsCA, + userTLSCert: userTLSCert, dialOpts: dialOpts, requestedRouteToApps: make(map[string][]*proto.RouteToApp), } @@ -409,6 +428,10 @@ func (p *fakeClientApp) ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppI cryptosuites.UserTLS) } +func (p *fakeClientApp) UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) { + return p.userTLSCert, nil +} + func (p *fakeClientApp) RequestedRouteToApps(publicAddr string) []*proto.RouteToApp { p.requestedRouteToAppsMu.RLock() defer p.requestedRouteToAppsMu.RUnlock() @@ -484,6 +507,30 @@ func (p *fakeClientApp) OnInvalidLocalPort(_ context.Context, _ *vnetv1.AppInfo, p.onInvalidLocalPortCallCount.Add(1) } +func (p *fakeClientApp) dialSSHNode( + ctx context.Context, + target dialTarget, + tlsConfig *tls.Config, + dialOpts *vnetv1.DialOptions, +) (net.Conn, error) { + targetCluster, ok := p.cfg.clusters[target.profile] + if !ok { + return nil, trace.NotFound("no such profile") + } + if target.cluster != target.profile { + targetCluster, ok = targetCluster.leafClusters[target.cluster] + if !ok { + return nil, trace.NotFound("no such cluster") + } + } + if _, ok := targetCluster.nodes[strings.TrimSuffix(target.host, ":0")]; !ok { + return nil, trace.NotFound("no such host") + } + // For now just let it dial the fake web proxy, later we'll need to set up a + // fake SSH server for the test to dial to. + return tls.Dial("tcp", dialOpts.GetWebProxyAddr(), tlsConfig) +} + type fakeClusterClient struct { authClient *fakeAuthClient } @@ -1010,17 +1057,29 @@ func TestSSH(t *testing.T) { clusters: map[string]testClusterSpec{ "root1.example.com": { cidrRange: root1CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + }, leafClusters: map[string]testClusterSpec{ "leaf1.example.com": { cidrRange: leaf1CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + }, }, }, }, "root2.example.com": { cidrRange: root2CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + }, leafClusters: map[string]testClusterSpec{ "leaf2.example.com": { cidrRange: leaf2CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + }, }, }, }, @@ -1035,32 +1094,67 @@ func TestSSH(t *testing.T) { }) for _, tc := range []struct { - addr string - expectCIDR string + dialAddr string + dialPort int + expectCIDR string + expectLookupToFail bool + expectDialToFail bool }{ { - addr: "node.root1.example.com", + dialAddr: "node.root1.example.com", + dialPort: 22, expectCIDR: root1CIDR, }, { - addr: "node.leaf1.example.com.root1.example.com", + // Dial should fail on non-standard SSH port. + dialAddr: "node.root1.example.com", + dialPort: 23, + expectCIDR: root1CIDR, + expectDialToFail: true, + }, + { + dialAddr: "node.leaf1.example.com.root1.example.com", + dialPort: 22, expectCIDR: leaf1CIDR, }, { - addr: "node.root2.example.com", + dialAddr: "node.root2.example.com", + dialPort: 22, expectCIDR: root2CIDR, }, { - addr: "node.leaf2.example.com.root2.example.com", + dialAddr: "node.leaf2.example.com.root2.example.com", + dialPort: 22, expectCIDR: leaf2CIDR, }, + { + // DNS lookup should fail if the FQDN doesn't match any cluster. + dialAddr: "node.bogus.example.com.", + dialPort: 22, + expectLookupToFail: true, + }, + { + // If the FQDN matches a cluster but no node, the DNS lookup should + // succeed but the TCP dial should fail. + dialAddr: "bogus.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + expectDialToFail: true, + }, } { - t.Run(tc.addr, func(t *testing.T) { + t.Run(fmt.Sprintf("%s:%d", tc.dialAddr, tc.dialPort), func(t *testing.T) { t.Parallel() + + lookupCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() // SSH access isn't fully implemented yet, at this point the DNS // lookup for *. should resolve to an IP in the // expected CIDR range for the cluster. - resolvedAddrs, err := p.lookupHost(ctx, tc.addr) + resolvedAddrs, err := p.lookupHost(lookupCtx, tc.dialAddr) + if tc.expectLookupToFail { + require.Error(t, err) + return + } require.NoError(t, err) _, expectNet, err := net.ParseCIDR(tc.expectCIDR) @@ -1076,10 +1170,15 @@ func TestSSH(t *testing.T) { "expected CIDR range %s does not include resolved IP %s", expectNet, resolvedIPSuffix) } - // Actually dialing the address should still fail until VNet SSH is - // implemented. - _, err = p.dialHost(ctx, tc.addr, 22) - require.Error(t, err) + dialCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + conn, err := p.dialHost(dialCtx, tc.dialAddr, tc.dialPort) + if tc.expectDialToFail { + require.Error(t, err) + return + } + require.NoError(t, err) + conn.Close() }) } } diff --git a/proto/teleport/lib/vnet/v1/client_application_service.proto b/proto/teleport/lib/vnet/v1/client_application_service.proto index e552e5bd0b277..4552204c8a483 100644 --- a/proto/teleport/lib/vnet/v1/client_application_service.proto +++ b/proto/teleport/lib/vnet/v1/client_application_service.proto @@ -53,6 +53,10 @@ service ClientApplicationService { rpc OnInvalidLocalPort(OnInvalidLocalPortRequest) returns (OnInvalidLocalPortResponse); // GetTargetOSConfiguration gets the target OS configuration. rpc GetTargetOSConfiguration(GetTargetOSConfigurationRequest) returns (GetTargetOSConfigurationResponse); + // UserTLSCert returns the user TLS certificate for a specific profile. + rpc UserTLSCert(UserTLSCertRequest) returns (UserTLSCertResponse); + // SignForUserTLS signs a digest with the user TLS private key. + rpc SignForUserTLS(SignForUserTLSRequest) returns (SignForUserTLSResponse); } // AuthenticateProcessRequest is a request for AuthenticateProcess. @@ -131,6 +135,14 @@ message MatchedCluster { // WebProxyAddr is the web proxy address of the root cluster that matched the // query. string web_proxy_addr = 2; + // Profile is the profile the matched cluster was found in. + string profile = 3; + // RootCluster will always be set to the name of the root cluster that matched + // the query. + string root_cluster = 4; + // LeafCluster will be set only when the query matched a leaf cluster of + // RootCluster, or else it will be empty. + string leaf_cluster = 5; } // AppInfo holds all necessary info for making connections to VNet TCP apps. @@ -172,8 +184,10 @@ message DialOptions { string sni = 3; // InsecureSkipVerify turns off verification for x509 upstream ALPN proxy service certificate. bool insecure_skip_verify = 4; - // RootClusterCaCertPool overrides the x509 certificate pool used to verify the server. - // It is a PEM-encoded X509 certificate pool. + // RootClusterCaCertPool is the host CA TLS certificate pool for the root + // cluster. It is a PEM-encoded X509 certificate pool. It should be used when + // dialing the proxy and AlpnConnUpgradeRequired is true or when dialing the + // transport service. bytes root_cluster_ca_cert_pool = 5; } @@ -198,6 +212,8 @@ message ReissueAppCertResponse { // ReissueAppCert. The private key used for the signature will match the subject // public key of the issued x509 certificate. message SignForAppRequest { + reserved 3, 4, 5; + reserved "digest", "hash", "pss_salt_length"; // AppKey uniquely identifies a TCP app, it must match the key of an app from // a previous successful call to ReissueAppCert. AppKey app_key = 1; @@ -205,13 +221,19 @@ message SignForAppRequest { // TargetPort of a previous successful call to ReissueAppCert for an app // matching AppKey. uint32 target_port = 2; + // Sign holds signature request details. + SignRequest sign = 6; +} + +// SignRequest holds signature request details. +message SignRequest { // Digest is the bytes to sign. - bytes digest = 3; + bytes digest = 1; // Hash is the hash function used to compute digest. - Hash hash = 4; + Hash hash = 2; // PssSaltLength specifies the length of the salt added to the digest before a // signature. Only used and required for RSA PSS signatures. - optional int32 pss_salt_length = 5; + optional int32 pss_salt_length = 3; } // Hash specifies a cryptographic hash function. @@ -278,3 +300,32 @@ message TargetOSConfiguration { // should also include the default range. repeated string ipv4_cidr_ranges = 2; } + +// UserTLSCertRequest is a request for UserTLSCert. +message UserTLSCertRequest { + // Profile is the profile to retrieve the certificate for. + string profile = 1; +} + +// UserTLSCertResponse is a response for UserTLSCert. +message UserTLSCertResponse { + // Cert is the user TLS certificate in X.509 ASN.1 DER format. + bytes cert = 1; + // DialOptions holds options that should be used when dialing the root cluster + // proxy. + DialOptions dial_options = 2; +} + +// SignForUserTLSRequest is a request for SignForUserTLS. +message SignForUserTLSRequest { + // Profile is the user profile to sign for. + string profile = 1; + // Sign holds signature request details. + SignRequest sign = 2; +} + +// SignForUserTLSResponse is a response for SignForUserTLS. +message SignForUserTLSResponse { + // Signature is the signature. + bytes signature = 1; +} diff --git a/tool/tsh/common/vnet_client_application.go b/tool/tsh/common/vnet_client_application.go index b9ee5c1ed4103..cbffa21f90fd1 100644 --- a/tool/tsh/common/vnet_client_application.go +++ b/tool/tsh/common/vnet_client_application.go @@ -100,6 +100,22 @@ func (p *vnetClientApplication) ReissueAppCert(ctx context.Context, appInfo *vne return cert, trace.Wrap(err) } +// UserTLSCert returns the user TLS certificate for the given profile. +func (p *vnetClientApplication) UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) { + profile, err := p.clientStore.GetProfile(profileName) + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "loading user profile %s", profileName) + } + tlsConfig, err := profile.TLSConfig() + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "loading TLS config for profile") + } + if len(tlsConfig.Certificates) == 0 { + return tls.Certificate{}, trace.Errorf("user tls config has no certificates") + } + return tlsConfig.Certificates[0], nil +} + // GetDialOptions returns ALPN dial options for the profile. func (p *vnetClientApplication) GetDialOptions(ctx context.Context, profileName string) (*vnetv1.DialOptions, error) { profile, err := p.clientStore.GetProfile(profileName) @@ -111,11 +127,9 @@ func (p *vnetClientApplication) GetDialOptions(ctx context.Context, profileName AlpnConnUpgradeRequired: profile.TLSRoutingConnUpgradeRequired, InsecureSkipVerify: p.cf.InsecureSkipVerify, } - if dialOpts.AlpnConnUpgradeRequired { - dialOpts.RootClusterCaCertPool, err = p.getRootClusterCACertPoolPEM(ctx, profileName) - if err != nil { - return nil, trace.Wrap(err) - } + dialOpts.RootClusterCaCertPool, err = p.getRootClusterCACertPoolPEM(ctx, profileName) + if err != nil { + return nil, trace.Wrap(err) } return dialOpts, nil } From 19f5dceab1b18d762d0bc44d41d6d676124d5782 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Sat, 31 May 2025 08:04:23 -0700 Subject: [PATCH 02/25] [v18][vnet] feat: accept incoming SSH connections Backport #55155 to branch/v18 --- lib/vnet/admin_process_common.go | 11 ++- lib/vnet/ssh_handler.go | 84 +++++++++++++++- lib/vnet/ssh_provider.go | 54 ++++++++++- lib/vnet/vnet_test.go | 160 +++++++++++++++++++++++++------ 4 files changed, 269 insertions(+), 40 deletions(-) diff --git a/lib/vnet/admin_process_common.go b/lib/vnet/admin_process_common.go index 47c1793dbe70f..2aad6c5b1967f 100644 --- a/lib/vnet/admin_process_common.go +++ b/lib/vnet/admin_process_common.go @@ -22,12 +22,19 @@ import ( ) func newNetworkStackConfig(tun tunDevice, clt *clientApplicationServiceClient) (*networkStackConfig, error) { - sshProvider := newSSHProvider(sshProviderConfig{clt: clt}) + clock := clockwork.NewRealClock() + sshProvider, err := newSSHProvider(sshProviderConfig{ + clt: clt, + clock: clock, + }) + if err != nil { + return nil, trace.Wrap(err) + } tcpHandlerResolver := newTCPHandlerResolver(&tcpHandlerResolverConfig{ clt: clt, appProvider: newAppProvider(clt), sshProvider: sshProvider, - clock: clockwork.NewRealClock(), + clock: clock, }) ipv6Prefix, err := newIPv6Prefix() if err != nil { diff --git a/lib/vnet/ssh_handler.go b/lib/vnet/ssh_handler.go index 627665913abaf..f8e8e831ea24b 100644 --- a/lib/vnet/ssh_handler.go +++ b/lib/vnet/ssh_handler.go @@ -18,9 +18,15 @@ package vnet import ( "context" + "crypto/rand" "net" + "strings" "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport/api/utils/sshutils" + "github.com/gravitational/teleport/lib/cryptosuites" ) // sshHandler handles incoming VNet SSH connections. @@ -61,13 +67,83 @@ func (h *sshHandler) handleTCPConnectorWithTargetConn( connector func() (net.Conn, error), targetConn net.Conn, ) error { - // For now we accept the incoming TCP conn to indicate that the node exists, - // but SSH connection forwarding is not implemented yet so we immediately - // close it. + hostCert, err := h.newHostCert(ctx) + if err != nil { + return trace.Wrap(err) + } + localConn, err := connector() if err != nil { return trace.Wrap(err) } - localConn.Close() + defer localConn.Close() + + // For now we accept the incoming SSH connection but forwarding to the + // target is not implemented yet so we immediately close it. + serverConfig := &ssh.ServerConfig{ + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if !sshutils.KeysEqual(h.cfg.sshProvider.trustedUserPublicKey, key) { + return nil, trace.AccessDenied("SSH client public key is not trusted") + } + return nil, nil + }, + } + serverConfig.AddHostKey(hostCert) + serverConn, chans, reqs, err := ssh.NewServerConn(localConn, serverConfig) + if err != nil { + return trace.Wrap(err, "accepting incoming SSH connection") + } + // Immediately close the connection but make sure to drain the channels. + serverConn.Close() + go ssh.DiscardRequests(reqs) + go func() { + for newChan := range chans { + _ = newChan.Reject(0, "") + } + }() + target := h.cfg.target + log.DebugContext(ctx, "Accepted incoming SSH connection", + "profile", target.profile, + "cluster", target.cluster, + "host", target.host, + "user", serverConn.User(), + ) return trace.NotImplemented("VNet SSH connection forwarding is not yet implemented") } + +func (h *sshHandler) newHostCert(ctx context.Context) (ssh.Signer, error) { + // If the user typed "ssh host.com" or "ssh host.com." our DNS handler will + // only see the fully-qualified variant with the trailing "." but the SSH + // client treats them differently, we need both in the principals if we want + // the cert to be trusted in both cases. + validPrincipals := []string{ + h.cfg.target.fqdn, + strings.TrimSuffix(h.cfg.target.fqdn, "."), + } + // We generate an ephemeral key for every connection, Ed25519 is fast and + // well supported. + hostKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err, "generating SSH host key") + } + hostSigner, err := ssh.NewSignerFromSigner(hostKey) + if err != nil { + return nil, trace.Wrap(err) + } + cert := &ssh.Certificate{ + Key: hostSigner.PublicKey(), + Serial: 1, + CertType: ssh.HostCert, + ValidPrincipals: validPrincipals, + // This cert will only ever be used to handle this one SSH connection, + // the private key is held only in memory, the issuing CA is regenerated + // every time this process restarts and will only be trusted on this one + // host. The expiry doesn't matter. + ValidBefore: ssh.CertTimeInfinity, + } + if err := cert.SignCert(rand.Reader, h.cfg.sshProvider.hostCASigner); err != nil { + return nil, trace.Wrap(err, "signing SSH host cert") + } + certSigner, err := ssh.NewCertSigner(cert, hostSigner) + return certSigner, trace.Wrap(err) +} diff --git a/lib/vnet/ssh_provider.go b/lib/vnet/ssh_provider.go index ace2db9bf17a5..1e0cc00e2ebd1 100644 --- a/lib/vnet/ssh_provider.go +++ b/lib/vnet/ssh_provider.go @@ -24,19 +24,26 @@ import ( "strings" "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" "golang.org/x/crypto/ssh" proxyclient "github.com/gravitational/teleport/api/client/proxy" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" + "github.com/gravitational/teleport/lib/cryptosuites" ) // sshProvider provides methods necessary for VNet SSH access. type sshProvider struct { cfg sshProviderConfig + // hostCASigner is the host CA key used internally in VNet to terminate + // connections from clients, it is not a Teleport CA used by any cluster. + hostCASigner ssh.Signer + trustedUserPublicKey ssh.PublicKey } type sshProviderConfig struct { - clt *clientApplicationServiceClient + clt *clientApplicationServiceClient + clock clockwork.Clock // overrideNodeDialer can be used in tests to dial SSH nodes with the real // TLS configuration but without setting up the proxy transport service. overrideNodeDialer func( @@ -45,12 +52,45 @@ type sshProviderConfig struct { tlsConfig *tls.Config, dialOpts *vnetv1.DialOptions, ) (net.Conn, error) + // hostCASigner can be used in tests to set a specific key for the SSH host CA. + hostCASigner ssh.Signer + // trustedUserPublicKey can be used in tests to set a specific trusted user + // SSH key. + trustedUserPublicKey ssh.PublicKey } -func newSSHProvider(cfg sshProviderConfig) *sshProvider { - return &sshProvider{ - cfg: cfg, +func newSSHProvider(cfg sshProviderConfig) (*sshProvider, error) { + hostCASigner := cfg.hostCASigner + if hostCASigner == nil { + // TODO(nklaassen): write host CA public key to $TELEPORT_HOME/vnet_known_hosts + hostKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err) + } + hostCASigner, err = ssh.NewSignerFromSigner(hostKey) + if err != nil { + return nil, trace.Wrap(err) + } + } + trustedUserPublicKey := cfg.trustedUserPublicKey + if trustedUserPublicKey == nil { + // TODO(nklaassen): check if $TELEPORT_HOME/id_vnet.pub exists. + // If it does, read that file and trust it. + // If not, generate the keypair and write it to $TELEPORT_HOME/id_vnet. + userKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err) + } + trustedUserPublicKey, err = ssh.NewPublicKey(userKey.Public()) + if err != nil { + return nil, trace.Wrap(err) + } } + return &sshProvider{ + cfg: cfg, + hostCASigner: hostCASigner, + trustedUserPublicKey: trustedUserPublicKey, + }, nil } // dial dials the target SSH host. @@ -142,7 +182,10 @@ func (p *sshProvider) userTLSConfig( } type dialTarget struct { - profile, cluster, host string + fqdn string + profile string + cluster string + host string } func computeDialTarget(matchedCluster *vnetv1.MatchedCluster, fqdn string) dialTarget { @@ -155,6 +198,7 @@ func computeDialTarget(matchedCluster *vnetv1.MatchedCluster, fqdn string) dialT } targetHost = targetHost + ":0" return dialTarget{ + fqdn: fqdn, profile: targetProfile, cluster: targetCluster, host: targetHost, diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index d5a64a67a3bea..3063f7d1a288a 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -43,6 +43,7 @@ import ( "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" "google.golang.org/grpc" grpccredentials "google.golang.org/grpc/credentials" "gvisor.dev/gvisor/pkg/tcpip" @@ -58,6 +59,7 @@ import ( "github.com/gravitational/teleport/api/types" typesvnet "github.com/gravitational/teleport/api/types/vnet" "github.com/gravitational/teleport/api/utils/grpc/interceptors" + "github.com/gravitational/teleport/api/utils/sshutils" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/cryptosuites" @@ -85,8 +87,10 @@ type testPack struct { } type testPackConfig struct { - clock clockwork.Clock - fakeClientApp *fakeClientApp + clock clockwork.Clock + fakeClientApp *fakeClientApp + sshHostCASigner ssh.Signer + sshTrustedUserPublicKey ssh.PublicKey } func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPack { @@ -145,10 +149,14 @@ func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPac // interface with fakeClientApp via the gRPC client. clt := runTestClientApplicationService(t, ctx, cfg.clock, cfg.fakeClientApp) appProvider := newAppProvider(clt) - sshProvider := newSSHProvider(sshProviderConfig{ - clt: clt, - overrideNodeDialer: cfg.fakeClientApp.dialSSHNode, + sshProvider, err := newSSHProvider(sshProviderConfig{ + clt: clt, + clock: cfg.clock, + overrideNodeDialer: cfg.fakeClientApp.dialSSHNode, + hostCASigner: cfg.sshHostCASigner, + trustedUserPublicKey: cfg.sshTrustedUserPublicKey, }) + require.NoError(t, err) tcpHandlerResolver := newTCPHandlerResolver(&tcpHandlerResolverConfig{ clt: clt, appProvider: appProvider, @@ -1088,22 +1096,52 @@ func TestSSH(t *testing.T) { signatureAlgorithmSuite: types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1, }) + sshHostKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + require.NoError(t, err) + sshHostCASigner, err := ssh.NewSignerFromSigner(sshHostKey) + require.NoError(t, err) + + sshUserKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + require.NoError(t, err) + sshUserSigner, err := ssh.NewSignerFromSigner(sshUserKey) + require.NoError(t, err) + + badUserKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + require.NoError(t, err) + badUserSigner, err := ssh.NewSignerFromSigner(badUserKey) + require.NoError(t, err) + p := newTestPack(t, ctx, testPackConfig{ - fakeClientApp: clientApp, - clock: clock, + fakeClientApp: clientApp, + clock: clock, + sshHostCASigner: sshHostCASigner, + sshTrustedUserPublicKey: sshUserSigner.PublicKey(), }) for _, tc := range []struct { - dialAddr string - dialPort int - expectCIDR string - expectLookupToFail bool - expectDialToFail bool + dialAddr string + dialPort int + expectCIDR string + expectLookupToFail bool + expectDialToFail bool + sshUser string + sshUserSigner ssh.Signer + expectSSHHandshakeToFail bool }{ { - dialAddr: "node.root1.example.com", - dialPort: 22, - expectCIDR: root1CIDR, + dialAddr: "node.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + }, + { + // Fully-qualified hostname should also work. + dialAddr: "node.root1.example.com.", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, }, { // Dial should fail on non-standard SSH port. @@ -1113,19 +1151,33 @@ func TestSSH(t *testing.T) { expectDialToFail: true, }, { - dialAddr: "node.leaf1.example.com.root1.example.com", - dialPort: 22, - expectCIDR: leaf1CIDR, + dialAddr: "node.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "baduser", + sshUserSigner: badUserSigner, + expectSSHHandshakeToFail: true, }, { - dialAddr: "node.root2.example.com", - dialPort: 22, - expectCIDR: root2CIDR, + dialAddr: "node.leaf1.example.com.root1.example.com", + dialPort: 22, + expectCIDR: leaf1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, }, { - dialAddr: "node.leaf2.example.com.root2.example.com", - dialPort: 22, - expectCIDR: leaf2CIDR, + dialAddr: "node.root2.example.com", + dialPort: 22, + expectCIDR: root2CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + }, + { + dialAddr: "node.leaf2.example.com.root2.example.com", + dialPort: 22, + expectCIDR: leaf2CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, }, { // DNS lookup should fail if the FQDN doesn't match any cluster. @@ -1142,14 +1194,13 @@ func TestSSH(t *testing.T) { expectDialToFail: true, }, } { - t.Run(fmt.Sprintf("%s:%d", tc.dialAddr, tc.dialPort), func(t *testing.T) { + t.Run(fmt.Sprintf("%s@%s:%d", tc.sshUser, tc.dialAddr, tc.dialPort), func(t *testing.T) { t.Parallel() lookupCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() - // SSH access isn't fully implemented yet, at this point the DNS - // lookup for *. should resolve to an IP in the - // expected CIDR range for the cluster. + // The DNS lookup for *. should resolve to an IP in + // the expected CIDR range for the cluster. resolvedAddrs, err := p.lookupHost(lookupCtx, tc.dialAddr) if tc.expectLookupToFail { require.Error(t, err) @@ -1170,6 +1221,8 @@ func TestSSH(t *testing.T) { "expected CIDR range %s does not include resolved IP %s", expectNet, resolvedIPSuffix) } + // TCP dial the target address, it should fail if the node doesn't + // exist. dialCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() conn, err := p.dialHost(dialCtx, tc.dialAddr, tc.dialPort) @@ -1178,9 +1231,58 @@ func TestSSH(t *testing.T) { return } require.NoError(t, err) - conn.Close() + defer conn.Close() + + // Initiate an SSH connection to the target. At this point the + // handshake should complete successfully as long as the right keys + // are used, but the SSH connection will be immediately closed by + // the server. + certChecker := ssh.CertChecker{ + IsHostAuthority: func(auth ssh.PublicKey, address string) bool { + return sshutils.KeysEqual(auth, sshHostCASigner.PublicKey()) + }, + Clock: clock.Now, + } + clientConfig := &ssh.ClientConfig{ + User: tc.sshUser, + Auth: []ssh.AuthMethod{ssh.PublicKeys(tc.sshUserSigner)}, + HostKeyCallback: certChecker.CheckHostKey, + } + sshConn, _, _, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", tc.dialAddr, tc.dialPort), clientConfig) + if tc.expectSSHHandshakeToFail { + require.Error(t, err, "expected SSH handshake to fail") + return + } + require.NoError(t, err) + defer sshConn.Close() }) } + + // Test that a fresh SSH host cert is used on each connection. + t.Run("ephemeral certs", func(t *testing.T) { + // Set up the SSH client config to capture the host certs it sees. + var checkedHostCerts []*ssh.Certificate + clientConfig := &ssh.ClientConfig{ + User: "testuser", + Auth: []ssh.AuthMethod{ssh.PublicKeys(sshUserSigner)}, + HostKeyCallback: func(addr string, remote net.Addr, key ssh.PublicKey) error { + checkedHostCerts = append(checkedHostCerts, key.(*ssh.Certificate)) + return nil + }, + } + const connections = 3 + for range connections { + conn, err := p.dialHost(ctx, "node.root1.example.com", 22) + require.NoError(t, err) + sshConn, _, _, err := ssh.NewClientConn(conn, "node.root1.example.com:22", clientConfig) + require.NoError(t, err) + sshConn.Close() + } + require.Len(t, checkedHostCerts, connections) + for i := range connections - 1 { + require.NotEqual(t, checkedHostCerts[i], checkedHostCerts[i+1]) + } + }) } func randomULAAddress() (tcpip.Address, error) { From 4517ec58c7954117feeb39303cd8ed0de84c4e43 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Sat, 31 May 2025 09:24:04 -0700 Subject: [PATCH 03/25] [v18][vnet] feat: forward SSH connections to target Backport #55156 to branch/v18 --- .../vnet/v1/client_application_service.pb.go | 347 ++++++++++++++++-- .../v1/client_application_service_grpc.pb.go | 82 +++++ lib/client/cluster_client.go | 67 +++- lib/vnet/client_application_service.go | 138 ++++++- lib/vnet/client_application_service_client.go | 25 ++ lib/vnet/ssh_handler.go | 112 +++++- lib/vnet/ssh_provider.go | 97 ++++- lib/vnet/ssh_proxy.go | 15 +- lib/vnet/ssh_proxy_test.go | 5 + lib/vnet/user_process.go | 8 +- lib/vnet/vnet_test.go | 266 ++++++++++++-- .../vnet/v1/client_application_service.proto | 48 +++ 12 files changed, 1084 insertions(+), 126 deletions(-) diff --git a/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go index 1869b69ce9159..921832bda2b01 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go @@ -1773,6 +1773,258 @@ func (x *SignForUserTLSResponse) GetSignature() []byte { return nil } +// SessionSSHConfigRequest is a request for SessionSSHConfig. +type SessionSSHConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Profile is the profile in which the SSH server is found. + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // RootCluster is the cluster in which the SSH server is found. + RootCluster string `protobuf:"bytes,2,opt,name=root_cluster,json=rootCluster,proto3" json:"root_cluster,omitempty"` + // LeafCluster is the leaf cluster in which the SSH server is found. + // If empty, the SSH server is in the root cluster. + LeafCluster string `protobuf:"bytes,3,opt,name=leaf_cluster,json=leafCluster,proto3" json:"leaf_cluster,omitempty"` + // Address is the address of the SSH server. + Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"` + // User is the SSH user the session is for. + User string `protobuf:"bytes,5,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionSSHConfigRequest) Reset() { + *x = SessionSSHConfigRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionSSHConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionSSHConfigRequest) ProtoMessage() {} + +func (x *SessionSSHConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[31] + 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 SessionSSHConfigRequest.ProtoReflect.Descriptor instead. +func (*SessionSSHConfigRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{31} +} + +func (x *SessionSSHConfigRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SessionSSHConfigRequest) GetRootCluster() string { + if x != nil { + return x.RootCluster + } + return "" +} + +func (x *SessionSSHConfigRequest) GetLeafCluster() string { + if x != nil { + return x.LeafCluster + } + return "" +} + +func (x *SessionSSHConfigRequest) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *SessionSSHConfigRequest) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +// SessionSSHConfigResponse is a response for SessionSSHConfig. +type SessionSSHConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // SessionId is an opaque identifier for the session, it should be passed to + // SignForSSHSession to issue signatures with the private key associated with + // the session. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Cert is the session SSH certificate in SSH wire format. + Cert []byte `protobuf:"bytes,2,opt,name=cert,proto3" json:"cert,omitempty"` + // TrustedCas is a list of trusted SSH certificate authorities in SSH wire + // format. + TrustedCas [][]byte `protobuf:"bytes,3,rep,name=trusted_cas,json=trustedCas,proto3" json:"trusted_cas,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionSSHConfigResponse) Reset() { + *x = SessionSSHConfigResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionSSHConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionSSHConfigResponse) ProtoMessage() {} + +func (x *SessionSSHConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[32] + 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 SessionSSHConfigResponse.ProtoReflect.Descriptor instead. +func (*SessionSSHConfigResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{32} +} + +func (x *SessionSSHConfigResponse) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SessionSSHConfigResponse) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +func (x *SessionSSHConfigResponse) GetTrustedCas() [][]byte { + if x != nil { + return x.TrustedCas + } + return nil +} + +// SignForSSHSessionRequest is a request for SignForSSHSession. +type SignForSSHSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // SessionId is an opaque identifier for the session returned from a previous + // call to SessionSSHConfig. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Sign holds signature request details. + Sign *SignRequest `protobuf:"bytes,2,opt,name=sign,proto3" json:"sign,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForSSHSessionRequest) Reset() { + *x = SignForSSHSessionRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForSSHSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForSSHSessionRequest) ProtoMessage() {} + +func (x *SignForSSHSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[33] + 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 SignForSSHSessionRequest.ProtoReflect.Descriptor instead. +func (*SignForSSHSessionRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{33} +} + +func (x *SignForSSHSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SignForSSHSessionRequest) GetSign() *SignRequest { + if x != nil { + return x.Sign + } + return nil +} + +// SignForSSHSessionResponse is a response for SignForSSHSession. +type SignForSSHSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Signature is the signature. + Signature []byte `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForSSHSessionResponse) Reset() { + *x = SignForSSHSessionResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForSSHSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForSSHSessionResponse) ProtoMessage() {} + +func (x *SignForSSHSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[34] + 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 SignForSSHSessionResponse.ProtoReflect.Descriptor instead. +func (*SignForSSHSessionResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{34} +} + +func (x *SignForSSHSessionResponse) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + var File_teleport_lib_vnet_v1_client_application_service_proto protoreflect.FileDescriptor const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + @@ -1865,11 +2117,29 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\aprofile\x18\x01 \x01(\tR\aprofile\x125\n" + "\x04sign\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04sign\"6\n" + "\x16SignForUserTLSResponse\x12\x1c\n" + + "\tsignature\x18\x01 \x01(\fR\tsignature\"\xa7\x01\n" + + "\x17SessionSSHConfigRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\x12!\n" + + "\froot_cluster\x18\x02 \x01(\tR\vrootCluster\x12!\n" + + "\fleaf_cluster\x18\x03 \x01(\tR\vleafCluster\x12\x18\n" + + "\aaddress\x18\x04 \x01(\tR\aaddress\x12\x12\n" + + "\x04user\x18\x05 \x01(\tR\x04user\"n\n" + + "\x18SessionSSHConfigResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x12\n" + + "\x04cert\x18\x02 \x01(\fR\x04cert\x12\x1f\n" + + "\vtrusted_cas\x18\x03 \x03(\fR\n" + + "trustedCas\"p\n" + + "\x18SignForSSHSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x125\n" + + "\x04sign\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04sign\"9\n" + + "\x19SignForSSHSessionResponse\x12\x1c\n" + "\tsignature\x18\x01 \x01(\fR\tsignature*<\n" + "\x04Hash\x12\x14\n" + "\x10HASH_UNSPECIFIED\x10\x00\x12\r\n" + "\tHASH_NONE\x10\x01\x12\x0f\n" + - "\vHASH_SHA256\x10\x022\xe3\t\n" + + "\vHASH_SHA256\x10\x022\xcc\v\n" + "\x18ClientApplicationService\x12z\n" + "\x13AuthenticateProcess\x120.teleport.lib.vnet.v1.AuthenticateProcessRequest\x1a1.teleport.lib.vnet.v1.AuthenticateProcessResponse\x12\x83\x01\n" + "\x16ReportNetworkStackInfo\x123.teleport.lib.vnet.v1.ReportNetworkStackInfoRequest\x1a4.teleport.lib.vnet.v1.ReportNetworkStackInfoResponse\x12M\n" + @@ -1882,7 +2152,9 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\x12OnInvalidLocalPort\x12/.teleport.lib.vnet.v1.OnInvalidLocalPortRequest\x1a0.teleport.lib.vnet.v1.OnInvalidLocalPortResponse\x12\x89\x01\n" + "\x18GetTargetOSConfiguration\x125.teleport.lib.vnet.v1.GetTargetOSConfigurationRequest\x1a6.teleport.lib.vnet.v1.GetTargetOSConfigurationResponse\x12b\n" + "\vUserTLSCert\x12(.teleport.lib.vnet.v1.UserTLSCertRequest\x1a).teleport.lib.vnet.v1.UserTLSCertResponse\x12k\n" + - "\x0eSignForUserTLS\x12+.teleport.lib.vnet.v1.SignForUserTLSRequest\x1a,.teleport.lib.vnet.v1.SignForUserTLSResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" + "\x0eSignForUserTLS\x12+.teleport.lib.vnet.v1.SignForUserTLSRequest\x1a,.teleport.lib.vnet.v1.SignForUserTLSResponse\x12q\n" + + "\x10SessionSSHConfig\x12-.teleport.lib.vnet.v1.SessionSSHConfigRequest\x1a..teleport.lib.vnet.v1.SessionSSHConfigResponse\x12t\n" + + "\x11SignForSSHSession\x12..teleport.lib.vnet.v1.SignForSSHSessionRequest\x1a/.teleport.lib.vnet.v1.SignForSSHSessionResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" var ( file_teleport_lib_vnet_v1_client_application_service_proto_rawDescOnce sync.Once @@ -1897,7 +2169,7 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP() [] } var file_teleport_lib_vnet_v1_client_application_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 35) var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (Hash)(0), // 0: teleport.lib.vnet.v1.Hash (*AuthenticateProcessRequest)(nil), // 1: teleport.lib.vnet.v1.AuthenticateProcessRequest @@ -1931,7 +2203,11 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (*UserTLSCertResponse)(nil), // 29: teleport.lib.vnet.v1.UserTLSCertResponse (*SignForUserTLSRequest)(nil), // 30: teleport.lib.vnet.v1.SignForUserTLSRequest (*SignForUserTLSResponse)(nil), // 31: teleport.lib.vnet.v1.SignForUserTLSResponse - (*types.AppV3)(nil), // 32: types.AppV3 + (*SessionSSHConfigRequest)(nil), // 32: teleport.lib.vnet.v1.SessionSSHConfigRequest + (*SessionSSHConfigResponse)(nil), // 33: teleport.lib.vnet.v1.SessionSSHConfigResponse + (*SignForSSHSessionRequest)(nil), // 34: teleport.lib.vnet.v1.SignForSSHSessionRequest + (*SignForSSHSessionResponse)(nil), // 35: teleport.lib.vnet.v1.SignForSSHSessionResponse + (*types.AppV3)(nil), // 36: types.AppV3 } var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32{ 4, // 0: teleport.lib.vnet.v1.ReportNetworkStackInfoRequest.network_stack_info:type_name -> teleport.lib.vnet.v1.NetworkStackInfo @@ -1940,7 +2216,7 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32 12, // 3: teleport.lib.vnet.v1.ResolveFQDNResponse.matched_cluster:type_name -> teleport.lib.vnet.v1.MatchedCluster 13, // 4: teleport.lib.vnet.v1.MatchedTCPApp.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 5: teleport.lib.vnet.v1.AppInfo.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 32, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 + 36, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 15, // 7: teleport.lib.vnet.v1.AppInfo.dial_options:type_name -> teleport.lib.vnet.v1.DialOptions 13, // 8: teleport.lib.vnet.v1.ReissueAppCertRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 9: teleport.lib.vnet.v1.SignForAppRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey @@ -1951,33 +2227,38 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32 27, // 14: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse.target_os_configuration:type_name -> teleport.lib.vnet.v1.TargetOSConfiguration 15, // 15: teleport.lib.vnet.v1.UserTLSCertResponse.dial_options:type_name -> teleport.lib.vnet.v1.DialOptions 19, // 16: teleport.lib.vnet.v1.SignForUserTLSRequest.sign:type_name -> teleport.lib.vnet.v1.SignRequest - 1, // 17: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:input_type -> teleport.lib.vnet.v1.AuthenticateProcessRequest - 3, // 18: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:input_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoRequest - 6, // 19: teleport.lib.vnet.v1.ClientApplicationService.Ping:input_type -> teleport.lib.vnet.v1.PingRequest - 8, // 20: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:input_type -> teleport.lib.vnet.v1.ResolveFQDNRequest - 16, // 21: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:input_type -> teleport.lib.vnet.v1.ReissueAppCertRequest - 18, // 22: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:input_type -> teleport.lib.vnet.v1.SignForAppRequest - 21, // 23: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:input_type -> teleport.lib.vnet.v1.OnNewConnectionRequest - 23, // 24: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:input_type -> teleport.lib.vnet.v1.OnInvalidLocalPortRequest - 25, // 25: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:input_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationRequest - 28, // 26: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:input_type -> teleport.lib.vnet.v1.UserTLSCertRequest - 30, // 27: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:input_type -> teleport.lib.vnet.v1.SignForUserTLSRequest - 2, // 28: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse - 5, // 29: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse - 7, // 30: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse - 9, // 31: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse - 17, // 32: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse - 20, // 33: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse - 22, // 34: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:output_type -> teleport.lib.vnet.v1.OnNewConnectionResponse - 24, // 35: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse - 26, // 36: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse - 29, // 37: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:output_type -> teleport.lib.vnet.v1.UserTLSCertResponse - 31, // 38: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:output_type -> teleport.lib.vnet.v1.SignForUserTLSResponse - 28, // [28:39] is the sub-list for method output_type - 17, // [17:28] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 19, // 17: teleport.lib.vnet.v1.SignForSSHSessionRequest.sign:type_name -> teleport.lib.vnet.v1.SignRequest + 1, // 18: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:input_type -> teleport.lib.vnet.v1.AuthenticateProcessRequest + 3, // 19: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:input_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoRequest + 6, // 20: teleport.lib.vnet.v1.ClientApplicationService.Ping:input_type -> teleport.lib.vnet.v1.PingRequest + 8, // 21: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:input_type -> teleport.lib.vnet.v1.ResolveFQDNRequest + 16, // 22: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:input_type -> teleport.lib.vnet.v1.ReissueAppCertRequest + 18, // 23: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:input_type -> teleport.lib.vnet.v1.SignForAppRequest + 21, // 24: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:input_type -> teleport.lib.vnet.v1.OnNewConnectionRequest + 23, // 25: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:input_type -> teleport.lib.vnet.v1.OnInvalidLocalPortRequest + 25, // 26: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:input_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationRequest + 28, // 27: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:input_type -> teleport.lib.vnet.v1.UserTLSCertRequest + 30, // 28: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:input_type -> teleport.lib.vnet.v1.SignForUserTLSRequest + 32, // 29: teleport.lib.vnet.v1.ClientApplicationService.SessionSSHConfig:input_type -> teleport.lib.vnet.v1.SessionSSHConfigRequest + 34, // 30: teleport.lib.vnet.v1.ClientApplicationService.SignForSSHSession:input_type -> teleport.lib.vnet.v1.SignForSSHSessionRequest + 2, // 31: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse + 5, // 32: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse + 7, // 33: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse + 9, // 34: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse + 17, // 35: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse + 20, // 36: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse + 22, // 37: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:output_type -> teleport.lib.vnet.v1.OnNewConnectionResponse + 24, // 38: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse + 26, // 39: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse + 29, // 40: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:output_type -> teleport.lib.vnet.v1.UserTLSCertResponse + 31, // 41: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:output_type -> teleport.lib.vnet.v1.SignForUserTLSResponse + 33, // 42: teleport.lib.vnet.v1.ClientApplicationService.SessionSSHConfig:output_type -> teleport.lib.vnet.v1.SessionSSHConfigResponse + 35, // 43: teleport.lib.vnet.v1.ClientApplicationService.SignForSSHSession:output_type -> teleport.lib.vnet.v1.SignForSSHSessionResponse + 31, // [31:44] is the sub-list for method output_type + 18, // [18:31] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_teleport_lib_vnet_v1_client_application_service_proto_init() } @@ -1997,7 +2278,7 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc), len(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 31, + NumMessages: 35, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go index 8af306472da9f..d1b5e481447ce 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go @@ -46,6 +46,8 @@ const ( ClientApplicationService_GetTargetOSConfiguration_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/GetTargetOSConfiguration" ClientApplicationService_UserTLSCert_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/UserTLSCert" ClientApplicationService_SignForUserTLS_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForUserTLS" + ClientApplicationService_SessionSSHConfig_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SessionSSHConfig" + ClientApplicationService_SignForSSHSession_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForSSHSession" ) // ClientApplicationServiceClient is the client API for ClientApplicationService service. @@ -87,6 +89,11 @@ type ClientApplicationServiceClient interface { UserTLSCert(ctx context.Context, in *UserTLSCertRequest, opts ...grpc.CallOption) (*UserTLSCertResponse, error) // SignForUserTLS signs a digest with the user TLS private key. SignForUserTLS(ctx context.Context, in *SignForUserTLSRequest, opts ...grpc.CallOption) (*SignForUserTLSResponse, error) + // SessionSSHConfig returns the user SSH configuration for an SSH session. + SessionSSHConfig(ctx context.Context, in *SessionSSHConfigRequest, opts ...grpc.CallOption) (*SessionSSHConfigResponse, error) + // SignForSSHSession signs a digest with the SSH private key associated with the + // session from a previous call to SessionSSHConfig. + SignForSSHSession(ctx context.Context, in *SignForSSHSessionRequest, opts ...grpc.CallOption) (*SignForSSHSessionResponse, error) } type clientApplicationServiceClient struct { @@ -207,6 +214,26 @@ func (c *clientApplicationServiceClient) SignForUserTLS(ctx context.Context, in return out, nil } +func (c *clientApplicationServiceClient) SessionSSHConfig(ctx context.Context, in *SessionSSHConfigRequest, opts ...grpc.CallOption) (*SessionSSHConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SessionSSHConfigResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_SessionSSHConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *clientApplicationServiceClient) SignForSSHSession(ctx context.Context, in *SignForSSHSessionRequest, opts ...grpc.CallOption) (*SignForSSHSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SignForSSHSessionResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_SignForSSHSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ClientApplicationServiceServer is the server API for ClientApplicationService service. // All implementations must embed UnimplementedClientApplicationServiceServer // for forward compatibility. @@ -246,6 +273,11 @@ type ClientApplicationServiceServer interface { UserTLSCert(context.Context, *UserTLSCertRequest) (*UserTLSCertResponse, error) // SignForUserTLS signs a digest with the user TLS private key. SignForUserTLS(context.Context, *SignForUserTLSRequest) (*SignForUserTLSResponse, error) + // SessionSSHConfig returns the user SSH configuration for an SSH session. + SessionSSHConfig(context.Context, *SessionSSHConfigRequest) (*SessionSSHConfigResponse, error) + // SignForSSHSession signs a digest with the SSH private key associated with the + // session from a previous call to SessionSSHConfig. + SignForSSHSession(context.Context, *SignForSSHSessionRequest) (*SignForSSHSessionResponse, error) mustEmbedUnimplementedClientApplicationServiceServer() } @@ -289,6 +321,12 @@ func (UnimplementedClientApplicationServiceServer) UserTLSCert(context.Context, func (UnimplementedClientApplicationServiceServer) SignForUserTLS(context.Context, *SignForUserTLSRequest) (*SignForUserTLSResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SignForUserTLS not implemented") } +func (UnimplementedClientApplicationServiceServer) SessionSSHConfig(context.Context, *SessionSSHConfigRequest) (*SessionSSHConfigResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SessionSSHConfig not implemented") +} +func (UnimplementedClientApplicationServiceServer) SignForSSHSession(context.Context, *SignForSSHSessionRequest) (*SignForSSHSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SignForSSHSession not implemented") +} func (UnimplementedClientApplicationServiceServer) mustEmbedUnimplementedClientApplicationServiceServer() { } func (UnimplementedClientApplicationServiceServer) testEmbeddedByValue() {} @@ -509,6 +547,42 @@ func _ClientApplicationService_SignForUserTLS_Handler(srv interface{}, ctx conte return interceptor(ctx, in, info, handler) } +func _ClientApplicationService_SessionSSHConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SessionSSHConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).SessionSSHConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_SessionSSHConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).SessionSSHConfig(ctx, req.(*SessionSSHConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClientApplicationService_SignForSSHSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SignForSSHSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).SignForSSHSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_SignForSSHSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).SignForSSHSession(ctx, req.(*SignForSSHSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ClientApplicationService_ServiceDesc is the grpc.ServiceDesc for ClientApplicationService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -560,6 +634,14 @@ var ClientApplicationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SignForUserTLS", Handler: _ClientApplicationService_SignForUserTLS_Handler, }, + { + MethodName: "SessionSSHConfig", + Handler: _ClientApplicationService_SessionSSHConfig_Handler, + }, + { + MethodName: "SignForSSHSession", + Handler: _ClientApplicationService_SignForSSHSession_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "teleport/lib/vnet/v1/client_application_service.proto", diff --git a/lib/client/cluster_client.go b/lib/client/cluster_client.go index f636c5d19d32b..40962d53ccc74 100644 --- a/lib/client/cluster_client.go +++ b/lib/client/cluster_client.go @@ -282,28 +282,66 @@ func (c *ClusterClient) SessionSSHConfig(ctx context.Context, user string, targe return sshConfig, nil } - keyRing, err := c.tc.localAgent.GetKeyRing(target.Cluster, WithAllCerts...) + newKeyRing, completedMFA, err := c.SessionSSHKeyRing(ctx, user, target) if err != nil { - return nil, trace.Wrap(MFARequiredUnknown(err)) + return nil, trace.Wrap(err) + } + if !completedMFA { + // The caller relies on this function returning an error if + // target.MFACheck is nil and session MFA was not actually required. + return nil, trace.Wrap(services.ErrSessionMFANotRequired) + } + + am, err := newKeyRing.AsAuthMethod() + if err != nil { + return nil, trace.Wrap(ceremonyFailedErr{err}) + } + + sshConfig.Auth = []ssh.AuthMethod{am} + return sshConfig, nil +} + +// SessionSSHKeyRing returns a KeyRing valid for an SSH session to the target. +// If per session MFA is required to establish the connection, then the MFA +// ceremony will be performed. If per session MFA is not required, the user's +// base KeyRing for the cluster will be returned. +func (c *ClusterClient) SessionSSHKeyRing(ctx context.Context, user string, target NodeDetails) (keyRing *KeyRing, completedMFA bool, err error) { + ctx, span := c.Tracer.Start( + ctx, + "clusterClient/SessionSSHKeyRing", + oteltrace.WithSpanKind(oteltrace.SpanKindClient), + oteltrace.WithAttributes( + attribute.String("cluster", c.tc.SiteName), + ), + ) + defer span.End() + + baseKeyRing, err := c.tc.localAgent.GetKeyRing(target.Cluster, WithSSHCerts{}) + if err != nil { + return nil, false, trace.Wrap(MFARequiredUnknown(err)) + } + + if target.MFACheck != nil && !target.MFACheck.Required { + return baseKeyRing, false, nil } // Always connect to root for getting new credentials, but attempt to reuse // the existing client if possible. - rootClusterName, err := keyRing.RootClusterName() + rootClusterName, err := baseKeyRing.RootClusterName() if err != nil { - return nil, trace.Wrap(MFARequiredUnknown(err)) + return nil, false, trace.Wrap(MFARequiredUnknown(err)) } mfaClt := c if target.Cluster != rootClusterName { cfg, err := c.ProxyClient.ClientConfig(ctx, rootClusterName) if err != nil { - return nil, trace.Wrap(err) + return nil, false, trace.Wrap(err) } authClient, err := authclient.NewClient(cfg) if err != nil { - return nil, trace.Wrap(MFARequiredUnknown(err)) + return nil, false, trace.Wrap(MFARequiredUnknown(err)) } mfaClt = &ClusterClient{ @@ -326,20 +364,19 @@ func (c *ClusterClient) SessionSSHConfig(ctx context.Context, user string, targe RouteToCluster: target.Cluster, MFACheck: target.MFACheck, }, - keyRing, + baseKeyRing.Copy(), ) if err != nil { - return nil, trace.Wrap(err) + if errors.Is(err, services.ErrSessionMFANotRequired) { + log.DebugContext(ctx, "Session MFA was not required, returning original KeyRing") + return baseKeyRing, false, nil + } + log.DebugContext(ctx, "Error performing session MFA ceremony", "error", err) + return nil, false, trace.Wrap(err) } log.DebugContext(ctx, "Issued single-use user certificate after an MFA check") - am, err := result.KeyRing.AsAuthMethod() - if err != nil { - return nil, trace.Wrap(ceremonyFailedErr{err}) - } - - sshConfig.Auth = []ssh.AuthMethod{am} - return sshConfig, nil + return result.KeyRing, true, nil } // prepareUserCertsRequest creates a [proto.UserCertsRequest] with the fields diff --git a/lib/vnet/client_application_service.go b/lib/vnet/client_application_service.go index 898089ebc3a6d..8be84e5d61ac5 100644 --- a/lib/vnet/client_application_service.go +++ b/lib/vnet/client_application_service.go @@ -17,16 +17,24 @@ package vnet import ( + "cmp" "context" "crypto" "crypto/rand" "crypto/rsa" "sync" + "time" + "github.com/google/uuid" "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "golang.org/x/crypto/ssh" "github.com/gravitational/teleport/api" + "github.com/gravitational/teleport/api/client/proto" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/utils" ) // clientApplicationService implements the gRPC @@ -43,8 +51,8 @@ type clientApplicationService struct { // ReportNetworkStackInfo. networkStackInfo chan *vnetv1.NetworkStackInfo - // mu protects appSignerCache - mu sync.Mutex + // appSignerMu protects appSignerCache. + appSignerMu sync.Mutex // appSignerCache caches the crypto.Signer for each certificate issued by // ReissueAppCert so that SignForApp can later use that signer. // @@ -53,20 +61,35 @@ type clientApplicationService struct { // ReissueAppCert, which will overwrite the signer for the app with a new // one. appSignerCache map[appKey]crypto.Signer + + // sshSigners is a cache containing [crypto.Signer]s keyed by SSH session + // ID. This "session ID" is a concept only used here for retrieving a signer + // previously associated with the same session, it is not some Teleport + // session identifier. + sshSigners *utils.FnCache } type clientApplicationServiceConfig struct { fqdnResolver *fqdnResolver localOSConfigProvider *LocalOSConfigProvider clientApplication ClientApplication + clock clockwork.Clock } -func newClientApplicationService(cfg *clientApplicationServiceConfig) *clientApplicationService { +func newClientApplicationService(cfg *clientApplicationServiceConfig) (*clientApplicationService, error) { + sshSigners, err := utils.NewFnCache(utils.FnCacheConfig{ + TTL: time.Minute, + Clock: cfg.clock, + }) + if err != nil { + return nil, trace.Wrap(err) + } return &clientApplicationService{ cfg: cfg, networkStackInfo: make(chan *vnetv1.NetworkStackInfo, 1), appSignerCache: make(map[appKey]crypto.Signer), - } + sshSigners: sshSigners, + }, nil } // AuthenticateProcess implements [vnetv1.ClientApplicationServiceServer.AuthenticateProcess]. @@ -181,14 +204,14 @@ func sign(signer crypto.Signer, signReq *vnetv1.SignRequest) ([]byte, error) { } func (s *clientApplicationService) setSignerForApp(appKey *vnetv1.AppKey, targetPort uint16, signer crypto.Signer) { - s.mu.Lock() - defer s.mu.Unlock() + s.appSignerMu.Lock() + defer s.appSignerMu.Unlock() s.appSignerCache[newAppKey(appKey, targetPort)] = signer } func (s *clientApplicationService) getSignerForApp(appKey *vnetv1.AppKey, targetPort uint16) (crypto.Signer, bool) { - s.mu.Lock() - defer s.mu.Unlock() + s.appSignerMu.Lock() + defer s.appSignerMu.Unlock() signer, ok := s.appSignerCache[newAppKey(appKey, targetPort)] return signer, ok } @@ -278,6 +301,105 @@ func (s *clientApplicationService) SignForUserTLS(ctx context.Context, req *vnet }, nil } +// SessionSSHConfig returns user SSH configuration values for an SSH session. +func (s *clientApplicationService) SessionSSHConfig(ctx context.Context, req *vnetv1.SessionSSHConfigRequest) (*vnetv1.SessionSSHConfigResponse, error) { + clusterClient, err := s.cfg.clientApplication.GetCachedClient(ctx, req.GetProfile(), req.GetLeafCluster()) + if err != nil { + return nil, trace.Wrap(err) + } + // If req.LeafCluster is not empty the node is in the leaf cluster, else it + // is in the root cluster. + targetCluster := cmp.Or(req.GetLeafCluster(), req.GetRootCluster()) + target := client.NodeDetails{ + Addr: req.GetAddress(), + Cluster: targetCluster, + } + keyRing, completedMFA, err := clusterClient.SessionSSHKeyRing(ctx, req.GetUser(), target) + if err != nil { + return nil, trace.Wrap(err, "getting KeyRing for SSH session") + } + if !completedMFA && keyRing.Cert == nil && targetCluster == req.GetLeafCluster() { + // It's possible/likely the user doesn't have an SSH cert specifically + // for the leaf cluster. Luckily if MFA was not required, the root + // cluster cert should work. + log.DebugContext(ctx, "Leaf cluster KeyRing had no SSH cert, using root cluster KeyRing") + rootClusterClient, err := s.cfg.clientApplication.GetCachedClient(ctx, req.GetProfile(), "") + if err != nil { + return nil, trace.Wrap(err) + } + // Set the target cluster to the root cluster and disable the MFA check + // so that SessionSSHKeyRing will just return the base root cluster + // keyring. + target.Cluster = req.GetRootCluster() + target.MFACheck = &proto.IsMFARequiredResponse{ + Required: false, + MFARequired: proto.MFARequired_MFA_REQUIRED_NO, + } + keyRing, _, err = rootClusterClient.SessionSSHKeyRing(ctx, req.GetUser(), target) + if err != nil { + return nil, trace.Wrap(err, "getting root cluster KeyRing for SSH session") + } + } + if len(keyRing.Cert) == 0 { + return nil, trace.Errorf("user KeyRing has no SSH cert") + } + sshCert, _, _, _, err := ssh.ParseAuthorizedKey(keyRing.Cert) + if err != nil { + return nil, trace.Wrap(err, "parsing user SSH certificate") + } + var trustedCAs [][]byte + for _, trustedCert := range keyRing.TrustedCerts { + if trustedCert.ClusterName != targetCluster { + continue + } + for _, authorizedKey := range trustedCert.AuthorizedKeys { + trustedCA, _, _, _, err := ssh.ParseAuthorizedKey(authorizedKey) + if err != nil { + return nil, trace.Wrap(err, "parsing CA cert") + } + trustedCAs = append(trustedCAs, trustedCA.Marshal()) + } + } + if len(trustedCAs) == 0 { + return nil, trace.Errorf("user KeyRing host no trusted SSH CAs for cluster %s", targetCluster) + } + sessionID := s.setSignerForSSHSession(keyRing.SSHPrivateKey) + return &vnetv1.SessionSSHConfigResponse{ + SessionId: sessionID, + Cert: sshCert.Marshal(), + TrustedCas: trustedCAs, + }, nil +} + +// SignForSSHSession signs a digest with the SSH private key associated with the +// session from a previous call to SessionSSHConfig. +func (s *clientApplicationService) SignForSSHSession(ctx context.Context, req *vnetv1.SignForSSHSessionRequest) (*vnetv1.SignForSSHSessionResponse, error) { + signer, err := s.getSignerForSSHSession(ctx, req.GetSessionId()) + if err != nil { + return nil, trace.Wrap(err) + } + signature, err := sign(signer, req.GetSign()) + if err != nil { + return nil, trace.Wrap(err) + } + return &vnetv1.SignForSSHSessionResponse{ + Signature: signature, + }, nil +} + +func (s *clientApplicationService) setSignerForSSHSession(signer crypto.Signer) string { + sessionID := uuid.NewString() + s.sshSigners.Set(sessionID, signer) + return sessionID +} + +func (s *clientApplicationService) getSignerForSSHSession(ctx context.Context, sessionID string) (crypto.Signer, error) { + signer, err := utils.FnCacheGet(ctx, s.sshSigners, sessionID, func(ctx context.Context) (crypto.Signer, error) { + return nil, trace.NotFound("session key expired") + }) + return signer, trace.Wrap(err) +} + // checkAppKey checks that at least the app profile and name are set, which are // necessary to to disambiguate apps. LeafCluster is expected to be empty if the // app is in a root cluster. diff --git a/lib/vnet/client_application_service_client.go b/lib/vnet/client_application_service_client.go index 394e327a28158..bf8149a5a2e7d 100644 --- a/lib/vnet/client_application_service_client.go +++ b/lib/vnet/client_application_service_client.go @@ -187,6 +187,31 @@ func (c *clientApplicationServiceClient) SignForUserTLS(ctx context.Context, req return resp.GetSignature(), nil } +// SessionSSHConfig returns user SSH configuration values for an SSH session. +func (c *clientApplicationServiceClient) SessionSSHConfig(ctx context.Context, target dialTarget, user string) (*vnetv1.SessionSSHConfigResponse, error) { + resp, err := c.clt.SessionSSHConfig(ctx, &vnetv1.SessionSSHConfigRequest{ + Profile: target.profile, + RootCluster: target.rootCluster, + LeafCluster: target.leafCluster, + Address: target.addr, + User: user, + }) + return resp, trace.Wrap(err, "calling SessionSSHConfig rpc") +} + +// SignForSSHSession signs a digest with the SSH private key associated with the +// session from a previous call to SessionSSHConfig. +func (c *clientApplicationServiceClient) SignForSSHSession(ctx context.Context, sessionID string, sign *vnetv1.SignRequest) ([]byte, error) { + resp, err := c.clt.SignForSSHSession(ctx, &vnetv1.SignForSSHSessionRequest{ + SessionId: sessionID, + Sign: sign, + }) + if err != nil { + return nil, trace.Wrap(err, "calling SignForSSHSession rpc") + } + return resp.GetSignature(), nil +} + // rpcSigner implements [crypto.Signer] for signatures that are issued by the // client application over gRPC. type rpcSigner struct { diff --git a/lib/vnet/ssh_handler.go b/lib/vnet/ssh_handler.go index f8e8e831ea24b..02b95f548d325 100644 --- a/lib/vnet/ssh_handler.go +++ b/lib/vnet/ssh_handler.go @@ -19,14 +19,17 @@ package vnet import ( "context" "crypto/rand" + "fmt" "net" "strings" "github.com/gravitational/trace" "golang.org/x/crypto/ssh" + tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh" "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/cryptosuites" + "github.com/gravitational/teleport/lib/utils" ) // sshHandler handles incoming VNet SSH connections. @@ -67,7 +70,8 @@ func (h *sshHandler) handleTCPConnectorWithTargetConn( connector func() (net.Conn, error), targetConn net.Conn, ) error { - hostCert, err := h.newHostCert(ctx) + target := h.cfg.target + hostCert, err := newHostCert(target.fqdn, h.cfg.sshProvider.hostCASigner) if err != nil { return trace.Wrap(err) } @@ -78,47 +82,111 @@ func (h *sshHandler) handleTCPConnectorWithTargetConn( } defer localConn.Close() - // For now we accept the incoming SSH connection but forwarding to the - // target is not implemented yet so we immediately close it. + var ( + clientConn *sshConn + clientConnErr error + initiatedSSHConn bool + ) serverConfig := &ssh.ServerConfig{ + // We attempt to initiate an SSH connection with the target server in + // PublicKeyCallback in order to fail the SSH authentication phase with + // the client if SSH authentication to the target fails. Otherwise, when + // connection to an SSH node the user is not allowed to access, they + // would just see an succesfull SSH handshake and then an immediately + // closed connection. + // + // TODO(nklaassen): if https://github.com/golang/go/issues/70795 ever + // gets implemented we should do this in VerifiedPublicKeyCallback + // instead. PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { if !sshutils.KeysEqual(h.cfg.sshProvider.trustedUserPublicKey, key) { - return nil, trace.AccessDenied("SSH client public key is not trusted") + return nil, trace.AccessDenied("client public key is not trusted") + } + // Make sure to only initiate the SSH connection once in case + // PublicKeyCallback is called multiple times. + if initiatedSSHConn { + return nil, clientConnErr + } + initiatedSSHConn = true + clientConn, clientConnErr = h.initiateSSHConn(ctx, targetConn, conn.User()) + if clientConnErr != nil { + // Attempt to send a friendlier errer message if we failed to + // initiate the SSH connection to the target by sending an auth + // banner message. + if utils.IsHandshakeFailedError(clientConnErr) { + // We don't have much real information about the error in + // this case, this is the same message tsh prints. + return nil, &ssh.BannerError{ + Err: clientConnErr, + Message: formatBannerMessage(fmt.Sprintf("access denied to %s connecting to %s", conn.User(), target.hostname)), + } + } + return nil, &ssh.BannerError{ + Err: clientConnErr, + Message: formatBannerMessage(trace.UserMessage(clientConnErr)), + } } return nil, nil }, } serverConfig.AddHostKey(hostCert) - serverConn, chans, reqs, err := ssh.NewServerConn(localConn, serverConfig) + + serverConn, serverChans, serverReqs, err := ssh.NewServerConn(localConn, serverConfig) if err != nil { + // Make sure to close the client conn if we already accepted it. + if clientConn != nil { + clientConn.Close() + } return trace.Wrap(err, "accepting incoming SSH connection") } - // Immediately close the connection but make sure to drain the channels. - serverConn.Close() - go ssh.DiscardRequests(reqs) - go func() { - for newChan := range chans { - _ = newChan.Reject(0, "") - } - }() - target := h.cfg.target log.DebugContext(ctx, "Accepted incoming SSH connection", "profile", target.profile, "cluster", target.cluster, - "host", target.host, + "host", target.hostname, "user", serverConn.User(), ) - return trace.NotImplemented("VNet SSH connection forwarding is not yet implemented") + + // proxySSHConnection transparently proxies the SSH connection from the + // client to the target. It will handle closing the connections before it + // returns. + proxySSHConnection(ctx, + sshConn{ + conn: serverConn, + chans: serverChans, + reqs: serverReqs, + }, + *clientConn, + ) + return nil +} + +func (h *sshHandler) initiateSSHConn(ctx context.Context, targetConn net.Conn, user string) (*sshConn, error) { + target := h.cfg.target + clientConfig, err := h.cfg.sshProvider.sessionSSHConfig(ctx, target, user) + if err != nil { + return nil, trace.Wrap(err, "building SSH client config") + } + clientConn, clientChans, clientReqs, err := tracessh.NewClientConn(ctx, targetConn, target.addr, clientConfig) + if err != nil { + return nil, trace.Wrap(err, "initiating SSH connection to %s@%s", user, target.addr) + } + log.DebugContext(ctx, "Initiated SSH connection to target", "root_cluster", target.rootCluster, + "leaf_cluster", target.leafCluster, "host", target.addr) + return &sshConn{ + conn: clientConn, + chans: clientChans, + reqs: clientReqs, + }, nil } -func (h *sshHandler) newHostCert(ctx context.Context) (ssh.Signer, error) { +func newHostCert(fqdn string, ca ssh.Signer) (ssh.Signer, error) { // If the user typed "ssh host.com" or "ssh host.com." our DNS handler will // only see the fully-qualified variant with the trailing "." but the SSH // client treats them differently, we need both in the principals if we want // the cert to be trusted in both cases. validPrincipals := []string{ - h.cfg.target.fqdn, - strings.TrimSuffix(h.cfg.target.fqdn, "."), + fqdn, + strings.TrimSuffix(fqdn, "."), } // We generate an ephemeral key for every connection, Ed25519 is fast and // well supported. @@ -141,9 +209,13 @@ func (h *sshHandler) newHostCert(ctx context.Context) (ssh.Signer, error) { // host. The expiry doesn't matter. ValidBefore: ssh.CertTimeInfinity, } - if err := cert.SignCert(rand.Reader, h.cfg.sshProvider.hostCASigner); err != nil { + if err := cert.SignCert(rand.Reader, ca); err != nil { return nil, trace.Wrap(err, "signing SSH host cert") } certSigner, err := ssh.NewCertSigner(cert, hostSigner) return certSigner, trace.Wrap(err) } + +func formatBannerMessage(msg string) string { + return "VNet: " + msg + "\n" +} diff --git a/lib/vnet/ssh_provider.go b/lib/vnet/ssh_provider.go index 1e0cc00e2ebd1..9bdc302a50b38 100644 --- a/lib/vnet/ssh_provider.go +++ b/lib/vnet/ssh_provider.go @@ -28,6 +28,7 @@ import ( "golang.org/x/crypto/ssh" proxyclient "github.com/gravitational/teleport/api/client/proxy" + "github.com/gravitational/teleport/api/utils/sshutils" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/cryptosuites" ) @@ -135,7 +136,7 @@ func (p *sshProvider) dialViaProxy( return nil, trace.Wrap(err, "building proxy client") } // TODO(nklaassen): pass an SSH keyring to support proxy recording mode. - conn, _, err := pclt.DialHost(ctx, target.host, target.cluster, nil /*keyRing*/) + conn, _, err := pclt.DialHost(ctx, target.addr, target.cluster, nil /*keyRing*/) if err != nil { pclt.Close() return nil, trace.Wrap(err, "dialing target via proxy") @@ -181,27 +182,99 @@ func (p *sshProvider) userTLSConfig( }, nil } +func (p *sshProvider) sessionSSHConfig( + ctx context.Context, + target dialTarget, + user string, +) (*ssh.ClientConfig, error) { + // TODO(nklaassen): cache session SSH configs so we don't have to regenerate + // every time. + resp, err := p.cfg.clt.SessionSSHConfig(ctx, target, user) + if err != nil { + return nil, trace.Wrap(err) + } + sshPub, err := ssh.ParsePublicKey(resp.GetCert()) + if err != nil { + return nil, trace.Wrap(err, "parsing session SSH cert") + } + sshCert, ok := sshPub.(*ssh.Certificate) + if !ok { + return nil, trace.BadParameter("expected ssh.Certificate, got %T", sshCert) + } + cryptoPub, ok := sshCert.Key.(ssh.CryptoPublicKey) + if !ok { + return nil, trace.BadParameter("expected SSH key to implement CryptoPublicKey, got %T", sshCert.Key) + } + sessionID := resp.GetSessionId() + signer := &rpcSigner{ + pub: cryptoPub.CryptoPublicKey(), + sendRequest: func(req *vnetv1.SignRequest) ([]byte, error) { + return p.cfg.clt.SignForSSHSession(ctx, sessionID, req) + }, + } + sshSigner, err := ssh.NewSignerFromSigner(signer) + if err != nil { + return nil, trace.Wrap(err) + } + certSigner, err := ssh.NewCertSigner(sshCert, sshSigner) + if err != nil { + return nil, trace.Wrap(err) + } + hostKeyCallback, err := buildHostKeyCallback(resp.GetTrustedCas(), p.cfg.clock) + if err != nil { + return nil, trace.Wrap(err) + } + return &ssh.ClientConfig{ + Auth: []ssh.AuthMethod{ssh.PublicKeys(certSigner)}, + User: user, + HostKeyCallback: hostKeyCallback, + }, nil +} + +func buildHostKeyCallback(trustedCAs [][]byte, clock clockwork.Clock) (ssh.HostKeyCallback, error) { + var caKeys []ssh.PublicKey + for _, trustedCA := range trustedCAs { + caKey, err := ssh.ParsePublicKey(trustedCA) + if err != nil { + return nil, trace.Wrap(err, "parsing trusted CA key") + } + caKeys = append(caKeys, caKey) + } + hostKeyCallback, err := sshutils.NewHostKeyCallback(sshutils.HostKeyCallbackConfig{ + GetHostCheckers: func() ([]ssh.PublicKey, error) { + return caKeys, nil + }, + Clock: clock, + }) + return hostKeyCallback, trace.Wrap(err, "building host key callback") +} + type dialTarget struct { - fqdn string - profile string - cluster string - host string + fqdn string + profile string + rootCluster string + leafCluster string + cluster string + hostname string + addr string } func computeDialTarget(matchedCluster *vnetv1.MatchedCluster, fqdn string) dialTarget { - targetProfile := matchedCluster.GetProfile() targetCluster := matchedCluster.GetRootCluster() targetHost := strings.TrimSuffix(fqdn, "."+matchedCluster.GetRootCluster()+".") - if leafCluster := matchedCluster.GetLeafCluster(); leafCluster != "" { + leafCluster := matchedCluster.GetLeafCluster() + if leafCluster != "" { targetCluster = leafCluster targetHost = strings.TrimSuffix(targetHost, "."+leafCluster) } - targetHost = targetHost + ":0" return dialTarget{ - fqdn: fqdn, - profile: targetProfile, - cluster: targetCluster, - host: targetHost, + fqdn: fqdn, + profile: matchedCluster.GetProfile(), + rootCluster: matchedCluster.GetRootCluster(), + leafCluster: leafCluster, + cluster: targetCluster, + hostname: targetHost, + addr: targetHost + ":0", } } diff --git a/lib/vnet/ssh_proxy.go b/lib/vnet/ssh_proxy.go index 35c1f44e17b13..9dd2c2a0235e6 100644 --- a/lib/vnet/ssh_proxy.go +++ b/lib/vnet/ssh_proxy.go @@ -22,6 +22,7 @@ import ( "log/slog" "sync" + "github.com/gravitational/trace" "golang.org/x/crypto/ssh" "github.com/gravitational/teleport/lib/utils" @@ -34,6 +35,16 @@ type sshConn struct { reqs <-chan *ssh.Request } +// Close closes the connection and drains all channels. +func (c *sshConn) Close() error { + err := trace.Wrap(c.conn.Close()) + go ssh.DiscardRequests(c.reqs) + for newChan := range c.chans { + newChan.Reject(0, "") + } + return err +} + // proxySSHConnection transparently proxies SSH channels and requests // between 2 established SSH connections. serverConn represents an incoming SSH // connection where this proxy acts as a server, client represents an outgoing @@ -44,8 +55,8 @@ func proxySSHConnection( clientConn sshConn, ) { closeConnections := sync.OnceFunc(func() { - clientConn.conn.Close() - serverConn.conn.Close() + clientConn.Close() + serverConn.Close() }) // Close both connections if the context is canceled. stop := context.AfterFunc(ctx, closeConnections) diff --git a/lib/vnet/ssh_proxy_test.go b/lib/vnet/ssh_proxy_test.go index cca3f6a72862a..154d74ae0458b 100644 --- a/lib/vnet/ssh_proxy_test.go +++ b/lib/vnet/ssh_proxy_test.go @@ -103,6 +103,11 @@ func testSSHConnection(t *testing.T, dial dialer) { sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, "localhost", clientConfig) require.NoError(t, err) defer sshConn.Close() + + testConnectionToSshEchoServer(t, sshConn, chans, reqs) +} + +func testConnectionToSshEchoServer(t *testing.T, sshConn ssh.Conn, chans <-chan ssh.NewChannel, reqs <-chan *ssh.Request) { go ssh.DiscardRequests(reqs) go func() { for newChan := range chans { diff --git a/lib/vnet/user_process.go b/lib/vnet/user_process.go index acaa234a7cd1f..75df45f7fd196 100644 --- a/lib/vnet/user_process.go +++ b/lib/vnet/user_process.go @@ -25,6 +25,7 @@ import ( 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" ) // ClientApplication is the common interface implemented by each VNet client @@ -70,6 +71,7 @@ type ClusterClient interface { CurrentCluster() authclient.ClientI ClusterName() string RootClusterName() string + SessionSSHKeyRing(ctx context.Context, user string, target client.NodeDetails) (keyRing *client.KeyRing, completedMFA bool, err error) } // RunUserProcess is the entry point called by all VNet client applications @@ -101,11 +103,15 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) - clientApplicationService := newClientApplicationService(&clientApplicationServiceConfig{ + clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{ clientApplication: clientApplication, fqdnResolver: fqdnResolver, localOSConfigProvider: osConfigProvider, + clock: clock, }) + if err != nil { + return nil, trace.Wrap(err) + } userProcess := &UserProcess{ clientApplication: clientApplication, osConfigProvider: osConfigProvider, diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index 3063f7d1a288a..e01ad78feebc1 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -62,7 +62,9 @@ import ( "github.com/gravitational/teleport/api/utils/sshutils" 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" "github.com/gravitational/teleport/lib/cryptosuites" + alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" ) @@ -277,11 +279,13 @@ func runTestClientApplicationService(t *testing.T, ctx context.Context, clock cl clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) - clientApplicationService := newClientApplicationService(&clientApplicationServiceConfig{ + clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{ clientApplication: clientApp, fqdnResolver: fqdnResolver, localOSConfigProvider: nil, // OS configuration is not needed in tests. + clock: clock, }) + require.NoError(t, err) ipcCredentials, err := newIPCCredentials() require.NoError(t, err) @@ -333,7 +337,9 @@ type appSpec struct { tcpPorts []*types.PortRange } -type nodeSpec struct{} +type nodeSpec struct { + denyAccess bool +} type testClusterSpec struct { apps []appSpec @@ -346,9 +352,15 @@ type testClusterSpec struct { type fakeClientApp struct { cfg *fakeClientAppConfig - tlsCA tls.Certificate - userTLSCert tls.Certificate - dialOpts *vnetv1.DialOptions + tlsCA tls.Certificate + dialOpts *vnetv1.DialOptions + + userTLSCertMu sync.Mutex + userTLSCert tls.Certificate + userTLSCertExpires time.Time + + teleportHostCA ssh.Signer + teleportUserCA ssh.Signer onNewConnectionCallCount atomic.Uint32 onInvalidLocalPortCallCount atomic.Uint32 @@ -368,20 +380,31 @@ type fakeClientAppConfig struct { // able to run with any implementation of [ClientApplication] and little to no // other configuration. func newFakeClientApp(ctx context.Context, t *testing.T, cfg *fakeClientAppConfig) *fakeClientApp { - tlsCA := newSelfSignedCA(t) - dialOpts := mustStartFakeWebProxy(ctx, t, tlsCA, cfg.clock, cfg.signatureAlgorithmSuite) - userTLSCert, err := newClientCert(ctx, - tlsCA, - "testuser", - cfg.clock.Now().Add(defaults.CertDuration), - cfg.signatureAlgorithmSuite, - cryptosuites.UserTLS) + teleportHostCAKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + require.NoError(t, err) + teleportHostCA, err := ssh.NewSignerFromSigner(teleportHostCAKey) + require.NoError(t, err) + + teleportUserCAKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) require.NoError(t, err) + teleportUserCA, err := ssh.NewSignerFromSigner(teleportUserCAKey) + require.NoError(t, err) + + tlsCA := newSelfSignedCA(t) + dialOpts := mustStartFakeWebProxy(ctx, t, fakeWebProxyConfig{ + tlsCA: tlsCA, + hostCA: teleportHostCA, + userCA: teleportUserCA, + clock: cfg.clock, + suite: cfg.signatureAlgorithmSuite, + }) + return &fakeClientApp{ cfg: cfg, tlsCA: tlsCA, - userTLSCert: userTLSCert, dialOpts: dialOpts, + teleportHostCA: teleportHostCA, + teleportUserCA: teleportUserCA, requestedRouteToApps: make(map[string][]*proto.RouteToApp), } } @@ -406,6 +429,10 @@ func (p *fakeClientApp) GetCachedClient(ctx context.Context, profileName, leafCl clusterName: profileName, rootClusterName: profileName, }, + clusterSpec: &rootCluster, + teleportHostCA: p.teleportHostCA, + teleportUserCA: p.teleportUserCA, + clock: p.cfg.clock, }, nil } leafCluster, ok := rootCluster.leafClusters[leafClusterName] @@ -418,6 +445,10 @@ func (p *fakeClientApp) GetCachedClient(ctx context.Context, profileName, leafCl clusterName: leafClusterName, rootClusterName: profileName, }, + clusterSpec: &leafCluster, + teleportHostCA: p.teleportHostCA, + teleportUserCA: p.teleportUserCA, + clock: p.cfg.clock, }, nil } @@ -437,7 +468,26 @@ func (p *fakeClientApp) ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppI } func (p *fakeClientApp) UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) { - return p.userTLSCert, nil + p.userTLSCertMu.Lock() + defer p.userTLSCertMu.Unlock() + + now := p.cfg.clock.Now() + if now.Before(p.userTLSCertExpires) { + return p.userTLSCert, nil + } + expiry := now.Add(defaults.CertDuration) + userTLSCert, err := newClientCert(ctx, + p.tlsCA, + "testuser", + expiry, + p.cfg.signatureAlgorithmSuite, + cryptosuites.UserTLS) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + p.userTLSCert = userTLSCert + p.userTLSCertExpires = expiry + return userTLSCert, nil } func (p *fakeClientApp) RequestedRouteToApps(publicAddr string) []*proto.RouteToApp { @@ -531,16 +581,19 @@ func (p *fakeClientApp) dialSSHNode( return nil, trace.NotFound("no such cluster") } } - if _, ok := targetCluster.nodes[strings.TrimSuffix(target.host, ":0")]; !ok { + if _, ok := targetCluster.nodes[target.hostname]; !ok { return nil, trace.NotFound("no such host") } - // For now just let it dial the fake web proxy, later we'll need to set up a - // fake SSH server for the test to dial to. + tlsConfig.NextProtos = []string{string(alpncommon.ProtocolProxySSH)} return tls.Dial("tcp", dialOpts.GetWebProxyAddr(), tlsConfig) } type fakeClusterClient struct { - authClient *fakeAuthClient + authClient *fakeAuthClient + clusterSpec *testClusterSpec + teleportHostCA ssh.Signer + teleportUserCA ssh.Signer + clock clockwork.Clock } func (c *fakeClusterClient) CurrentCluster() authclient.ClientI { @@ -555,6 +608,52 @@ func (c *fakeClusterClient) RootClusterName() string { return c.authClient.rootClusterName } +func (c *fakeClusterClient) SessionSSHKeyRing(ctx context.Context, user string, target client.NodeDetails) (*client.KeyRing, bool, error) { + targetHost, _, err := net.SplitHostPort(target.Addr) + if err != nil { + return nil, false, trace.Wrap(err) + } + nodeSpec, ok := c.clusterSpec.nodes[targetHost] + if !ok { + return nil, false, trace.NotFound("no such node") + } + if nodeSpec.denyAccess { + return nil, false, trace.AccessDenied("access denied to %s", targetHost) + } + userSSHKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, false, trace.Wrap(err) + } + userSSHSigner, err := ssh.NewSignerFromSigner(userSSHKey) + if err != nil { + return nil, false, trace.Wrap(err) + } + now := c.clock.Now() + cert := &ssh.Certificate{ + Key: userSSHSigner.PublicKey(), + Serial: 1, + CertType: ssh.UserCert, + ValidPrincipals: []string{user}, + ValidAfter: uint64(now.Add(-1 * time.Minute).Unix()), + ValidBefore: uint64(now.Add(time.Minute).Unix()), + } + if err := cert.SignCert(rand.Reader, c.teleportUserCA); err != nil { + return nil, false, trace.Wrap(err) + } + trustedCert := ssh.MarshalAuthorizedKey(c.teleportHostCA.PublicKey()) + k := &client.KeyRing{ + SSHPrivateKey: userSSHKey, + Cert: ssh.MarshalAuthorizedKey(cert), + TrustedCerts: []authclient.TrustedCerts{ + { + ClusterName: c.ClusterName(), + AuthorizedKeys: [][]byte{trustedCert}, + }, + }, + } + return k, false, nil +} + // fakeAuthClient is a fake auth client that answers GetResources requests with a static list of apps and // basic/faked predicate filtering. type fakeAuthClient struct { @@ -1066,7 +1165,8 @@ func TestSSH(t *testing.T) { "root1.example.com": { cidrRange: root1CIDR, nodes: map[string]nodeSpec{ - "node": {}, + "node": {}, + "denynode": {denyAccess: true}, }, leafClusters: map[string]testClusterSpec{ "leaf1.example.com": { @@ -1127,8 +1227,10 @@ func TestSSH(t *testing.T) { sshUser string sshUserSigner ssh.Signer expectSSHHandshakeToFail bool + expectBannerMessages []string }{ { + // Connection to node in root cluster should work. dialAddr: "node.root1.example.com", dialPort: 22, expectCIDR: root1CIDR, @@ -1151,6 +1253,7 @@ func TestSSH(t *testing.T) { expectDialToFail: true, }, { + // SSH handshake should fail if using the wrong user key. dialAddr: "node.root1.example.com", dialPort: 22, expectCIDR: root1CIDR, @@ -1159,6 +1262,32 @@ func TestSSH(t *testing.T) { expectSSHHandshakeToFail: true, }, { + // Access to denied node should be denied with appropriate banner + // messages. + dialAddr: "denynode.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + expectBannerMessages: []string{ + "VNet: building SSH client config\n\tcalling SessionSSHConfig rpc\n\t\tgetting KeyRing for SSH session\n\taccess denied to denynode\n", + }, + expectSSHHandshakeToFail: true, + }, + { + // username "denyuser" is hardcoded to be denied. + dialAddr: "node.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "denyuser", + sshUserSigner: sshUserSigner, + expectBannerMessages: []string{ + "VNet: access denied to denyuser connecting to node\n", + }, + expectSSHHandshakeToFail: true, + }, + { + // Connection to node in leaf cluster should work. dialAddr: "node.leaf1.example.com.root1.example.com", dialPort: 22, expectCIDR: leaf1CIDR, @@ -1166,6 +1295,8 @@ func TestSSH(t *testing.T) { sshUserSigner: sshUserSigner, }, { + // Connection to node in root cluster in alternate profile should + // work. dialAddr: "node.root2.example.com", dialPort: 22, expectCIDR: root2CIDR, @@ -1173,6 +1304,8 @@ func TestSSH(t *testing.T) { sshUserSigner: sshUserSigner, }, { + // Connection to node in leaf cluster in alternate profile should + // work. dialAddr: "node.leaf2.example.com.root2.example.com", dialPort: 22, expectCIDR: leaf2CIDR, @@ -1243,18 +1376,26 @@ func TestSSH(t *testing.T) { }, Clock: clock.Now, } + var bannerMessages []string clientConfig := &ssh.ClientConfig{ User: tc.sshUser, Auth: []ssh.AuthMethod{ssh.PublicKeys(tc.sshUserSigner)}, HostKeyCallback: certChecker.CheckHostKey, + BannerCallback: func(msg string) error { + bannerMessages = append(bannerMessages, msg) + return nil + }, } - sshConn, _, _, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", tc.dialAddr, tc.dialPort), clientConfig) + sshConn, chans, reqs, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", tc.dialAddr, tc.dialPort), clientConfig) + assert.Equal(t, tc.expectBannerMessages, bannerMessages, "actual banner messages did not match the expected") if tc.expectSSHHandshakeToFail { - require.Error(t, err, "expected SSH handshake to fail") + assert.Error(t, err, "expected SSH handshake to fail") return } require.NoError(t, err) defer sshConn.Close() + + testConnectionToSshEchoServer(t, sshConn, chans, reqs) }) } @@ -1456,27 +1597,33 @@ func newLeafCert( }, nil } +type fakeWebProxyConfig struct { + tlsCA tls.Certificate + hostCA ssh.Signer + userCA ssh.Signer + clock clockwork.Clock + suite types.SignatureAlgorithmSuite +} + func mustStartFakeWebProxy( ctx context.Context, t *testing.T, - ca tls.Certificate, - clock clockwork.Clock, - suite types.SignatureAlgorithmSuite, + cfg fakeWebProxyConfig, ) *vnetv1.DialOptions { t.Helper() roots := x509.NewCertPool() - caX509, err := x509.ParseCertificate(ca.Certificate[0]) + caX509, err := x509.ParseCertificate(cfg.tlsCA.Certificate[0]) require.NoError(t, err) roots.AddCert(caX509) const proxyCN = "testproxy" proxyCert, err := newServerCert( ctx, - ca, + cfg.tlsCA, proxyCN, - clock.Now().Add(365*24*time.Hour), - suite, + cfg.clock.Now().Add(365*24*time.Hour), + cfg.suite, cryptosuites.HostIdentity, ) require.NoError(t, err) @@ -1485,12 +1632,56 @@ func mustStartFakeWebProxy( Certificates: []tls.Certificate{proxyCert}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: roots, + NextProtos: []string{ + string(alpncommon.ProtocolProxySSH), + string(alpncommon.ProtocolTCP), + }, + } + + tcpAppHandler := func(conn net.Conn) error { + // All fake TCP apps for the tests get routed to this handler which + // simply echos any input back on the tcp connection. + _, err := io.Copy(conn, conn) + return trace.Wrap(err, "io.Copy error in proxy echo server") + } + sshHandler := func(conn net.Conn) error { + // All fake SSH nodes for the tests get routed to this handler which + // terminates the incoming SSH connection with an ephemeral host cert. + // It trusts cfg.userCA for incoming SSH connections but always denies + // access for SSH users named "denyuser". After completing the handshake + // it runs a test "echo" SSH server implemented in + // runTestSSHServerInstance. + hostCert, err := newHostCert("node", cfg.hostCA) + if err != nil { + return trace.Wrap(err) + } + certChecker := ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return sshutils.KeysEqual(auth, cfg.userCA.PublicKey()) + }, + Clock: cfg.clock.Now, + } + serverConfig := &ssh.ServerConfig{ + PublicKeyCallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + if conn.User() == "denyuser" { + return nil, trace.AccessDenied("access denied for denyuser") + } + return certChecker.Authenticate(conn, pubKey) + }, + } + serverConfig.AddHostKey(hostCert) + return trace.Wrap(runTestSSHServerInstance(conn, serverConfig)) + } + + // Run a simplified TLS router for the test. + protocolHandlers := map[alpncommon.Protocol]func(net.Conn) error{ + alpncommon.ProtocolTCP: tcpAppHandler, + alpncommon.ProtocolProxySSH: sshHandler, } listener, err := tls.Listen("tcp", "localhost:0", proxyTLSConfig) require.NoError(t, err) - // Run a fake web proxy that will accept any client connection and echo the input back. utils.RunTestBackgroundTask(ctx, t, &utils.TestBackgroundTask{ Name: "web proxy", Task: func(ctx context.Context) error { @@ -1526,14 +1717,19 @@ func mustStartFakeWebProxy( // It's important that the fake clock is never far behind the real clock, and that the // cert NotBefore is always at/before the real current time, so the TLS library is // satisfied. - if clock.Now().After(clientCerts[0].NotAfter) { - t.Logf("client cert is expired: currentTime=%s expiry=%s", clock.Now(), clientCerts[0].NotAfter) + if cfg.clock.Now().After(clientCerts[0].NotAfter) { + t.Logf("client cert is expired: currentTime=%s expiry=%s", cfg.clock.Now(), clientCerts[0].NotAfter) return } - _, err := io.Copy(conn, conn) - if err != nil && !utils.IsOKNetworkError(err) { - t.Logf("error in io.Copy for echo proxy server: %v", err) + protocol := tlsConn.ConnectionState().NegotiatedProtocol + handler, ok := protocolHandlers[alpncommon.Protocol(protocol)] + if !ok { + t.Logf("unhandled proxy protocol %s", protocol) + return + } + if err := handler(conn); err != nil { + t.Logf("error in protocol handler: %v", err) } }() } diff --git a/proto/teleport/lib/vnet/v1/client_application_service.proto b/proto/teleport/lib/vnet/v1/client_application_service.proto index 4552204c8a483..340a36d4f0a12 100644 --- a/proto/teleport/lib/vnet/v1/client_application_service.proto +++ b/proto/teleport/lib/vnet/v1/client_application_service.proto @@ -57,6 +57,11 @@ service ClientApplicationService { rpc UserTLSCert(UserTLSCertRequest) returns (UserTLSCertResponse); // SignForUserTLS signs a digest with the user TLS private key. rpc SignForUserTLS(SignForUserTLSRequest) returns (SignForUserTLSResponse); + // SessionSSHConfig returns the user SSH configuration for an SSH session. + rpc SessionSSHConfig(SessionSSHConfigRequest) returns (SessionSSHConfigResponse); + // SignForSSHSession signs a digest with the SSH private key associated with the + // session from a previous call to SessionSSHConfig. + rpc SignForSSHSession(SignForSSHSessionRequest) returns (SignForSSHSessionResponse); } // AuthenticateProcessRequest is a request for AuthenticateProcess. @@ -329,3 +334,46 @@ message SignForUserTLSResponse { // Signature is the signature. bytes signature = 1; } + +// SessionSSHConfigRequest is a request for SessionSSHConfig. +message SessionSSHConfigRequest { + // Profile is the profile in which the SSH server is found. + string profile = 1; + // RootCluster is the cluster in which the SSH server is found. + string root_cluster = 2; + // LeafCluster is the leaf cluster in which the SSH server is found. + // If empty, the SSH server is in the root cluster. + string leaf_cluster = 3; + // Address is the address of the SSH server. + string address = 4; + // User is the SSH user the session is for. + string user = 5; +} + +// SessionSSHConfigResponse is a response for SessionSSHConfig. +message SessionSSHConfigResponse { + // SessionId is an opaque identifier for the session, it should be passed to + // SignForSSHSession to issue signatures with the private key associated with + // the session. + string session_id = 1; + // Cert is the session SSH certificate in SSH wire format. + bytes cert = 2; + // TrustedCas is a list of trusted SSH certificate authorities in SSH wire + // format. + repeated bytes trusted_cas = 3; +} + +// SignForSSHSessionRequest is a request for SignForSSHSession. +message SignForSSHSessionRequest { + // SessionId is an opaque identifier for the session returned from a previous + // call to SessionSSHConfig. + string session_id = 1; + // Sign holds signature request details. + SignRequest sign = 2; +} + +// SignForSSHSessionResponse is a response for SignForSSHSession. +message SignForSSHSessionResponse { + // Signature is the signature. + bytes signature = 1; +} From ab0fb8c2de7c9a885e337661fa6f1c99a27ae112 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Sat, 31 May 2025 22:50:10 -0700 Subject: [PATCH 04/25] [v18][vnet] feat: write VNet SSH keys to TELEPORT_HOME Backport #55228 to branch/v18 --- api/utils/keypaths/keypaths.go | 27 ++++ .../vnet/v1/client_application_service.pb.go | 147 +++++++++++++++--- .../v1/client_application_service_grpc.pb.go | 42 +++++ lib/client/keystore.go | 26 ++-- lib/vnet/admin_process_common.go | 6 +- lib/vnet/admin_process_darwin.go | 2 +- lib/vnet/admin_process_windows.go | 2 +- lib/vnet/client_application_service.go | 20 +++ lib/vnet/client_application_service_client.go | 19 +++ lib/vnet/opensshconfig.go | 123 +++++++++++++++ lib/vnet/ssh_provider.go | 41 ++--- lib/vnet/vnet_test.go | 66 ++++---- .../vnet/v1/client_application_service.proto | 17 ++ 13 files changed, 441 insertions(+), 97 deletions(-) create mode 100644 lib/vnet/opensshconfig.go diff --git a/api/utils/keypaths/keypaths.go b/api/utils/keypaths/keypaths.go index 6e0bc36d1e3c7..f1853313f67fe 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -73,6 +73,15 @@ const ( profileFileExt = ".yaml" // oracleWalletDirSuffix is the suffix of the oracle wallet database directory. oracleWalletDirSuffix = "-wallet" + // VNetClientSSHKey is the file name of the SSH key used by third-party SSH + // clients to connect to VNet SSH. + VNetClientSSHKey = "id_vnet" + // VNetClientSSHKeyPub is the file name of the SSH public key matching + // VNetClientSSHKey. + VNetClientSSHKeyPub = VNetClientSSHKey + fileExtPub + // vnetKnownHosts is the file name of the known_hosts file trusted by + // third-party SSH clients connecting to VNet SSH. + vnetKnownHosts = "vnet_known_hosts" ) // Here's the file layout of all these keypaths. @@ -81,6 +90,9 @@ const ( // ├── one.example.com.yaml --> file containing profile details for proxy "one.example.com" // ├── two.example.com.yaml --> file containing profile details for proxy "two.example.com" // ├── known_hosts --> trusted certificate authorities (their keys) in a format similar to known_hosts +// ├── id_vnet --> SSH Private Key for third-party clients of VNet SSH +// ├── id_vnet.pub --> SSH Public Key for third-party clients of VNet SSH +// ├── vnet_known_hosts --> trusted certificate authorities (their keys) for third-party clients of VNet SSH // └── keys --> session keys directory // ├── one.example.com --> Proxy hostname // │ ├── certs.pem --> TLS CA certs for the Teleport CA @@ -429,6 +441,21 @@ func IdentitySSHCertPath(path string) string { return path + fileExtSSHCert } +// VNetClientSSHKeyPath returns the path to the VNet client SSH private key. +func VNetClientSSHKeyPath(baseDir string) string { + return filepath.Join(baseDir, VNetClientSSHKey) +} + +// VNetClientSSHKeyPubPath returns the path to the VNet client SSH public key. +func VNetClientSSHKeyPubPath(baseDir string) string { + return filepath.Join(baseDir, VNetClientSSHKeyPub) +} + +// VNetKnownHostsPath returns the path to the VNet known_hosts file. +func VNetKnownHostsPath(baseDir string) string { + return filepath.Join(baseDir, vnetKnownHosts) +} + // TrimKeyPathSuffix returns the given path with any key suffix/extension trimmed off. func TrimKeyPathSuffix(path string) string { return strings.TrimSuffix(path, fileExtTLSKey) diff --git a/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go index 921832bda2b01..a30d0cece9171 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go @@ -2025,6 +2025,100 @@ func (x *SignForSSHSessionResponse) GetSignature() []byte { return nil } +// ExchangeSSHKeysRequest is a request to exchange SSH keys for VNet SSH. +type ExchangeSSHKeysRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HostPublicKey is the host key that should be trusted by clients connecting + // to VNet SSH addresses. It is encoded in OpenSSH wire format. + HostPublicKey []byte `protobuf:"bytes,1,opt,name=host_public_key,json=hostPublicKey,proto3" json:"host_public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeSSHKeysRequest) Reset() { + *x = ExchangeSSHKeysRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeSSHKeysRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeSSHKeysRequest) ProtoMessage() {} + +func (x *ExchangeSSHKeysRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[35] + 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 ExchangeSSHKeysRequest.ProtoReflect.Descriptor instead. +func (*ExchangeSSHKeysRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{35} +} + +func (x *ExchangeSSHKeysRequest) GetHostPublicKey() []byte { + if x != nil { + return x.HostPublicKey + } + return nil +} + +// ExchangeSSHKeysResponse is a response for ExchangeSSHKeys. +type ExchangeSSHKeysResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // UserPublicKey is the user key that should be trusted by VNet for incoming + // connections from SSH clients. It is encoded in OpenSSH wire format. + UserPublicKey []byte `protobuf:"bytes,1,opt,name=user_public_key,json=userPublicKey,proto3" json:"user_public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeSSHKeysResponse) Reset() { + *x = ExchangeSSHKeysResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeSSHKeysResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeSSHKeysResponse) ProtoMessage() {} + +func (x *ExchangeSSHKeysResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[36] + 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 ExchangeSSHKeysResponse.ProtoReflect.Descriptor instead. +func (*ExchangeSSHKeysResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{36} +} + +func (x *ExchangeSSHKeysResponse) GetUserPublicKey() []byte { + if x != nil { + return x.UserPublicKey + } + return nil +} + var File_teleport_lib_vnet_v1_client_application_service_proto protoreflect.FileDescriptor const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + @@ -2135,11 +2229,15 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "session_id\x18\x01 \x01(\tR\tsessionId\x125\n" + "\x04sign\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04sign\"9\n" + "\x19SignForSSHSessionResponse\x12\x1c\n" + - "\tsignature\x18\x01 \x01(\fR\tsignature*<\n" + + "\tsignature\x18\x01 \x01(\fR\tsignature\"@\n" + + "\x16ExchangeSSHKeysRequest\x12&\n" + + "\x0fhost_public_key\x18\x01 \x01(\fR\rhostPublicKey\"A\n" + + "\x17ExchangeSSHKeysResponse\x12&\n" + + "\x0fuser_public_key\x18\x01 \x01(\fR\ruserPublicKey*<\n" + "\x04Hash\x12\x14\n" + "\x10HASH_UNSPECIFIED\x10\x00\x12\r\n" + "\tHASH_NONE\x10\x01\x12\x0f\n" + - "\vHASH_SHA256\x10\x022\xcc\v\n" + + "\vHASH_SHA256\x10\x022\xbc\f\n" + "\x18ClientApplicationService\x12z\n" + "\x13AuthenticateProcess\x120.teleport.lib.vnet.v1.AuthenticateProcessRequest\x1a1.teleport.lib.vnet.v1.AuthenticateProcessResponse\x12\x83\x01\n" + "\x16ReportNetworkStackInfo\x123.teleport.lib.vnet.v1.ReportNetworkStackInfoRequest\x1a4.teleport.lib.vnet.v1.ReportNetworkStackInfoResponse\x12M\n" + @@ -2154,7 +2252,8 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\vUserTLSCert\x12(.teleport.lib.vnet.v1.UserTLSCertRequest\x1a).teleport.lib.vnet.v1.UserTLSCertResponse\x12k\n" + "\x0eSignForUserTLS\x12+.teleport.lib.vnet.v1.SignForUserTLSRequest\x1a,.teleport.lib.vnet.v1.SignForUserTLSResponse\x12q\n" + "\x10SessionSSHConfig\x12-.teleport.lib.vnet.v1.SessionSSHConfigRequest\x1a..teleport.lib.vnet.v1.SessionSSHConfigResponse\x12t\n" + - "\x11SignForSSHSession\x12..teleport.lib.vnet.v1.SignForSSHSessionRequest\x1a/.teleport.lib.vnet.v1.SignForSSHSessionResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" + "\x11SignForSSHSession\x12..teleport.lib.vnet.v1.SignForSSHSessionRequest\x1a/.teleport.lib.vnet.v1.SignForSSHSessionResponse\x12n\n" + + "\x0fExchangeSSHKeys\x12,.teleport.lib.vnet.v1.ExchangeSSHKeysRequest\x1a-.teleport.lib.vnet.v1.ExchangeSSHKeysResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" var ( file_teleport_lib_vnet_v1_client_application_service_proto_rawDescOnce sync.Once @@ -2169,7 +2268,7 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP() [] } var file_teleport_lib_vnet_v1_client_application_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 35) +var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 37) var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (Hash)(0), // 0: teleport.lib.vnet.v1.Hash (*AuthenticateProcessRequest)(nil), // 1: teleport.lib.vnet.v1.AuthenticateProcessRequest @@ -2207,7 +2306,9 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (*SessionSSHConfigResponse)(nil), // 33: teleport.lib.vnet.v1.SessionSSHConfigResponse (*SignForSSHSessionRequest)(nil), // 34: teleport.lib.vnet.v1.SignForSSHSessionRequest (*SignForSSHSessionResponse)(nil), // 35: teleport.lib.vnet.v1.SignForSSHSessionResponse - (*types.AppV3)(nil), // 36: types.AppV3 + (*ExchangeSSHKeysRequest)(nil), // 36: teleport.lib.vnet.v1.ExchangeSSHKeysRequest + (*ExchangeSSHKeysResponse)(nil), // 37: teleport.lib.vnet.v1.ExchangeSSHKeysResponse + (*types.AppV3)(nil), // 38: types.AppV3 } var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32{ 4, // 0: teleport.lib.vnet.v1.ReportNetworkStackInfoRequest.network_stack_info:type_name -> teleport.lib.vnet.v1.NetworkStackInfo @@ -2216,7 +2317,7 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32 12, // 3: teleport.lib.vnet.v1.ResolveFQDNResponse.matched_cluster:type_name -> teleport.lib.vnet.v1.MatchedCluster 13, // 4: teleport.lib.vnet.v1.MatchedTCPApp.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 5: teleport.lib.vnet.v1.AppInfo.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 36, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 + 38, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 15, // 7: teleport.lib.vnet.v1.AppInfo.dial_options:type_name -> teleport.lib.vnet.v1.DialOptions 13, // 8: teleport.lib.vnet.v1.ReissueAppCertRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 9: teleport.lib.vnet.v1.SignForAppRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey @@ -2241,21 +2342,23 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32 30, // 28: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:input_type -> teleport.lib.vnet.v1.SignForUserTLSRequest 32, // 29: teleport.lib.vnet.v1.ClientApplicationService.SessionSSHConfig:input_type -> teleport.lib.vnet.v1.SessionSSHConfigRequest 34, // 30: teleport.lib.vnet.v1.ClientApplicationService.SignForSSHSession:input_type -> teleport.lib.vnet.v1.SignForSSHSessionRequest - 2, // 31: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse - 5, // 32: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse - 7, // 33: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse - 9, // 34: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse - 17, // 35: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse - 20, // 36: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse - 22, // 37: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:output_type -> teleport.lib.vnet.v1.OnNewConnectionResponse - 24, // 38: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse - 26, // 39: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse - 29, // 40: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:output_type -> teleport.lib.vnet.v1.UserTLSCertResponse - 31, // 41: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:output_type -> teleport.lib.vnet.v1.SignForUserTLSResponse - 33, // 42: teleport.lib.vnet.v1.ClientApplicationService.SessionSSHConfig:output_type -> teleport.lib.vnet.v1.SessionSSHConfigResponse - 35, // 43: teleport.lib.vnet.v1.ClientApplicationService.SignForSSHSession:output_type -> teleport.lib.vnet.v1.SignForSSHSessionResponse - 31, // [31:44] is the sub-list for method output_type - 18, // [18:31] is the sub-list for method input_type + 36, // 31: teleport.lib.vnet.v1.ClientApplicationService.ExchangeSSHKeys:input_type -> teleport.lib.vnet.v1.ExchangeSSHKeysRequest + 2, // 32: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse + 5, // 33: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse + 7, // 34: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse + 9, // 35: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse + 17, // 36: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse + 20, // 37: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse + 22, // 38: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:output_type -> teleport.lib.vnet.v1.OnNewConnectionResponse + 24, // 39: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse + 26, // 40: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse + 29, // 41: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:output_type -> teleport.lib.vnet.v1.UserTLSCertResponse + 31, // 42: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:output_type -> teleport.lib.vnet.v1.SignForUserTLSResponse + 33, // 43: teleport.lib.vnet.v1.ClientApplicationService.SessionSSHConfig:output_type -> teleport.lib.vnet.v1.SessionSSHConfigResponse + 35, // 44: teleport.lib.vnet.v1.ClientApplicationService.SignForSSHSession:output_type -> teleport.lib.vnet.v1.SignForSSHSessionResponse + 37, // 45: teleport.lib.vnet.v1.ClientApplicationService.ExchangeSSHKeys:output_type -> teleport.lib.vnet.v1.ExchangeSSHKeysResponse + 32, // [32:46] is the sub-list for method output_type + 18, // [18:32] is the sub-list for method input_type 18, // [18:18] is the sub-list for extension type_name 18, // [18:18] is the sub-list for extension extendee 0, // [0:18] is the sub-list for field type_name @@ -2278,7 +2381,7 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc), len(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 35, + NumMessages: 37, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go index d1b5e481447ce..e153a98dd6646 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go @@ -48,6 +48,7 @@ const ( ClientApplicationService_SignForUserTLS_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForUserTLS" ClientApplicationService_SessionSSHConfig_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SessionSSHConfig" ClientApplicationService_SignForSSHSession_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForSSHSession" + ClientApplicationService_ExchangeSSHKeys_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/ExchangeSSHKeys" ) // ClientApplicationServiceClient is the client API for ClientApplicationService service. @@ -94,6 +95,9 @@ type ClientApplicationServiceClient interface { // SignForSSHSession signs a digest with the SSH private key associated with the // session from a previous call to SessionSSHConfig. SignForSSHSession(ctx context.Context, in *SignForSSHSessionRequest, opts ...grpc.CallOption) (*SignForSSHSessionResponse, error) + // ExchangeSSHKeys sends VNet's SSH host CA key to the client application and + // returns the user public key. + ExchangeSSHKeys(ctx context.Context, in *ExchangeSSHKeysRequest, opts ...grpc.CallOption) (*ExchangeSSHKeysResponse, error) } type clientApplicationServiceClient struct { @@ -234,6 +238,16 @@ func (c *clientApplicationServiceClient) SignForSSHSession(ctx context.Context, return out, nil } +func (c *clientApplicationServiceClient) ExchangeSSHKeys(ctx context.Context, in *ExchangeSSHKeysRequest, opts ...grpc.CallOption) (*ExchangeSSHKeysResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExchangeSSHKeysResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_ExchangeSSHKeys_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ClientApplicationServiceServer is the server API for ClientApplicationService service. // All implementations must embed UnimplementedClientApplicationServiceServer // for forward compatibility. @@ -278,6 +292,9 @@ type ClientApplicationServiceServer interface { // SignForSSHSession signs a digest with the SSH private key associated with the // session from a previous call to SessionSSHConfig. SignForSSHSession(context.Context, *SignForSSHSessionRequest) (*SignForSSHSessionResponse, error) + // ExchangeSSHKeys sends VNet's SSH host CA key to the client application and + // returns the user public key. + ExchangeSSHKeys(context.Context, *ExchangeSSHKeysRequest) (*ExchangeSSHKeysResponse, error) mustEmbedUnimplementedClientApplicationServiceServer() } @@ -327,6 +344,9 @@ func (UnimplementedClientApplicationServiceServer) SessionSSHConfig(context.Cont func (UnimplementedClientApplicationServiceServer) SignForSSHSession(context.Context, *SignForSSHSessionRequest) (*SignForSSHSessionResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SignForSSHSession not implemented") } +func (UnimplementedClientApplicationServiceServer) ExchangeSSHKeys(context.Context, *ExchangeSSHKeysRequest) (*ExchangeSSHKeysResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ExchangeSSHKeys not implemented") +} func (UnimplementedClientApplicationServiceServer) mustEmbedUnimplementedClientApplicationServiceServer() { } func (UnimplementedClientApplicationServiceServer) testEmbeddedByValue() {} @@ -583,6 +603,24 @@ func _ClientApplicationService_SignForSSHSession_Handler(srv interface{}, ctx co return interceptor(ctx, in, info, handler) } +func _ClientApplicationService_ExchangeSSHKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExchangeSSHKeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).ExchangeSSHKeys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_ExchangeSSHKeys_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).ExchangeSSHKeys(ctx, req.(*ExchangeSSHKeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ClientApplicationService_ServiceDesc is the grpc.ServiceDesc for ClientApplicationService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -642,6 +680,10 @@ var ClientApplicationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SignForSSHSession", Handler: _ClientApplicationService_SignForSSHSession_Handler, }, + { + MethodName: "ExchangeSSHKeys", + Handler: _ClientApplicationService_ExchangeSSHKeys_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "teleport/lib/vnet/v1/client_application_service.proto", diff --git a/lib/client/keystore.go b/lib/client/keystore.go index d7823b330ee75..06f930286705b 100644 --- a/lib/client/keystore.go +++ b/lib/client/keystore.go @@ -53,9 +53,9 @@ const ( // under ~/.tsh keyFilePerms os.FileMode = 0600 - // tshConfigFileName is the name of the directory containing the + // tshConfigDirName is the name of the directory containing the // tsh config file. - tshConfigFileName = "config" + tshConfigDirName = "config" // tshAzureDirName is the name of the directory containing the // az cli app-specific profiles. @@ -474,19 +474,21 @@ func (fs *FSKeyStore) DeleteKeys() error { if err != nil { return trace.ConvertSystemError(err) } - ignoreDirs := map[string]struct{}{tshConfigFileName: {}, tshAzureDirName: {}, tshBin: {}} for _, file := range files { - // Don't delete 'config', 'azure' and 'bin' directories. - // TODO: this is hackish and really shouldn't be needed, but fs.KeyDir is `~/.tsh` while it probably should be `~/.tsh/keys` instead. - if _, ok := ignoreDirs[file.Name()]; ok && file.IsDir() { - continue - } if file.IsDir() { - err := utils.RemoveAllSecure(filepath.Join(fs.KeyDir, file.Name())) - if err != nil { - return trace.ConvertSystemError(err) + switch file.Name() { + case tshConfigDirName, tshAzureDirName, tshBin: + // Don't delete 'config', 'azure' and 'bin' directories. + // TODO: this is hackish and really shouldn't be needed, but fs.KeyDir is `~/.tsh` while it probably should be `~/.tsh/keys` instead. + continue + } + } else { + switch file.Name() { + case keypaths.VNetClientSSHKey, keypaths.VNetClientSSHKeyPub: + // Don't delete VNet client SSH keys on logout in case a user wants to + // set these to their own key compatible with their third-party SSH client. + continue } - continue } err := utils.RemoveAllSecure(filepath.Join(fs.KeyDir, file.Name())) if err != nil { diff --git a/lib/vnet/admin_process_common.go b/lib/vnet/admin_process_common.go index 2aad6c5b1967f..7ec69dae0b07f 100644 --- a/lib/vnet/admin_process_common.go +++ b/lib/vnet/admin_process_common.go @@ -17,13 +17,15 @@ package vnet import ( + "context" + "github.com/gravitational/trace" "github.com/jonboulle/clockwork" ) -func newNetworkStackConfig(tun tunDevice, clt *clientApplicationServiceClient) (*networkStackConfig, error) { +func newNetworkStackConfig(ctx context.Context, tun tunDevice, clt *clientApplicationServiceClient) (*networkStackConfig, error) { clock := clockwork.NewRealClock() - sshProvider, err := newSSHProvider(sshProviderConfig{ + sshProvider, err := newSSHProvider(ctx, sshProviderConfig{ clt: clt, clock: clock, }) diff --git a/lib/vnet/admin_process_darwin.go b/lib/vnet/admin_process_darwin.go index 90355bc85588b..c91bddbdbcb45 100644 --- a/lib/vnet/admin_process_darwin.go +++ b/lib/vnet/admin_process_darwin.go @@ -59,7 +59,7 @@ func RunDarwinAdminProcess(ctx context.Context, config daemon.Config) error { } defer tun.Close() - networkStackConfig, err := newNetworkStackConfig(tun, clt) + networkStackConfig, err := newNetworkStackConfig(ctx, tun, clt) if err != nil { return trace.Wrap(err, "creating network stack config") } diff --git a/lib/vnet/admin_process_windows.go b/lib/vnet/admin_process_windows.go index e4f1112f2b0be..2f6532ace2a26 100644 --- a/lib/vnet/admin_process_windows.go +++ b/lib/vnet/admin_process_windows.go @@ -100,7 +100,7 @@ func runWindowsAdminProcess(ctx context.Context, cfg *windowsAdminProcessConfig) } log.InfoContext(ctx, "Created TUN interface", "tun", tunName) - networkStackConfig, err := newNetworkStackConfig(device, clt) + networkStackConfig, err := newNetworkStackConfig(ctx, device, clt) if err != nil { return trace.Wrap(err, "creating network stack config") } diff --git a/lib/vnet/client_application_service.go b/lib/vnet/client_application_service.go index 8be84e5d61ac5..37a3cc86e8c0c 100644 --- a/lib/vnet/client_application_service.go +++ b/lib/vnet/client_application_service.go @@ -73,6 +73,7 @@ type clientApplicationServiceConfig struct { fqdnResolver *fqdnResolver localOSConfigProvider *LocalOSConfigProvider clientApplication ClientApplication + homePath string clock clockwork.Clock } @@ -400,6 +401,25 @@ func (s *clientApplicationService) getSignerForSSHSession(ctx context.Context, s return signer, trace.Wrap(err) } +// ExchangeSSHKeys recevies the VNet service host CA public key and writes it to +// ${TELEPORT_HOME}/vnet_known_hosts so that third-party SSH clients can trust +// it. It then reads or generates ${TELEPORT_HOME}/id_vnet(.pub) which SSH +// clients should be configured to use for connections to VNet SSH. It returns +// id_vnet.pub so that VNet SSH can trust it for incoming connections. +func (s *clientApplicationService) ExchangeSSHKeys(ctx context.Context, req *vnetv1.ExchangeSSHKeysRequest) (*vnetv1.ExchangeSSHKeysResponse, error) { + hostPublicKey, err := ssh.ParsePublicKey(req.GetHostPublicKey()) + if err != nil { + return nil, trace.Wrap(err, "parsing host public key") + } + userPublicKey, err := writeSSHKeys(s.cfg.homePath, hostPublicKey) + if err != nil { + return nil, trace.Wrap(err, "writing SSH keys") + } + return &vnetv1.ExchangeSSHKeysResponse{ + UserPublicKey: userPublicKey.Marshal(), + }, nil +} + // checkAppKey checks that at least the app profile and name are set, which are // necessary to to disambiguate apps. LeafCluster is expected to be empty if the // app is in a root cluster. diff --git a/lib/vnet/client_application_service_client.go b/lib/vnet/client_application_service_client.go index bf8149a5a2e7d..72d6c33b68f53 100644 --- a/lib/vnet/client_application_service_client.go +++ b/lib/vnet/client_application_service_client.go @@ -23,6 +23,7 @@ import ( "io" "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" "google.golang.org/grpc" grpccredentials "google.golang.org/grpc/credentials" @@ -212,6 +213,24 @@ func (c *clientApplicationServiceClient) SignForSSHSession(ctx context.Context, return resp.GetSignature(), nil } +// ExchangeSSHKeys sends hostPublicKey to the client application so that it +// can write an OpenSSH-compatible configuration file. It returns the user +// public key that should be trusted for incoming connections from third-party +// SSH clients. +func (c *clientApplicationServiceClient) ExchangeSSHKeys(ctx context.Context, hostPublicKey ssh.PublicKey) (ssh.PublicKey, error) { + resp, err := c.clt.ExchangeSSHKeys(ctx, &vnetv1.ExchangeSSHKeysRequest{ + HostPublicKey: hostPublicKey.Marshal(), + }) + if err != nil { + return nil, trace.Wrap(err, "calling ExchangeSSHKeys rpc") + } + userPublicKey, err := ssh.ParsePublicKey(resp.GetUserPublicKey()) + if err != nil { + return nil, trace.Wrap(err, "parsing trusted user public key") + } + return userPublicKey, nil +} + // rpcSigner implements [crypto.Signer] for signatures that are issued by the // client application over gRPC. type rpcSigner struct { diff --git a/lib/vnet/opensshconfig.go b/lib/vnet/opensshconfig.go new file mode 100644 index 0000000000000..3bd5176729b9a --- /dev/null +++ b/lib/vnet/opensshconfig.go @@ -0,0 +1,123 @@ +// 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 vnet + +import ( + "encoding/pem" + "io" + "os" + "path/filepath" + + renameio "github.com/google/renameio/v2/maybe" // Writes aren't guaranteed to be atomic on Windows. + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/keypaths" + "github.com/gravitational/teleport/lib/cryptosuites" +) + +const ( + filePerms os.FileMode = 0o600 +) + +// writeSSHKeys writes hostCAKey to ${TELEPORT_HOME}/vnet_known_hosts so that +// third-party SSH clients can trust it. It then reads or generates +// ${TELEPORT_HOME}/id_vnet(.pub) which SSH clients should be configured to use +// for connections to VNet SSH. It returns id_vnet.pub so that VNet SSH can +// trust it for incoming connections. +func writeSSHKeys(homePath string, hostCAKey ssh.PublicKey) (ssh.PublicKey, error) { + profilePath := fullProfilePath(homePath) + if err := writeKnownHosts(profilePath, hostCAKey); err != nil { + return nil, trace.Wrap(err) + } + userPubKey, err := readUserPubKey(profilePath) + if trace.IsNotFound(err) { + userPubKey, err = generateAndWriteUserKey(profilePath) + } + if err != nil { + return nil, trace.Wrap(err) + } + return userPubKey, nil +} + +func fullProfilePath(homePath string) string { + if homePath == "" { + if homeDir := os.Getenv(types.HomeEnvVar); homeDir != "" { + homePath = filepath.Clean(homeDir) + } + } + return profile.FullProfilePath(homePath) +} + +func writeKnownHosts(profilePath string, hostCAKey ssh.PublicKey) error { + // MarshalAuthorizedKey serializes the key for inclusion in an + // authorized_keys file, we need to add the @cert-authority prefix and the + // wildcard so this CA is trusted for all hosts. The SSH configuration file + // should only load this vnet_known_hosts file for hosts matching + // appropriate subdomains, there is no need to keep that list of domains + // updated in both the SSH config file and the vnet_known_hosts file. + authorizedKey := ssh.MarshalAuthorizedKey(hostCAKey) + authorizedCA := "@cert-authority * " + string(authorizedKey) + p := keypaths.VNetKnownHostsPath(profilePath) + err := renameio.WriteFile(p, []byte(authorizedCA), filePerms) + return trace.Wrap(trace.ConvertSystemError(err), "writing host CA to %s", p) +} + +func readUserPubKey(profilePath string) (ssh.PublicKey, error) { + p := keypaths.VNetClientSSHKeyPubPath(profilePath) + f, err := os.Open(p) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "opening %s for reading", p) + } + defer f.Close() + const maxPubKeyFileSize = 10000 // RSA 4096 pub key files are ~750 bytes, ~10x to be safe. + pubKeyBytes, err := io.ReadAll(io.LimitReader(f, maxPubKeyFileSize)) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "reading user public key from %s", p) + } + userPubKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes) + return userPubKey, trace.Wrap(err, "parsing user public key from %s", p) +} + +func generateAndWriteUserKey(profilePath string) (ssh.PublicKey, error) { + userKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err, "generating SSH user key") + } + + privPemBlock, err := ssh.MarshalPrivateKey(userKey, "") + if err != nil { + return nil, trace.Wrap(err, "marshaling SSH user key") + } + privKeyBytes := pem.EncodeToMemory(privPemBlock) + privKeyPath := keypaths.VNetClientSSHKeyPath(profilePath) + if err := renameio.WriteFile(privKeyPath, privKeyBytes, filePerms); err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "writing user private key to %s", privKeyPath) + } + + userPubKey, err := ssh.NewPublicKey(userKey.Public()) + if err != nil { + return nil, trace.Wrap(err) + } + pubKeyPath := keypaths.VNetClientSSHKeyPubPath(profilePath) + if err := renameio.WriteFile(pubKeyPath, ssh.MarshalAuthorizedKey(userPubKey), filePerms); err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "writing user public key to %s", pubKeyPath) + } + return userPubKey, nil +} diff --git a/lib/vnet/ssh_provider.go b/lib/vnet/ssh_provider.go index 9bdc302a50b38..dd964b4967f04 100644 --- a/lib/vnet/ssh_provider.go +++ b/lib/vnet/ssh_provider.go @@ -53,39 +53,20 @@ type sshProviderConfig struct { tlsConfig *tls.Config, dialOpts *vnetv1.DialOptions, ) (net.Conn, error) - // hostCASigner can be used in tests to set a specific key for the SSH host CA. - hostCASigner ssh.Signer - // trustedUserPublicKey can be used in tests to set a specific trusted user - // SSH key. - trustedUserPublicKey ssh.PublicKey } -func newSSHProvider(cfg sshProviderConfig) (*sshProvider, error) { - hostCASigner := cfg.hostCASigner - if hostCASigner == nil { - // TODO(nklaassen): write host CA public key to $TELEPORT_HOME/vnet_known_hosts - hostKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) - if err != nil { - return nil, trace.Wrap(err) - } - hostCASigner, err = ssh.NewSignerFromSigner(hostKey) - if err != nil { - return nil, trace.Wrap(err) - } +func newSSHProvider(ctx context.Context, cfg sshProviderConfig) (*sshProvider, error) { + hostCAKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err) } - trustedUserPublicKey := cfg.trustedUserPublicKey - if trustedUserPublicKey == nil { - // TODO(nklaassen): check if $TELEPORT_HOME/id_vnet.pub exists. - // If it does, read that file and trust it. - // If not, generate the keypair and write it to $TELEPORT_HOME/id_vnet. - userKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) - if err != nil { - return nil, trace.Wrap(err) - } - trustedUserPublicKey, err = ssh.NewPublicKey(userKey.Public()) - if err != nil { - return nil, trace.Wrap(err) - } + hostCASigner, err := ssh.NewSignerFromSigner(hostCAKey) + if err != nil { + return nil, trace.Wrap(err) + } + trustedUserPublicKey, err := cfg.clt.ExchangeSSHKeys(ctx, hostCASigner.PublicKey()) + if err != nil { + return nil, trace.Wrap(err) } return &sshProvider{ cfg: cfg, diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index e01ad78feebc1..72674a59edad3 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -59,6 +59,7 @@ import ( "github.com/gravitational/teleport/api/types" typesvnet "github.com/gravitational/teleport/api/types/vnet" "github.com/gravitational/teleport/api/utils/grpc/interceptors" + "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/api/utils/sshutils" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/auth/authclient" @@ -89,13 +90,16 @@ type testPack struct { } type testPackConfig struct { - clock clockwork.Clock - fakeClientApp *fakeClientApp - sshHostCASigner ssh.Signer - sshTrustedUserPublicKey ssh.PublicKey + clock clockwork.Clock + fakeClientApp *fakeClientApp + homePath string } func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPack { + if cfg.homePath == "" { + cfg.homePath = t.TempDir() + } + // Create two sides of an emulated TUN interface: writes to one can be read on the other, and vice versa. tun1, tun2 := newSplitTUN() @@ -149,14 +153,12 @@ func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPac // client application and communicates over gRPC. For the test, everything // runs in a single process, but we still set up the gRPC service and only // interface with fakeClientApp via the gRPC client. - clt := runTestClientApplicationService(t, ctx, cfg.clock, cfg.fakeClientApp) + clt := runTestClientApplicationService(t, ctx, cfg) appProvider := newAppProvider(clt) - sshProvider, err := newSSHProvider(sshProviderConfig{ - clt: clt, - clock: cfg.clock, - overrideNodeDialer: cfg.fakeClientApp.dialSSHNode, - hostCASigner: cfg.sshHostCASigner, - trustedUserPublicKey: cfg.sshTrustedUserPublicKey, + sshProvider, err := newSSHProvider(ctx, sshProviderConfig{ + clt: clt, + clock: cfg.clock, + overrideNodeDialer: cfg.fakeClientApp.dialSSHNode, }) require.NoError(t, err) tcpHandlerResolver := newTCPHandlerResolver(&tcpHandlerResolverConfig{ @@ -270,20 +272,21 @@ func (p *testPack) dialHost(ctx context.Context, host string, port int) (net.Con // runTestClientApplicationService runs the gRPC service that's normally used to // expose the client application and Teleport client methods to the VNet // admin/networking process over gRPC. It returns a client of the gRPC service. -func runTestClientApplicationService(t *testing.T, ctx context.Context, clock clockwork.Clock, clientApp *fakeClientApp) *clientApplicationServiceClient { - clusterConfigCache := NewClusterConfigCache(clock) - leafClusterCache, err := newLeafClusterCache(clock) +func runTestClientApplicationService(t *testing.T, ctx context.Context, cfg testPackConfig) *clientApplicationServiceClient { + clusterConfigCache := NewClusterConfigCache(cfg.clock) + leafClusterCache, err := newLeafClusterCache(cfg.clock) require.NoError(t, err) fqdnResolver := newFQDNResolver(&fqdnResolverConfig{ - clientApplication: clientApp, + clientApplication: cfg.fakeClientApp, clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{ - clientApplication: clientApp, + clientApplication: cfg.fakeClientApp, fqdnResolver: fqdnResolver, localOSConfigProvider: nil, // OS configuration is not needed in tests. - clock: clock, + homePath: cfg.homePath, + clock: cfg.clock, }) require.NoError(t, err) @@ -1153,6 +1156,7 @@ func TestSSH(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) clock := clockwork.NewRealClock() + homePath := t.TempDir() const ( root1CIDR = "192.168.1.0/24" @@ -1196,28 +1200,32 @@ func TestSSH(t *testing.T) { signatureAlgorithmSuite: types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1, }) - sshHostKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + p := newTestPack(t, ctx, testPackConfig{ + fakeClientApp: clientApp, + clock: clock, + homePath: homePath, + }) + + // Read the generated vnet_known_hosts file to get the trusted host CA key. + knownHosts, err := os.ReadFile(keypaths.VNetKnownHostsPath(homePath)) require.NoError(t, err) - sshHostCASigner, err := ssh.NewSignerFromSigner(sshHostKey) + marker, hosts, hostCAPubKey, _, _, err := ssh.ParseKnownHosts(knownHosts) require.NoError(t, err) + require.Equal(t, "cert-authority", marker) + require.Equal(t, []string{"*"}, hosts) - sshUserKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + // Read the generated id_vnet file to get the user key. + sshUserKey, err := os.ReadFile(keypaths.VNetClientSSHKeyPath(homePath)) require.NoError(t, err) - sshUserSigner, err := ssh.NewSignerFromSigner(sshUserKey) + sshUserSigner, err := ssh.ParsePrivateKey(sshUserKey) require.NoError(t, err) + // Create a fake user key to test failed authentication. badUserKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) require.NoError(t, err) badUserSigner, err := ssh.NewSignerFromSigner(badUserKey) require.NoError(t, err) - p := newTestPack(t, ctx, testPackConfig{ - fakeClientApp: clientApp, - clock: clock, - sshHostCASigner: sshHostCASigner, - sshTrustedUserPublicKey: sshUserSigner.PublicKey(), - }) - for _, tc := range []struct { dialAddr string dialPort int @@ -1372,7 +1380,7 @@ func TestSSH(t *testing.T) { // the server. certChecker := ssh.CertChecker{ IsHostAuthority: func(auth ssh.PublicKey, address string) bool { - return sshutils.KeysEqual(auth, sshHostCASigner.PublicKey()) + return sshutils.KeysEqual(auth, hostCAPubKey) }, Clock: clock.Now, } diff --git a/proto/teleport/lib/vnet/v1/client_application_service.proto b/proto/teleport/lib/vnet/v1/client_application_service.proto index 340a36d4f0a12..85625f7b693cc 100644 --- a/proto/teleport/lib/vnet/v1/client_application_service.proto +++ b/proto/teleport/lib/vnet/v1/client_application_service.proto @@ -62,6 +62,9 @@ service ClientApplicationService { // SignForSSHSession signs a digest with the SSH private key associated with the // session from a previous call to SessionSSHConfig. rpc SignForSSHSession(SignForSSHSessionRequest) returns (SignForSSHSessionResponse); + // ExchangeSSHKeys sends VNet's SSH host CA key to the client application and + // returns the user public key. + rpc ExchangeSSHKeys(ExchangeSSHKeysRequest) returns (ExchangeSSHKeysResponse); } // AuthenticateProcessRequest is a request for AuthenticateProcess. @@ -377,3 +380,17 @@ message SignForSSHSessionResponse { // Signature is the signature. bytes signature = 1; } + +// ExchangeSSHKeysRequest is a request to exchange SSH keys for VNet SSH. +message ExchangeSSHKeysRequest { + // HostPublicKey is the host key that should be trusted by clients connecting + // to VNet SSH addresses. It is encoded in OpenSSH wire format. + bytes host_public_key = 1; +} + +// ExchangeSSHKeysResponse is a response for ExchangeSSHKeys. +message ExchangeSSHKeysResponse { + // UserPublicKey is the user key that should be trusted by VNet for incoming + // connections from SSH clients. It is encoded in OpenSSH wire format. + bytes user_public_key = 1; +} From 87080113f945fca5ca147ed566da39a02d254877 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Sun, 1 Jun 2025 00:07:02 -0700 Subject: [PATCH 05/25] [v18][vnet] feat: write OpenSSH-compatible config file for VNet SSH Backport #55239 to branch/v18 --- api/utils/keypaths/keypaths.go | 10 +++ lib/vnet/opensshconfig.go | 131 ++++++++++++++++++++++++++++++- lib/vnet/opensshconfig_test.go | 104 ++++++++++++++++++++++++ lib/vnet/user_process.go | 12 ++- lib/vnet/user_process_darwin.go | 16 ++-- lib/vnet/user_process_windows.go | 16 ++-- 6 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 lib/vnet/opensshconfig_test.go diff --git a/api/utils/keypaths/keypaths.go b/api/utils/keypaths/keypaths.go index f1853313f67fe..60b2c472f8df5 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -82,6 +82,9 @@ const ( // vnetKnownHosts is the file name of the known_hosts file trusted by // third-party SSH clients connecting to VNet SSH. vnetKnownHosts = "vnet_known_hosts" + // vnetSSHConfig is the file name of the generated OpenSSH-compatible config + // file to be used by third-party SSH clients connecting to VNet SSH. + vnetSSHConfig = "vnet_ssh_config" ) // Here's the file layout of all these keypaths. @@ -93,6 +96,7 @@ const ( // ├── id_vnet --> SSH Private Key for third-party clients of VNet SSH // ├── id_vnet.pub --> SSH Public Key for third-party clients of VNet SSH // ├── vnet_known_hosts --> trusted certificate authorities (their keys) for third-party clients of VNet SSH +// ├── vnet_ssh_config --> OpenSSH-compatible config file for third-party clients of VNet SSH // └── keys --> session keys directory // ├── one.example.com --> Proxy hostname // │ ├── certs.pem --> TLS CA certs for the Teleport CA @@ -456,6 +460,12 @@ func VNetKnownHostsPath(baseDir string) string { return filepath.Join(baseDir, vnetKnownHosts) } +// VNetSSHConfigPath returns the path to VNet's generated OpenSSH-compatible +// config file. +func VNetSSHConfigPath(baseDir string) string { + return filepath.Join(baseDir, vnetSSHConfig) +} + // TrimKeyPathSuffix returns the given path with any key suffix/extension trimmed off. func TrimKeyPathSuffix(path string) string { return strings.TrimSuffix(path, fileExtTLSKey) diff --git a/lib/vnet/opensshconfig.go b/lib/vnet/opensshconfig.go index 3bd5176729b9a..bd7ce4f50d3cc 100644 --- a/lib/vnet/opensshconfig.go +++ b/lib/vnet/opensshconfig.go @@ -17,23 +17,34 @@ package vnet import ( + "bytes" + "cmp" + "context" "encoding/pem" "io" "os" "path/filepath" + "slices" + "strconv" + "strings" + "text/template" + "time" renameio "github.com/google/renameio/v2/maybe" // Writes aren't guaranteed to be atomic on Windows. "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" "golang.org/x/crypto/ssh" "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/lib/cryptosuites" ) const ( - filePerms os.FileMode = 0o600 + filePerms os.FileMode = 0o600 + sshConfigurationUpdateInterval = 30 * time.Second ) // writeSSHKeys writes hostCAKey to ${TELEPORT_HOME}/vnet_known_hosts so that @@ -121,3 +132,121 @@ func generateAndWriteUserKey(profilePath string) (ssh.PublicKey, error) { } return userPubKey, nil } + +// sshConfigurator writes an OpenSSH-compatible config file to +// TELEPORT_HOME/vnet_ssh_config, and keeps it up to date with the list of +// clusters that should match. +type sshConfigurator struct { + cfg sshConfiguratorConfig + profilePath string + clock clockwork.Clock +} + +type sshConfiguratorConfig struct { + clientApplication ClientApplication + homePath string + clock clockwork.Clock +} + +func newSSHConfigurator(cfg sshConfiguratorConfig) *sshConfigurator { + return &sshConfigurator{ + cfg: cfg, + profilePath: fullProfilePath(cfg.homePath), + clock: cmp.Or(cfg.clock, clockwork.NewRealClock()), + } +} + +func (c *sshConfigurator) runConfigurationLoop(ctx context.Context) error { + if err := c.updateSSHConfiguration(ctx); err != nil { + return trace.Wrap(err, "generating vnet_ssh_config") + } + // Delete the configuration file before exiting, if it is imported by the + // default SSH config file it will just stop taking effect. + defer func() { + if err := deleteSSHConfigFile(c.profilePath); err != nil { + log.WarnContext(ctx, "Failed to delete vnet_ssh_config while shutting down", "error", err) + } + }() + // clock.After is intentionally used in the loop instead of a ticker simply + // for more reliable testing. In the test I use clock.BlockUntilContext(1) + // to block until the loop is stuck waiting on the clock. If I used + // clock.NewTicker instead, the ticker always counts as a waiter, and that + // strategy doesn't work. In go 1.25 we can use testing/synctest instead. + for { + select { + case <-c.clock.After(sshConfigurationUpdateInterval): + if err := c.updateSSHConfiguration(ctx); err != nil { + return trace.Wrap(err, "updating vnet_ssh_config") + } + case <-ctx.Done(): + return trace.Wrap(ctx.Err(), "context canceled, shutting down vnet_ssh_config update loop") + } + } +} + +func (c *sshConfigurator) updateSSHConfiguration(ctx context.Context) error { + profileNames, err := c.cfg.clientApplication.ListProfiles() + if err != nil { + return trace.Wrap(err, "listing profiles") + } + hostMatchers := make([]string, 0, len(profileNames)) + for _, profileName := range profileNames { + rootClient, err := c.cfg.clientApplication.GetCachedClient(ctx, profileName, "" /*leafClusterName*/) + if err != nil { + log.WarnContext(ctx, + "Failed to get root cluster client from cache, profile may be expired, not configuring VNet SSH for this cluster", + "profile", profileName, "error", err) + continue + } + hostMatchers = append(hostMatchers, hostMatcher(rootClient.RootClusterName())) + } + hostMatchers = utils.Deduplicate(hostMatchers) + slices.Sort(hostMatchers) + hostMatchersString := strings.Join(hostMatchers, " ") + return trace.Wrap(writeSSHConfigFile(c.profilePath, hostMatchersString)) +} + +func hostMatcher(clusterName string) string { + return "*." + strings.Trim(clusterName, ".") +} + +func deleteSSHConfigFile(profilePath string) error { + p := keypaths.VNetSSHConfigPath(profilePath) + if err := os.Remove(p); err != nil { + err = trace.ConvertSystemError(err) + if trace.IsNotFound(err) { + return nil + } + return trace.Wrap(err, "deleting %s", p) + } + return nil +} + +func writeSSHConfigFile(profilePath, hostMatchers string) error { + t := template.Must(template.New("ssh_config").Parse(configFileTemplate)) + var b bytes.Buffer + if err := t.Execute(&b, configFileTemplateInput{ + Hosts: hostMatchers, + PrivateKeyPath: strconv.Quote(keypaths.VNetClientSSHKeyPath(profilePath)), + KnownHostsPath: strconv.Quote(keypaths.VNetKnownHostsPath(profilePath)), + }); err != nil { + return trace.Wrap(err, "generating SSH config file") + } + p := keypaths.VNetSSHConfigPath(profilePath) + err := renameio.WriteFile(p, b.Bytes(), filePerms) + return trace.Wrap(trace.ConvertSystemError(err), "writing SSH config file to %s", p) +} + +const configFileTemplate = `Host {{ .Hosts }} + IdentityFile {{ .PrivateKeyPath }} + GlobalKnownHostsFile {{ .KnownHostsPath }} + UserKnownHostsFile /dev/null + StrictHostKeyChecking yes + IdentitiesOnly yes +` + +type configFileTemplateInput struct { + Hosts string + PrivateKeyPath string + KnownHostsPath string +} diff --git a/lib/vnet/opensshconfig_test.go b/lib/vnet/opensshconfig_test.go new file mode 100644 index 0000000000000..3289577f783aa --- /dev/null +++ b/lib/vnet/opensshconfig_test.go @@ -0,0 +1,104 @@ +// 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 vnet + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keypaths" +) + +func TestSSHConfigurator(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + clock := clockwork.NewFakeClockAt(time.Now()) + homePath := t.TempDir() + + fakeClientApp := newFakeClientApp(ctx, t, &fakeClientAppConfig{ + clusters: map[string]testClusterSpec{ + "cluster1": {}, + "cluster2": {}, + }, + // Give the fake client app a different clock so we can rely on + // clock.BlockUntilContext only capturing the SSH configuration loop. + clock: clockwork.NewRealClock(), + }) + + c := newSSHConfigurator(sshConfiguratorConfig{ + clientApplication: fakeClientApp, + homePath: homePath, + clock: clock, + }) + errC := make(chan error) + go func() { + errC <- c.runConfigurationLoop(ctx) + }() + + // Intentionally not using the template defined in the production code to + // test that it actually produces output that looks like this. + expectedConfigFile := func(expectedHosts string) string { + return fmt.Sprintf(`Host %s + IdentityFile "%s/id_vnet" + GlobalKnownHostsFile "%s/vnet_known_hosts" + UserKnownHostsFile /dev/null + StrictHostKeyChecking yes + IdentitiesOnly yes +`, + expectedHosts, + homePath, homePath) + } + + assertConfigFile := func(expectedHosts string) { + t.Helper() + expected := expectedConfigFile(expectedHosts) + contents, err := os.ReadFile(keypaths.VNetSSHConfigPath(homePath)) + require.NoError(t, err) + require.Equal(t, expected, string(contents)) + } + + // Wait until the configurator has had a chance to write the initial config + // file and then get blocked in the loop. + clock.BlockUntilContext(ctx, 1) + // Assert the config file contains both root clusters reported by + // fakeClientApp. + assertConfigFile("*.cluster1 *.cluster2") + + // Add a root cluster, wait until the configurator is blocked in the loop, + // advance the clock, wait until the configurator is blocked again + // indicating it should have updated the config and made it back into the + // loop, and then assert that the new cluster is in the config file. + fakeClientApp.cfg.clusters["cluster3"] = testClusterSpec{} + clock.BlockUntilContext(ctx, 1) + clock.Advance(sshConfigurationUpdateInterval) + clock.BlockUntilContext(ctx, 1) + assertConfigFile("*.cluster1 *.cluster2 *.cluster3") + + // Kill the configurator, wait for it to return, and assert that the config + // file was deleted. + cancel() + require.ErrorIs(t, <-errC, context.Canceled) + _, err := os.Stat(keypaths.VNetSSHConfigPath(homePath)) + require.ErrorIs(t, err, os.ErrNotExist) +} diff --git a/lib/vnet/user_process.go b/lib/vnet/user_process.go index 75df45f7fd196..48eb3dbb38595 100644 --- a/lib/vnet/user_process.go +++ b/lib/vnet/user_process.go @@ -112,13 +112,23 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* if err != nil { return nil, trace.Wrap(err) } + + processManager, processCtx := newProcessManager() + sshConfigurator := newSSHConfigurator(sshConfiguratorConfig{ + clientApplication: clientApplication, + }) + processManager.AddCriticalBackgroundTask("SSH configuration loop", func() error { + return trace.Wrap(sshConfigurator.runConfigurationLoop(processCtx)) + }) + userProcess := &UserProcess{ clientApplication: clientApplication, osConfigProvider: osConfigProvider, clientApplicationService: clientApplicationService, clock: clock, + processManager: processManager, } - if err := userProcess.runPlatformUserProcess(ctx); err != nil { + if err := userProcess.runPlatformUserProcess(processCtx); err != nil { return nil, trace.Wrap(err) } return userProcess, nil diff --git a/lib/vnet/user_process_darwin.go b/lib/vnet/user_process_darwin.go index a88f2313688a6..44c0d672470ab 100644 --- a/lib/vnet/user_process_darwin.go +++ b/lib/vnet/user_process_darwin.go @@ -35,7 +35,7 @@ import ( // interface that the daemon uses to query application names and get user // certificates for apps. If successful it sets p.processManager and // p.networkStackInfo. -func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { +func (p *UserProcess) runPlatformUserProcess(processCtx context.Context) error { ipcCreds, err := newIPCCredentials() if err != nil { return trace.Wrap(err, "creating credentials for IPC") @@ -67,16 +67,14 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { ) vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService) - pm, processCtx := newProcessManager() - p.processManager = pm - pm.AddCriticalBackgroundTask("admin process", func() error { + p.processManager.AddCriticalBackgroundTask("admin process", func() error { defer func() { // Delete service credentials after the service terminates. if ipcCreds.client.remove(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential files", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential files", "error", err) } if err := os.RemoveAll(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential directory", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential directory", "error", err) } }() daemonConfig := daemon.Config{ @@ -85,13 +83,13 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { } return trace.Wrap(execAdminProcess(processCtx, daemonConfig)) }) - pm.AddCriticalBackgroundTask("gRPC service", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC service", func() error { log.InfoContext(processCtx, "Starting gRPC service", "addr", listener.Addr().String()) return trace.Wrap(grpcServer.Serve(listener), "serving VNet user process gRPC service") }) - pm.AddCriticalBackgroundTask("gRPC server closer", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC server closer", func() error { // grpcServer.Serve does not stop on its own when processCtx is done, so // this task waits for processCtx and then explicitly stops grpcServer. <-processCtx.Done() @@ -104,6 +102,6 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { p.networkStackInfo = nsi return nil case <-processCtx.Done(): - return trace.Wrap(pm.Wait(), "process manager exited before network stack info was received") + return trace.Wrap(p.processManager.Wait(), "process manager exited before network stack info was received") } } diff --git a/lib/vnet/user_process_windows.go b/lib/vnet/user_process_windows.go index 80d935f209fd1..e0ab82eda82cf 100644 --- a/lib/vnet/user_process_windows.go +++ b/lib/vnet/user_process_windows.go @@ -37,7 +37,7 @@ import ( // interface that the admin process uses to query application names and get user // certificates for apps. It returns a [ProcessManager] which controls the // lifecycle of both the user and admin processes. -func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { +func (p *UserProcess) runPlatformUserProcess(processCtx context.Context) error { ipcCreds, err := newIPCCredentials() if err != nil { return trace.Wrap(err, "creating credentials for IPC") @@ -77,17 +77,15 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { ) vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService) - pm, processCtx := newProcessManager() - p.processManager = pm - pm.AddCriticalBackgroundTask("admin process", func() error { + p.processManager.AddCriticalBackgroundTask("admin process", func() error { log.InfoContext(processCtx, "Starting Windows service") defer func() { // Delete service credentials after the service terminates. if ipcCreds.client.remove(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential files", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential files", "error", err) } if err := os.RemoveAll(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential directory", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential directory", "error", err) } }() return trace.Wrap(runService(processCtx, &windowsAdminProcessConfig{ @@ -96,13 +94,13 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { userSID: userSID, })) }) - pm.AddCriticalBackgroundTask("gRPC service", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC service", func() error { log.InfoContext(processCtx, "Starting gRPC service", "addr", listener.Addr().String()) return trace.Wrap(grpcServer.Serve(listener), "serving VNet user process gRPC service") }) - pm.AddCriticalBackgroundTask("gRPC server closer", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC server closer", func() error { // grpcServer.Serve does not stop on its own when processCtx is done, so // this task waits for processCtx and then explicitly stops grpcServer. <-processCtx.Done() @@ -115,7 +113,7 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { p.networkStackInfo = nsi return nil case <-processCtx.Done(): - return trace.Wrap(pm.Wait(), "process manager exited before network stack info was received") + return trace.Wrap(p.processManager.Wait(), "process manager exited before network stack info was received") } } From 6d698e6ea2c42763c35ad9d78a25f6f55aa12813 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Fri, 13 Jun 2025 09:26:36 -0700 Subject: [PATCH 06/25] [v18][vnet] fix: support . for VNet SSH Backport #55688 to branch/v18 --- lib/vnet/fqdn_resolver.go | 27 ++++++++-------- lib/vnet/opensshconfig.go | 11 +++++++ lib/vnet/opensshconfig_test.go | 50 ++++++++++++++++++----------- lib/vnet/ssh_handler.go | 3 +- lib/vnet/ssh_provider.go | 14 ++++----- lib/vnet/tcp_handler_resolver.go | 2 +- lib/vnet/user_process.go | 1 + lib/vnet/vnet_test.go | 54 ++++++++++++++------------------ rfd/0207-vnet-ssh.md | 20 ++++++------ 9 files changed, 98 insertions(+), 84 deletions(-) diff --git a/lib/vnet/fqdn_resolver.go b/lib/vnet/fqdn_resolver.go index c204ae7047f1e..38dad60b12419 100644 --- a/lib/vnet/fqdn_resolver.go +++ b/lib/vnet/fqdn_resolver.go @@ -226,11 +226,12 @@ func (r *fqdnResolver) resolveAppInfoForCluster( }, nil } -// VNet SSH handles SSH hostnames matching ".." or -// "...". tryResolveSSH checks if -// fqdn matches that pattern for any logged-in cluster and if so returns a -// match. We never actually query for whether or not a matching SSH node exists, -// we just attempt to dial it when the client connects to the assigned IP. +// VNet SSH handles SSH hostnames matching "..", where +// the may be the name of a root or leaf cluster. +// tryResolveSSH checks if fqdn matches that pattern for any known cluster +// and if so returns a match. We never actually query for whether or not a +// matching SSH node exists, we just attempt to dial it when the client +// connects to the assigned IP. func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, fqdn string) (*vnetv1.ResolveFQDNResponse, error) { for _, profileName := range profileNames { log := log.With("profile", profileName) @@ -240,23 +241,21 @@ func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, continue } rootClusterName := rootClient.ClusterName() - if !isDescendantSubdomain(fqdn, rootClusterName) { - continue - } + log = log.With("root_cluster", rootClusterName) leafClusters, err := r.cfg.leafClusterCache.getLeafClusters(ctx, rootClient) if err != nil { // Good chance we're here because the user is not logged in to the profile. log.ErrorContext(ctx, "Failed to list leaf clusters, SSH nodes in this cluster will not be resolved", "error", err) - return nil, errNoMatch + continue } rootDialOpts, err := r.cfg.clientApplication.GetDialOptions(ctx, profileName) if err != nil { log.ErrorContext(ctx, "Failed to get cluster dial options, SSH nodes in this cluster will not be resolved", "error", err) - return nil, errNoMatch + continue } for _, leafClusterName := range leafClusters { log := log.With("leaf_cluster", leafClusterName) - if !isDescendantSubdomain(fqdn, leafClusterName+"."+rootClusterName) { + if !isDescendantSubdomain(fqdn, leafClusterName) { continue } leafClient, err := r.cfg.clientApplication.GetCachedClient(ctx, profileName, leafClusterName) @@ -282,8 +281,10 @@ func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, }, }, nil } - // If it didn't match any leaf cluster assume it matches the root - // cluster. + // Didn't match any leaf, check if it's in the root cluster. + if !isDescendantSubdomain(fqdn, rootClusterName) { + continue + } clusterConfig, err := r.cfg.clusterConfigCache.GetClusterConfig(ctx, rootClient) if err != nil { log.ErrorContext(ctx, "Failed to get VNet config, SSH nodes in this cluster will not be resolved", "error", err) diff --git a/lib/vnet/opensshconfig.go b/lib/vnet/opensshconfig.go index bd7ce4f50d3cc..65f2587f63796 100644 --- a/lib/vnet/opensshconfig.go +++ b/lib/vnet/opensshconfig.go @@ -144,6 +144,7 @@ type sshConfigurator struct { type sshConfiguratorConfig struct { clientApplication ClientApplication + leafClusterCache *leafClusterCache homePath string clock clockwork.Clock } @@ -199,6 +200,16 @@ func (c *sshConfigurator) updateSSHConfiguration(ctx context.Context) error { continue } hostMatchers = append(hostMatchers, hostMatcher(rootClient.RootClusterName())) + leafClusters, err := c.cfg.leafClusterCache.getLeafClusters(ctx, rootClient) + if err != nil { + log.WarnContext(ctx, + "Failed to list leaf clusters, not configuring VNet SSH for leaf clusters of this cluster", + "root_cluster", rootClient.ClusterName(), "error", err) + continue + } + for _, leafCluster := range leafClusters { + hostMatchers = append(hostMatchers, hostMatcher(leafCluster)) + } } hostMatchers = utils.Deduplicate(hostMatchers) slices.Sort(hostMatchers) diff --git a/lib/vnet/opensshconfig_test.go b/lib/vnet/opensshconfig_test.go index 3289577f783aa..4e933fc3bc915 100644 --- a/lib/vnet/opensshconfig_test.go +++ b/lib/vnet/opensshconfig_test.go @@ -33,23 +33,33 @@ func TestSSHConfigurator(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - clock := clockwork.NewFakeClockAt(time.Now()) homePath := t.TempDir() + // This test gives a fake clock only to the SSH configurator and a real + // clock to everything else, so that fakeClock.BlockUntilContext will + // reliably only capture the SSH configuration loop and nothing else. + fakeClock := clockwork.NewFakeClockAt(time.Now()) + realClock := clockwork.NewRealClock() + fakeClientApp := newFakeClientApp(ctx, t, &fakeClientAppConfig{ clusters: map[string]testClusterSpec{ - "cluster1": {}, + "cluster1": { + leafClusters: map[string]testClusterSpec{ + "leaf1": {}, + }, + }, "cluster2": {}, }, - // Give the fake client app a different clock so we can rely on - // clock.BlockUntilContext only capturing the SSH configuration loop. - clock: clockwork.NewRealClock(), + clock: realClock, }) + leafClusterCache, err := newLeafClusterCache(realClock) + require.NoError(t, err) c := newSSHConfigurator(sshConfiguratorConfig{ clientApplication: fakeClientApp, + leafClusterCache: leafClusterCache, homePath: homePath, - clock: clock, + clock: fakeClock, }) errC := make(chan error) go func() { @@ -80,25 +90,29 @@ func TestSSHConfigurator(t *testing.T) { // Wait until the configurator has had a chance to write the initial config // file and then get blocked in the loop. - clock.BlockUntilContext(ctx, 1) + fakeClock.BlockUntilContext(ctx, 1) // Assert the config file contains both root clusters reported by // fakeClientApp. - assertConfigFile("*.cluster1 *.cluster2") + assertConfigFile("*.cluster1 *.cluster2 *.leaf1") - // Add a root cluster, wait until the configurator is blocked in the loop, - // advance the clock, wait until the configurator is blocked again - // indicating it should have updated the config and made it back into the - // loop, and then assert that the new cluster is in the config file. - fakeClientApp.cfg.clusters["cluster3"] = testClusterSpec{} - clock.BlockUntilContext(ctx, 1) - clock.Advance(sshConfigurationUpdateInterval) - clock.BlockUntilContext(ctx, 1) - assertConfigFile("*.cluster1 *.cluster2 *.cluster3") + // Add a new root and leaf cluster, wait until the configurator is blocked + // in the loop, advance the clock, wait until the configurator is blocked + // again indicating it should have updated the config and made it back into + // the loop, and then assert that the new clusters are in the config file. + fakeClientApp.cfg.clusters["cluster3"] = testClusterSpec{ + leafClusters: map[string]testClusterSpec{ + "leaf2": {}, + }, + } + fakeClock.BlockUntilContext(ctx, 1) + fakeClock.Advance(sshConfigurationUpdateInterval) + fakeClock.BlockUntilContext(ctx, 1) + assertConfigFile("*.cluster1 *.cluster2 *.cluster3 *.leaf1 *.leaf2") // Kill the configurator, wait for it to return, and assert that the config // file was deleted. cancel() require.ErrorIs(t, <-errC, context.Canceled) - _, err := os.Stat(keypaths.VNetSSHConfigPath(homePath)) + _, err = os.Stat(keypaths.VNetSSHConfigPath(homePath)) require.ErrorIs(t, err, os.ErrNotExist) } diff --git a/lib/vnet/ssh_handler.go b/lib/vnet/ssh_handler.go index 02b95f548d325..1ca080d294ee9 100644 --- a/lib/vnet/ssh_handler.go +++ b/lib/vnet/ssh_handler.go @@ -59,14 +59,13 @@ func (h *sshHandler) handleTCPConnector(ctx context.Context, localPort uint16, c return trace.Wrap(err) } defer targetConn.Close() - return trace.Wrap(h.handleTCPConnectorWithTargetConn(ctx, localPort, connector, targetConn)) + return trace.Wrap(h.handleTCPConnectorWithTargetConn(ctx, connector, targetConn)) } // handleTCPConnectorWithTargetTCPConn handles an incoming TCP connection from // VNet when a TCP connection to the target host has already been established. func (h *sshHandler) handleTCPConnectorWithTargetConn( ctx context.Context, - localPort uint16, connector func() (net.Conn, error), targetConn net.Conn, ) error { diff --git a/lib/vnet/ssh_provider.go b/lib/vnet/ssh_provider.go index dd964b4967f04..1c54bab4048a3 100644 --- a/lib/vnet/ssh_provider.go +++ b/lib/vnet/ssh_provider.go @@ -17,6 +17,7 @@ package vnet import ( + "cmp" "context" "crypto/tls" "crypto/x509" @@ -241,18 +242,15 @@ type dialTarget struct { } func computeDialTarget(matchedCluster *vnetv1.MatchedCluster, fqdn string) dialTarget { - targetCluster := matchedCluster.GetRootCluster() - targetHost := strings.TrimSuffix(fqdn, "."+matchedCluster.GetRootCluster()+".") - leafCluster := matchedCluster.GetLeafCluster() - if leafCluster != "" { - targetCluster = leafCluster - targetHost = strings.TrimSuffix(targetHost, "."+leafCluster) - } + // matchedCluster.LeafCluster will be set if the host was in a leaf + // cluster, else it will be unset and the target cluster is the root. + targetCluster := cmp.Or(matchedCluster.GetLeafCluster(), matchedCluster.GetRootCluster()) + targetHost := strings.TrimSuffix(fqdn, "."+fullyQualify(targetCluster)) return dialTarget{ fqdn: fqdn, profile: matchedCluster.GetProfile(), rootCluster: matchedCluster.GetRootCluster(), - leafCluster: leafCluster, + leafCluster: matchedCluster.GetLeafCluster(), cluster: targetCluster, hostname: targetHost, addr: targetHost + ":0", diff --git a/lib/vnet/tcp_handler_resolver.go b/lib/vnet/tcp_handler_resolver.go index 47791a6b4e30c..7d4d0dbe16696 100644 --- a/lib/vnet/tcp_handler_resolver.go +++ b/lib/vnet/tcp_handler_resolver.go @@ -209,7 +209,7 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin h.setDecidedHandler(sshHandler) // Handle the incoming connection with the TCP connection to the target // SSH node that has already been established. - return sshHandler.handleTCPConnectorWithTargetConn(ctx, localPort, connector, targetConn) + return sshHandler.handleTCPConnectorWithTargetConn(ctx, connector, targetConn) } return trace.Errorf("rejecting connection to %s:%d", h.cfg.fqdn, localPort) } diff --git a/lib/vnet/user_process.go b/lib/vnet/user_process.go index 48eb3dbb38595..dc84f2b6c2f2c 100644 --- a/lib/vnet/user_process.go +++ b/lib/vnet/user_process.go @@ -116,6 +116,7 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* processManager, processCtx := newProcessManager() sshConfigurator := newSSHConfigurator(sshConfiguratorConfig{ clientApplication: clientApplication, + leafClusterCache: leafClusterCache, }) processManager.AddCriticalBackgroundTask("SSH configuration loop", func() error { return trace.Wrap(sshConfigurator.runConfigurationLoop(processCtx)) diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index 72674a59edad3..b980a5c8bf46f 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -749,20 +749,16 @@ func TestDialFakeApp(t *testing.T) { clusters: map[string]testClusterSpec{ "root1.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.root1.example.com"}, - appSpec{publicAddr: "echo2.root1.example.com"}, - appSpec{publicAddr: "echo.myzone.example.com"}, - appSpec{publicAddr: "echo.nested.myzone.example.com"}, - appSpec{publicAddr: "not.in.a.custom.zone"}, - appSpec{ + {publicAddr: "echo1.root1.example.com"}, + {publicAddr: "echo2.root1.example.com"}, + {publicAddr: "echo.myzone.example.com"}, + {publicAddr: "echo.nested.myzone.example.com"}, + {publicAddr: "not.in.a.custom.zone"}, + { publicAddr: "multi-port.root1.example.com", tcpPorts: []*types.PortRange{ - &types.PortRange{ - Port: 1337, - }, - &types.PortRange{ - Port: 4242, - }, + {Port: 1337}, + {Port: 4242}, }, }, }, @@ -773,36 +769,32 @@ func TestDialFakeApp(t *testing.T) { leafClusters: map[string]testClusterSpec{ "leaf1.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.leaf1.example.com"}, - appSpec{ + {publicAddr: "echo1.leaf1.example.com"}, + { publicAddr: "multi-port.leaf1.example.com", tcpPorts: []*types.PortRange{ - &types.PortRange{ - Port: 1337, - }, - &types.PortRange{ - Port: 4242, - }, + {Port: 1337}, + {Port: 4242}, }, }, }, }, "leaf2.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.leaf2.example.com"}, + {publicAddr: "echo1.leaf2.example.com"}, }, }, }, }, "root2.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.root2.example.com"}, - appSpec{publicAddr: "echo2.root2.example.com"}, + {publicAddr: "echo1.root2.example.com"}, + {publicAddr: "echo2.root2.example.com"}, }, leafClusters: map[string]testClusterSpec{ "leaf3.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.leaf3.example.com"}, + {publicAddr: "echo1.leaf3.example.com"}, }, }, }, @@ -1044,7 +1036,7 @@ func TestOnNewConnection(t *testing.T) { clusters: map[string]testClusterSpec{ "root1.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1"}, + {publicAddr: "echo1"}, }, cidrRange: "192.168.2.0/24", leafClusters: map[string]testClusterSpec{}, @@ -1105,15 +1097,15 @@ func testWithAlgorithmSuite(t *testing.T, suite types.SignatureAlgorithmSuite) { clusters: map[string]testClusterSpec{ "root.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1"}, - appSpec{publicAddr: "echo2"}, + {publicAddr: "echo1"}, + {publicAddr: "echo2"}, }, cidrRange: "192.168.2.0/24", leafClusters: map[string]testClusterSpec{ "leaf.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1"}, - appSpec{publicAddr: "echo2"}, + {publicAddr: "echo1"}, + {publicAddr: "echo2"}, }, cidrRange: "192.168.2.0/24", }, @@ -1296,7 +1288,7 @@ func TestSSH(t *testing.T) { }, { // Connection to node in leaf cluster should work. - dialAddr: "node.leaf1.example.com.root1.example.com", + dialAddr: "node.leaf1.example.com", dialPort: 22, expectCIDR: leaf1CIDR, sshUser: "testuser", @@ -1314,7 +1306,7 @@ func TestSSH(t *testing.T) { { // Connection to node in leaf cluster in alternate profile should // work. - dialAddr: "node.leaf2.example.com.root2.example.com", + dialAddr: "node.leaf2.example.com", dialPort: 22, expectCIDR: leaf2CIDR, sshUser: "testuser", diff --git a/rfd/0207-vnet-ssh.md b/rfd/0207-vnet-ssh.md index a78cf3c5fe8dc..66ebabf392673 100644 --- a/rfd/0207-vnet-ssh.md +++ b/rfd/0207-vnet-ssh.md @@ -7,9 +7,9 @@ state: draft ## Required Approvers -* Engineering: @espadolini && @rosstimothy -* Security: doyensec -* Product: @klizhentas +- Engineering: @espadolini && @rosstimothy +- Security: doyensec +- Product: @klizhentas ## What @@ -24,7 +24,7 @@ Advanced Teleport features like per-session MFA and hardware keys will be fully supported. Here's a demo with a proof-of-concept of the feature in action: -https://goteleport.zoom.us/clips/share/3xSvI4taSD6YgM1C0l12nQ + ## Why @@ -197,13 +197,11 @@ we already have an SSH forwarding server implemented in `lib/srv/forward/sshserv VNet SSH will support SSH connections to DNS names matching any of the following: - `.` -- `..` - `.` -- `..` These are the same patterns supported by our existing OpenSSH client integration, which will offer a seamless transition for users switching to VNet SSH -https://goteleport.com/docs/enroll-resources/server-access/openssh/openssh-agentless/#step-23-generate-an-ssh-client-configuration + If users prefer to use a shorter name to connect to SSH hosts, they can add a `CanonicalDomains` option to their `~/.ssh/config` file, e.g. @@ -236,7 +234,7 @@ If the user runs `tsh vnet` instead of Connect, we won't add anything to `vnet_ssh_config` will include the following: ``` -Host *.teleport.example.com *.leaf.teleport.example.com +Host *.root.example.com *.leaf.example.com IdentityFile "/Users/nic/Library/Application Support/tsh/id_vnet" GlobalKnownHostsFile "/Users/nic/Library/Application Support/tsh/vnet_known_hosts" UserKnownHostsFile /dev/null @@ -299,9 +297,8 @@ When the VNet process receives a DNS query this is how it will be resolved: 1. If it matches a web app the DNS request will be forwarded to upstream DNS servers (this is also as it implicitly works today, now we'll do it explicitly to skip assigning a VNet IP for web apps). -1. If the name does not match `*.` or - `*..` for any profile, the request will - be forwarded to upstream DNS servers. +1. If the name does not match `*.` or for any cluster, the + request will be forwarded to upstream DNS servers. 1. VNet will assign a free IP address to the FQDN, but at this point it will not know if this IP will later resolve to an SSH host or an app or neither. 1. VNet will return the IP address in an authoritative DNS answer. @@ -310,6 +307,7 @@ When the VNet process receives a DNS query this is how it will be resolved: When the VNet process receives a TCP connection at an address that has been assigned to an FQDN but does not yet know if there is a matching app or SSH host: + 1. An app lookup will run first in case an app has been added since the DNS query that assigned this IP. If the queried FQDN matches a TCP app then the IP will be permanently From 67e956187399a3d130ca8426c7204f65afd49429 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Fri, 13 Jun 2025 11:15:22 -0700 Subject: [PATCH 07/25] [v18][vnet] feat: add "Connect with VNet" button to SSH servers Backport #55623 to branch/v18 --- .../MenuLoginWithActionMenu.tsx | 2 +- .../src/ui/DocumentCluster/ActionButtons.tsx | 55 +++++++++---- .../DocumentCluster/UnifiedResources.test.tsx | 18 +++-- .../teleterm/src/ui/Search/actions.tsx | 4 +- .../ui/Search/pickers/useActionAttempts.ts | 4 +- .../teleterm/src/ui/Vnet/DocumentVnetInfo.tsx | 15 ++-- web/packages/teleterm/src/ui/Vnet/index.ts | 2 +- ...netAppLauncher.tsx => useVnetLauncher.tsx} | 79 ++++++++----------- .../documentsService/connectToApp.ts | 6 +- .../documentsService/documentsService.ts | 8 +- .../documentsService/testHelpers.ts | 2 +- .../documentsService/types.ts | 39 +++++---- .../workspacesService/workspacesService.ts | 2 +- 13 files changed, 125 insertions(+), 111 deletions(-) rename web/packages/teleterm/src/ui/Vnet/{useVnetAppLauncher.tsx => useVnetLauncher.tsx} (69%) diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx index 7c29494ce00d1..8a46e5a35b377 100644 --- a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx +++ b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx @@ -54,7 +54,7 @@ export const MenuLoginWithActionMenu = ({ inputType, }: { /** Button text for main menu button. */ - buttonText: string; + buttonText?: string; /** * Handles select or click in main menu items. * If isExternalUrl item returned by getLoginItems is true, a button with tag is rendered diff --git a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx index 2e34a07ba7c2b..8a9a2759ed2d2 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx @@ -35,6 +35,7 @@ import { MenuLogin, MenuLoginProps, } from 'shared/components/MenuLogin'; +import { MenuLoginWithActionMenu } from 'shared/components/MenuLoginWithActionMenu'; import { formatPortRange, @@ -57,12 +58,25 @@ import { import { IAppContext } from 'teleterm/ui/types'; import { DatabaseUri, routing } from 'teleterm/ui/uri'; import { retryWithRelogin } from 'teleterm/ui/utils'; -import { useVnetAppLauncher, useVnetContext } from 'teleterm/ui/Vnet'; +import { useVnetContext, useVnetLauncher } from 'teleterm/ui/Vnet'; export function ConnectServerActionButton(props: { server: Server; }): React.JSX.Element { const ctx = useAppContext(); + const { isSupported: isVnetSupported } = useVnetContext(); + const { launchVnet } = useVnetLauncher(); + + function connectWithVnet(): void { + const hostname = props.server.hostname; + const cluster = ctx.clustersService.findClusterByResource(props.server.uri); + const clusterName = cluster?.name || ''; + const addr = `${hostname}.${clusterName}`; + launchVnet({ + addrToCopy: addr, + resourceUri: props.server.uri, + }); + } function getSshLogins(): string[] { const cluster = ctx.clustersService.findClusterByResource(props.server.uri); @@ -80,21 +94,28 @@ export function ConnectServerActionButton(props: { ); } + const commonProps = { + inputType: MenuInputType.FILTER, + textTransform: 'none', + getLoginItems: () => getSshLogins().map(login => ({ login, url: '' })), + onSelect: (e, login) => connect(login), + transformOrigin: { + vertical: 'top', + horizontal: 'right', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'right', + }, + }; + + if (!isVnetSupported) { + return ; + } return ( - getSshLogins().map(login => ({ login, url: '' }))} - onSelect={(e, login) => connect(login)} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'right', - }} - /> + + Connect with VNet + ); } @@ -121,13 +142,13 @@ export function ConnectKubeActionButton(props: { export function ConnectAppActionButton(props: { app: App }): React.JSX.Element { const appContext = useAppContext(); const { isSupported: isVnetSupported } = useVnetContext(); - const { launchVnet } = useVnetAppLauncher(); + const { launchVnet } = useVnetLauncher(); function connectWithVnet(targetPort?: number): void { void launchVnet({ addrToCopy: appToAddrToCopy(props.app, targetPort), resourceUri: props.app.uri, - isMultiPort: !!props.app.tcpPorts.length, + isMultiPortApp: !!props.app.tcpPorts.length, }); } diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx index d2d9dab4483dd..244c26109af0f 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx @@ -45,7 +45,9 @@ import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvi import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers'; +import { ConnectionsContextProvider } from 'teleterm/ui/TopBar/Connections/connectionsContext'; import * as uri from 'teleterm/ui/uri'; +import { VnetContextProvider } from 'teleterm/ui/Vnet'; const mio = mockIntersectionObserver(); @@ -311,12 +313,16 @@ test.each([ - - + + + + + + diff --git a/web/packages/teleterm/src/ui/Search/actions.tsx b/web/packages/teleterm/src/ui/Search/actions.tsx index 559aa71b28f47..f8bd045aec7dd 100644 --- a/web/packages/teleterm/src/ui/Search/actions.tsx +++ b/web/packages/teleterm/src/ui/Search/actions.tsx @@ -31,7 +31,7 @@ import { ResourceRequest } from 'teleterm/ui/services/workspacesService/accessRe import { IAppContext } from 'teleterm/ui/types'; import { routing } from 'teleterm/ui/uri'; import { assertUnreachable, retryWithRelogin } from 'teleterm/ui/utils'; -import { VnetAppLauncher } from 'teleterm/ui/Vnet'; +import { VnetLauncher } from 'teleterm/ui/Vnet'; export interface SimpleAction { type: 'simple-action'; @@ -73,7 +73,7 @@ export type SearchAction = SimpleAction | ParametrizedAction; export function mapToAction( ctx: IAppContext, - launchVnet: VnetAppLauncher, + launchVnet: VnetLauncher, searchContext: SearchContext, result: SearchResult ): SearchAction { diff --git a/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts b/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts index 0b31754da7115..de73c428cce77 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts +++ b/web/packages/teleterm/src/ui/Search/pickers/useActionAttempts.ts @@ -38,7 +38,7 @@ import { } from 'teleterm/ui/Search/useSearch'; import { routing } from 'teleterm/ui/uri'; import { isRetryable } from 'teleterm/ui/utils/retryWithRelogin'; -import { useVnetAppLauncher, useVnetContext } from 'teleterm/ui/Vnet'; +import { useVnetContext, useVnetLauncher } from 'teleterm/ui/Vnet'; import { useDisplayResults } from './useDisplayResults'; @@ -48,7 +48,7 @@ export function useActionAttempts() { const searchContext = useSearchContext(); const { inputValue, filters, pauseUserInteraction } = searchContext; const { isSupported: isVnetSupported } = useVnetContext(); - const vnetLauncher = useVnetAppLauncher(); + const vnetLauncher = useVnetLauncher(); const launchVnet = isVnetSupported ? vnetLauncher.launchVnet : undefined; const [resourceSearchAttempt, runResourceSearch, setResourceSearchAttempt] = diff --git a/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx b/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx index eb6bd8ecc0db7..878ae406aa808 100644 --- a/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx +++ b/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx @@ -31,7 +31,7 @@ import type * as docTypes from 'teleterm/ui/services/workspacesService'; import { routing } from 'teleterm/ui/uri'; import appAccessPng from './app-access.png'; -import { useVnetAppLauncher } from './useVnetAppLauncher'; +import { useVnetLauncher } from './useVnetLauncher'; import { useVnetContext } from './vnetContext'; export function DocumentVnetInfo(props: { @@ -46,7 +46,7 @@ export function DocumentVnetInfo(props: { stopAttempt, status, } = useVnetContext(); - const { launchVnetWithoutFirstTimeCheck } = useVnetAppLauncher(); + const { launchVnetWithoutFirstTimeCheck } = useVnetLauncher(); const userAtHost = useMemo(() => { const { hostname, username } = mainProcessClient.getRuntimeSettings(); return `${username}@${hostname}`; @@ -55,13 +55,10 @@ export function DocumentVnetInfo(props: { const proxyHostname = routing.parseClusterName(rootClusterUri); const startVnet = async () => { - await launchVnetWithoutFirstTimeCheck({ - addrToCopy: doc.app?.targetAddress, - isMultiPort: doc.app?.isMultiPort, - }); - // Remove targetAddress so that subsequent launches of VNet from this specific doc won't copy - // the stale app address to the clipboard. - documentsService.update(doc.uri, { app: undefined }); + await launchVnetWithoutFirstTimeCheck(doc.launcherArgs); + // Remove launcherArgs so that subsequent launches of VNet from this + // specific doc won't copy the stale address to the clipboard. + documentsService.update(doc.uri, { launcherArgs: undefined }); }; return ( diff --git a/web/packages/teleterm/src/ui/Vnet/index.ts b/web/packages/teleterm/src/ui/Vnet/index.ts index 84e6538a4bc67..0199a9148e28a 100644 --- a/web/packages/teleterm/src/ui/Vnet/index.ts +++ b/web/packages/teleterm/src/ui/Vnet/index.ts @@ -19,4 +19,4 @@ export * from './VnetSliderStep'; export * from './vnetContext'; export { VnetConnectionItem } from './VnetConnectionItem'; -export * from './useVnetAppLauncher'; +export * from './useVnetLauncher'; diff --git a/web/packages/teleterm/src/ui/Vnet/useVnetAppLauncher.tsx b/web/packages/teleterm/src/ui/Vnet/useVnetLauncher.tsx similarity index 69% rename from web/packages/teleterm/src/ui/Vnet/useVnetAppLauncher.tsx rename to web/packages/teleterm/src/ui/Vnet/useVnetLauncher.tsx index dd18aaeeddecc..9ede335521b95 100644 --- a/web/packages/teleterm/src/ui/Vnet/useVnetAppLauncher.tsx +++ b/web/packages/teleterm/src/ui/Vnet/useVnetLauncher.tsx @@ -19,48 +19,31 @@ import { useCallback, useMemo } from 'react'; import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { VnetLauncherArgs } from 'teleterm/ui/services/workspacesService/documentsService/types'; import { useConnectionsContext } from 'teleterm/ui/TopBar/Connections/connectionsContext'; -import { ResourceUri, routing } from 'teleterm/ui/uri'; +import { routing } from 'teleterm/ui/uri'; import { useVnetContext } from './vnetContext'; -export type VnetAppLauncher = (args: VnetAppLauncherArgs) => Promise; +export type VnetLauncher = (args: VnetLauncherArgs) => Promise; -// NOTE: Almost every field added to VnetAppLauncherArgs will need to be added to DocumentVnetInfo. -// -// This is because during the first launch of VNet through useVnetAppLauncher, the act of launching -// VNet is split into two parts. When a user clicks the "Connect" button next to a TCP app or opens -// one from the search bar, a DocumentVnetInfo is opened first. Then the user can start VNet from -// there, which should carry out the launch of that particular app. Hence the need to copy some -// arguments from the app to the doc. -type VnetAppLauncherArgs = { - addrToCopy: string | undefined; - /** - * resourceUri lets the VNet launcher establish which workspace to open the info doc in if - * there's a need to do it. - */ - resourceUri: ResourceUri; - isMultiPort: boolean; -}; - -export const useVnetAppLauncher = (): { +export const useVnetLauncher = (): { /** * launchVnet is a function that manages VNet start when: * * - The user clicks "Connect" next to a TCP app or selects one of the ports from the menu. * - The user selects a TCP app through the search bar. + * - The user clicks "Connect with VNet" on an SSH server. * * If the user is yet to start VNet, it opens the info doc. If they already started it in the past, - * it starts VNet and then copies the address of the app to the clipboard. + * it starts VNet and then copies the address of the resource to the clipboard. */ - launchVnet: VnetAppLauncher; + launchVnet: VnetLauncher; /** * launchVnetWithoutFirstTimeCheck never opens the info doc, it starts VNet and then copies the - * address of the app to the clipboard. + * address of the resource to the clipboard. */ - launchVnetWithoutFirstTimeCheck: ( - args: Pick - ) => Promise; + launchVnetWithoutFirstTimeCheck: (args?: VnetLauncherArgs) => Promise; } => { const { notificationsService, workspacesService } = useAppContext(); const { start, status, startAttempt, hasEverStarted } = useVnetContext(); @@ -82,8 +65,8 @@ export const useVnetAppLauncher = (): { }, [status.value, startAttempt.status, open, start]); const openInfoDoc = useCallback( - async ({ addrToCopy, resourceUri, isMultiPort }: VnetAppLauncherArgs) => { - const rootClusterUri = routing.ensureRootClusterUri(resourceUri); + async (args: VnetLauncherArgs) => { + const rootClusterUri = routing.ensureRootClusterUri(args.resourceUri); // Since VNet app launcher might be called from the search bar, we have to account for the // user being in a different workspace than the selected app. const { isAtDesiredWorkspace } = @@ -105,35 +88,39 @@ export const useVnetAppLauncher = (): { // Update targetAddress so that clicking "Start VNet" from the info doc is going to copy that // address to clipboard. docsService.update(docUri, { - app: { targetAddress: addrToCopy, isMultiPort }, + launcherArgs: args, }); }, [workspacesService] ); const launchVnetAndCopyAddr = useCallback( - async ({ - addrToCopy, - isMultiPort, - }: Pick) => { + async (args?: VnetLauncherArgs) => { if (!(await launchVnet())) { return; } - - if (!addrToCopy) { + if (!args) { + // args are optional, if unset don't copy anything to the clipboard. return; } - - const copiedToClipboard = copyAddrToClipboard(addrToCopy); - notificationsService.notifyInfo( - [ - `Connect via VNet by using ${addrToCopy}`, - copiedToClipboard && '(copied to clipboard)', - !isMultiPort && 'and any port', - ] - .filter(Boolean) - .join(' ') + '.' - ); + const { addrToCopy, isMultiPortApp, resourceUri } = args; + const isApp = !!routing.parseAppUri(resourceUri)?.params?.appId; + const isServer = !!routing.parseServerUri(resourceUri)?.params?.serverId; + let msgParts = []; + if (isApp) { + msgParts.push(`Connect via VNet by using ${addrToCopy}`); + } else if (isServer) { + msgParts.push(`Connect with any SSH client to ${addrToCopy}`); + } else { + msgParts.push(`Connect via VNet to ${addrToCopy}`); + } + if (copyAddrToClipboard(addrToCopy)) { + msgParts.push('(copied to clipboard)'); + } + if (isApp && !isMultiPortApp) { + msgParts.push('and any port'); + } + notificationsService.notifyInfo(msgParts.join(' ') + '.'); }, [launchVnet, notificationsService] ); diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts index 2602b80b0763b..8343dca0cc6fe 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/connectToApp.ts @@ -27,7 +27,7 @@ import { import { appToAddrToCopy } from 'teleterm/services/vnet/app'; import { IAppContext } from 'teleterm/ui/types'; import { AppUri, routing } from 'teleterm/ui/uri'; -import { VnetAppLauncher } from 'teleterm/ui/Vnet'; +import { VnetLauncher } from 'teleterm/ui/Vnet'; import { DocumentOrigin } from './types'; @@ -46,7 +46,7 @@ export async function connectToApp( * launchVnet is supposed to be provided if VNet is supported. If so, connectToApp is going to use * this function when targeting a TCP app. Otherwise it'll create an app gateway. */ - launchVnet: null | VnetAppLauncher, + launchVnet: null | VnetLauncher, target: App, telemetry: { origin: DocumentOrigin }, options?: { @@ -111,7 +111,7 @@ export async function connectToApp( await launchVnet({ addrToCopy: appToAddrToCopy(target), resourceUri: target.uri, - isMultiPort: !!target.tcpPorts.length, + isMultiPortApp: !!target.tcpPorts.length, }); return; } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index e974727d40926..352f161adba54 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -51,6 +51,7 @@ import { DocumentTshNode, DocumentVnetDiagReport, DocumentVnetInfo, + VnetLauncherArgs, WebSessionRequest, } from './types'; @@ -243,10 +244,7 @@ export class DocumentsService { createVnetInfoDocument(opts: { rootClusterUri: RootClusterUri; - app?: { - targetAddress: string; - isMultiPort: boolean; - }; + launcherArgs?: VnetLauncherArgs; }): DocumentVnetInfo { const uri = routing.getDocUri({ docId: unique() }); @@ -255,7 +253,7 @@ export class DocumentsService { kind: 'doc.vnet_info', title: 'VNet', rootClusterUri: opts.rootClusterUri, - app: opts.app, + launcherArgs: opts.launcherArgs, }; } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts index 90f3f72cd77ac..2f393d8cac0a5 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/testHelpers.ts @@ -221,7 +221,7 @@ export function makeDocumentVnetInfo( uri: '/docs/vnet-info', title: 'VNet', rootClusterUri, - app: undefined, + launcherArgs: undefined, ...props, }; } diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index cf702cab71fe8..ad7fb854a503c 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -227,30 +227,35 @@ export interface DocumentVnetInfo extends DocumentBase { // the document fields, hence why rootClusterUri is defined here. rootClusterUri: uri.RootClusterUri; /** - * Details of the app if the doc was opened by selecting a specific TCP app. + * Details of the resource if the doc was opened by selecting a specific resource. * * This field is needed to facilitate a scenario where a first-time user clicks "Connect" next to - * a TCP app, which opens this doc. Once the user clicks "Start VNet" in the doc, Connect should - * continue the regular flow of connecting to a TCP app through VNet, which means it should copy - * the address of the app to the clipboard, hence this field. + * a TCP app or SSH server, which opens this doc. Once the user clicks "Start VNet" in the doc, + * Connect should continue the regular flow of connecting to a TCP app through VNet, which means + * it should copy the address of the resource to the clipboard, hence this field. * - * app is removed when restoring persisted state. Let's say the user opens the doc through the - * "Connect" button of a specific app. If they close the app and then reopen the docs, we don't + * launcherArgs is removed when restoring persisted state. Let's say the user opens the doc through + * the "Connect" button of a specific app. If they close the app and then reopen the doc, we don't * want the "Start VNet" button to copy the address of the app from the prev session. */ - app: - | { - /** - * The address that's going to be copied to the clipboard after user starts VNet for the - * first time through this document. - * - */ - targetAddress: string | undefined; - isMultiPort: boolean; - } - | undefined; + launcherArgs: VnetLauncherArgs | undefined; } +/** + * Details about a user-selected resource that prompted the VNet launch, so + * that addrToCopy can be copied to the clipboard after VNet launches with a + * helpful message displayed in a notification. + */ +export type VnetLauncherArgs = { + // The address that's going to be copied to the clipboard. + addrToCopy: string; + // The URI of the resource the user clicked. + resourceUri: uri.ResourceUri; + // True if the user clicked a multi-port TCP app, used to render a slightly + // different message in the (copied to clipboard) notification. + isMultiPortApp?: boolean; +}; + /** * Document to authorize a web session with device trust. * Unlike other documents, it is not persisted on disk. diff --git a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts index 1f2874502cad6..166e4a01a59f7 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/workspacesService.ts @@ -590,7 +590,7 @@ export class WorkspacesService extends ImmutableStore { if (d.kind === 'doc.vnet_info') { const documentVnetInfo: DocumentVnetInfo = { ...d, - app: undefined, + launcherArgs: undefined, }; return documentVnetInfo; } From 76232a3709bdb466a1399253fa3c142cfc0e9e82 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Fri, 13 Jun 2025 12:48:31 -0700 Subject: [PATCH 08/25] fix test in backport --- .../teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx index cd88b6df22e4c..2da3ec26a9d90 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx @@ -404,7 +404,9 @@ it('passes props with stable identity to ', async () => { - {children} + + {children} + From 75d1c5a74175fa2546e0653d22eba62651d858de Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Tue, 17 Jun 2025 09:10:56 -0700 Subject: [PATCH 09/25] [v18][vnet] feat: support VNet SSH when cluster name does not match proxy public addr Backport #55655 to branch/v18 --- lib/teleterm/vnet/service.go | 6 +- lib/teleterm/vnet/service_darwin.go | 6 +- lib/vnet/admin_process_darwin.go | 2 +- lib/vnet/admin_process_windows.go | 2 +- lib/vnet/client_application_service.go | 17 +- lib/vnet/clusterconfigcache.go | 22 ++- lib/vnet/fqdn_resolver.go | 2 +- lib/vnet/local_osconfig_provider.go | 119 -------------- lib/vnet/osconfig.go | 4 +- ...onfig_provider.go => osconfig_provider.go} | 12 +- ...ider_test.go => osconfig_provider_test.go} | 6 +- lib/vnet/unified_cluster_config_provider.go | 152 ++++++++++++++++++ lib/vnet/user_process.go | 30 ++-- lib/vnet/vnet_test.go | 9 +- 14 files changed, 215 insertions(+), 174 deletions(-) delete mode 100644 lib/vnet/local_osconfig_provider.go rename lib/vnet/{remote_osconfig_provider.go => osconfig_provider.go} (83%) rename lib/vnet/{remote_osconfig_provider_test.go => osconfig_provider_test.go} (93%) create mode 100644 lib/vnet/unified_cluster_config_provider.go diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index cef2749ee8ab4..f54565c2684e1 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -225,15 +225,15 @@ func (s *Service) ListDNSZones(ctx context.Context, req *api.ListDNSZonesRequest s.mu.Unlock() return nil, trace.CompareFailed("VNet is not running") } - osConfigProvider := s.vnetProcess.GetOSConfigProvider() + unifiedClusterConfigProvider := s.vnetProcess.GetUnifiedClusterConfigProvider() s.mu.Unlock() - targetOSConfig, err := osConfigProvider.GetTargetOSConfiguration(ctx) + unifiedClusterConfig, err := unifiedClusterConfigProvider.GetUnifiedClusterConfig(ctx) if err != nil { return nil, trace.Wrap(err) } return &api.ListDNSZonesResponse{ - DnsZones: targetOSConfig.GetDnsZones(), + DnsZones: unifiedClusterConfig.AppDNSZones(), }, nil } diff --git a/lib/teleterm/vnet/service_darwin.go b/lib/teleterm/vnet/service_darwin.go index 707d26d70a43b..9eaddaf31dba8 100644 --- a/lib/teleterm/vnet/service_darwin.go +++ b/lib/teleterm/vnet/service_darwin.go @@ -77,14 +77,14 @@ func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsReq } func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, error) { - targetOSConfig, err := s.vnetProcess.GetOSConfigProvider().GetTargetOSConfiguration(ctx) + unifiedClusterConfig, err := s.vnetProcess.GetUnifiedClusterConfigProvider().GetUnifiedClusterConfig(ctx) if err != nil { return nil, trace.Wrap(err) } return &diagv1.NetworkStack{ InterfaceName: s.networkStackInfo.InterfaceName, Ipv6Prefix: s.networkStackInfo.Ipv6Prefix, - Ipv4CidrRanges: targetOSConfig.GetIpv4CidrRanges(), - DnsZones: targetOSConfig.GetDnsZones(), + Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, + DnsZones: unifiedClusterConfig.AllDNSZones(), }, nil } diff --git a/lib/vnet/admin_process_darwin.go b/lib/vnet/admin_process_darwin.go index c91bddbdbcb45..9161c45d94d4e 100644 --- a/lib/vnet/admin_process_darwin.go +++ b/lib/vnet/admin_process_darwin.go @@ -75,7 +75,7 @@ func RunDarwinAdminProcess(ctx context.Context, config daemon.Config) error { return trace.Wrap(err, "reporting network stack info to client application") } - osConfigProvider, err := newRemoteOSConfigProvider( + osConfigProvider, err := newOSConfigProvider( clt, tunName, networkStackConfig.ipv6Prefix.String(), diff --git a/lib/vnet/admin_process_windows.go b/lib/vnet/admin_process_windows.go index 2f6532ace2a26..a386ae57d63e9 100644 --- a/lib/vnet/admin_process_windows.go +++ b/lib/vnet/admin_process_windows.go @@ -116,7 +116,7 @@ func runWindowsAdminProcess(ctx context.Context, cfg *windowsAdminProcessConfig) return trace.Wrap(err, "reporting network stack info to client application") } - osConfigProvider, err := newRemoteOSConfigProvider( + osConfigProvider, err := newOSConfigProvider( clt, tunName, networkStackConfig.ipv6Prefix.String(), diff --git a/lib/vnet/client_application_service.go b/lib/vnet/client_application_service.go index 37a3cc86e8c0c..b612cc0891826 100644 --- a/lib/vnet/client_application_service.go +++ b/lib/vnet/client_application_service.go @@ -70,11 +70,11 @@ type clientApplicationService struct { } type clientApplicationServiceConfig struct { - fqdnResolver *fqdnResolver - localOSConfigProvider *LocalOSConfigProvider - clientApplication ClientApplication - homePath string - clock clockwork.Clock + fqdnResolver *fqdnResolver + unifiedClusterConfigProvider *UnifiedClusterConfigProvider + clientApplication ClientApplication + homePath string + clock clockwork.Clock } func newClientApplicationService(cfg *clientApplicationServiceConfig) (*clientApplicationService, error) { @@ -255,12 +255,15 @@ func newAppKey(protoAppKey *vnetv1.AppKey, port uint16) appKey { // DNS nameserver and the IPv4 CIDR ranges that should be routed to the VNet TUN // interface. func (s *clientApplicationService) GetTargetOSConfiguration(ctx context.Context, _ *vnetv1.GetTargetOSConfigurationRequest) (*vnetv1.GetTargetOSConfigurationResponse, error) { - targetConfig, err := s.cfg.localOSConfigProvider.GetTargetOSConfiguration(ctx) + unifiedClusterConfig, err := s.cfg.unifiedClusterConfigProvider.GetUnifiedClusterConfig(ctx) if err != nil { return nil, trace.Wrap(err, "getting target OS configuration") } return &vnetv1.GetTargetOSConfigurationResponse{ - TargetOsConfiguration: targetConfig, + TargetOsConfiguration: &vnetv1.TargetOSConfiguration{ + DnsZones: unifiedClusterConfig.AllDNSZones(), + Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, + }, }, nil } diff --git a/lib/vnet/clusterconfigcache.go b/lib/vnet/clusterconfigcache.go index b0dde70c522bf..70b8403d86ddd 100644 --- a/lib/vnet/clusterconfigcache.go +++ b/lib/vnet/clusterconfigcache.go @@ -32,9 +32,10 @@ import ( ) type ClusterConfig struct { - // DNSZones is the list of DNS zones that are valid for this cluster, this includes ProxyPublicAddr *and* - // any configured custom DNS zones for the cluster. - DNSZones []string + // ProxyPublicAddr is the public address of the proxy, it is always a valid DNS zone for apps. + ProxyPublicAddr string + // CustomDNSZones is the list of custom DNS zones configured for the cluster. + CustomDNSZones []string // IPv4CIDRRange is the CIDR range that IPv4 addresses should be assigned from for apps in this cluster. IPv4CIDRRange string // Expires is the time at which this information should be considered stale and refetched. Stale data may @@ -46,6 +47,10 @@ func (e *ClusterConfig) stale(clock clockwork.Clock) bool { return clock.Now().After(e.Expires) } +func (c *ClusterConfig) appDNSZones() []string { + return append([]string{c.ProxyPublicAddr}, c.CustomDNSZones...) +} + // ClusterConfigCache is a read-through cache for cluster VnetConfigs. Cached entries go stale after 5 // minutes, after which they will be re-fetched on the next read. // @@ -116,7 +121,7 @@ func (c *ClusterConfigCache) getClusterConfigUncached(ctx context.Context, clust } } - dnsZones := []string{proxyPublicAddr} + var customDNSZones []string ipv4CIDRRange := typesvnet.DefaultIPv4CIDRRange vnetConfig, err := clusterClient.CurrentCluster().GetVnetConfig(ctx) @@ -126,14 +131,15 @@ func (c *ClusterConfigCache) getClusterConfigUncached(ctx context.Context, clust return nil, trace.Wrap(err) } else { for _, zone := range vnetConfig.GetSpec().GetCustomDnsZones() { - dnsZones = append(dnsZones, zone.GetSuffix()) + customDNSZones = append(customDNSZones, zone.GetSuffix()) } ipv4CIDRRange = cmp.Or(vnetConfig.GetSpec().GetIpv4CidrRange(), typesvnet.DefaultIPv4CIDRRange) } return &ClusterConfig{ - DNSZones: dnsZones, - IPv4CIDRRange: ipv4CIDRRange, - Expires: c.clock.Now().Add(5 * time.Minute), + ProxyPublicAddr: proxyPublicAddr, + CustomDNSZones: customDNSZones, + IPv4CIDRRange: ipv4CIDRRange, + Expires: c.clock.Now().Add(5 * time.Minute), }, nil } diff --git a/lib/vnet/fqdn_resolver.go b/lib/vnet/fqdn_resolver.go index 38dad60b12419..cce5c910a02a8 100644 --- a/lib/vnet/fqdn_resolver.go +++ b/lib/vnet/fqdn_resolver.go @@ -146,7 +146,7 @@ func (r *fqdnResolver) clusterClientForAppFQDN(ctx context.Context, profileName, log.ErrorContext(ctx, "Failed to get VNet config, apps in this cluster will not be resolved.", "profile", profileName, "leaf_cluster", leafClusterName, "error", err) continue } - for _, zone := range clusterConfig.DNSZones { + for _, zone := range clusterConfig.appDNSZones() { if isDescendantSubdomain(fqdn, zone) { return clusterClient, nil } diff --git a/lib/vnet/local_osconfig_provider.go b/lib/vnet/local_osconfig_provider.go deleted file mode 100644 index 0e54b8d522855..0000000000000 --- a/lib/vnet/local_osconfig_provider.go +++ /dev/null @@ -1,119 +0,0 @@ -// 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 vnet - -import ( - "context" - - "github.com/gravitational/trace" - - "github.com/gravitational/teleport/api/utils" - vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" -) - -// LocalOSConfigProvider fetches target OS configuration parameters. -// Its methods get exposed by [clientApplicationService] so that -// [remoteOSConfigProvider] can be implemented by calling these methods from the -// VNet admin process. -type LocalOSConfigProvider struct { - cfg *LocalOSConfigProviderConfig -} - -// LocalOSConfigProviderConfig holds configuration parameters for -// LocalOSConfigProvider. -type LocalOSConfigProviderConfig struct { - clientApplication ClientApplication - clusterConfigCache *ClusterConfigCache - leafClusterCache *leafClusterCache -} - -// NewLocalOSConfigProvider returns a new LocalOSConfigProvider. -func NewLocalOSConfigProvider(cfg *LocalOSConfigProviderConfig) *LocalOSConfigProvider { - return &LocalOSConfigProvider{ - cfg: cfg, - } -} - -// GetTargetOSConfiguration returns the configuration values that should be -// configured in the OS, including DNS zones that should be handled by the VNet -// DNS nameserver and the IPv4 CIDR ranges that should be routed to the VNet TUN -// interface. This is not all of the OS configuration values, only the ones that -// must be communicated from the client application to the admin process. -func (p *LocalOSConfigProvider) GetTargetOSConfiguration(ctx context.Context) (*vnetv1.TargetOSConfiguration, error) { - profiles, err := p.cfg.clientApplication.ListProfiles() - if err != nil { - return nil, trace.Wrap(err, "listing profiles") - } - var targetOSConfig vnetv1.TargetOSConfiguration - for _, profileName := range profiles { - profileTargetConfig := p.targetOSConfigurationForProfile(ctx, profileName) - targetOSConfig.DnsZones = append(targetOSConfig.DnsZones, profileTargetConfig.DnsZones...) - targetOSConfig.Ipv4CidrRanges = append(targetOSConfig.Ipv4CidrRanges, profileTargetConfig.Ipv4CidrRanges...) - } - targetOSConfig.DnsZones = utils.Deduplicate(targetOSConfig.DnsZones) - targetOSConfig.Ipv4CidrRanges = utils.Deduplicate(targetOSConfig.Ipv4CidrRanges) - return &targetOSConfig, nil -} - -// targetOSConfigurationForProfile does not return errors, it is better to -// configure VNet for any working profiles and log errors for failures. -func (p *LocalOSConfigProvider) targetOSConfigurationForProfile(ctx context.Context, profileName string) *vnetv1.TargetOSConfiguration { - targetOSConfig := &vnetv1.TargetOSConfiguration{} - rootClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, "" /*leafClusterName*/) - if err != nil { - log.WarnContext(ctx, - "Failed to get root cluster client from cache, profile may be expired, not configuring VNet for this cluster", - "profile", profileName, "error", err) - return targetOSConfig - } - rootClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, rootClusterClient) - if err != nil { - log.WarnContext(ctx, - "Failed to load VNet configuration, profile may be expired, not configuring VNet for this cluster", - "profile", profileName, "error", err) - return targetOSConfig - } - targetOSConfig.DnsZones = rootClusterConfig.DNSZones - targetOSConfig.Ipv4CidrRanges = []string{rootClusterConfig.IPv4CIDRRange} - - leafClusterNames, err := p.cfg.leafClusterCache.getLeafClusters(ctx, rootClusterClient) - if err != nil { - log.WarnContext(ctx, - "Failed to list leaf clusters, profile may be expired, not configuring VNet for leaf clusters of this cluster", - "profile", profileName, "error", err) - return targetOSConfig - } - for _, leafClusterName := range leafClusterNames { - leafClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, leafClusterName) - if err != nil { - log.WarnContext(ctx, - "Failed to create leaf cluster client, not configuring VNet for this cluster", - "profile", profileName, "leaf_cluster", leafClusterName, "error", err) - return targetOSConfig - } - leafClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, leafClusterClient) - if err != nil { - log.WarnContext(ctx, - "Failed to load VNet configuration, not configuring VNet for this cluster", - "profile", profileName, "leaf_cluster", leafClusterName, "error", err) - return targetOSConfig - } - targetOSConfig.DnsZones = append(targetOSConfig.DnsZones, leafClusterConfig.DNSZones...) - targetOSConfig.Ipv4CidrRanges = append(targetOSConfig.Ipv4CidrRanges, leafClusterConfig.IPv4CIDRRange) - } - return targetOSConfig -} diff --git a/lib/vnet/osconfig.go b/lib/vnet/osconfig.go index 0edd68ab93bc7..4b0a19eadd033 100644 --- a/lib/vnet/osconfig.go +++ b/lib/vnet/osconfig.go @@ -45,11 +45,11 @@ func configureOS(ctx context.Context, osConfig *osConfig, osConfigState *osConfi } type osConfigurator struct { - remoteOSConfigProvider *remoteOSConfigProvider + remoteOSConfigProvider *osConfigProvider osConfigState osConfigState } -func newOSConfigurator(remoteOSConfigProvider *remoteOSConfigProvider) *osConfigurator { +func newOSConfigurator(remoteOSConfigProvider *osConfigProvider) *osConfigurator { return &osConfigurator{ remoteOSConfigProvider: remoteOSConfigProvider, } diff --git a/lib/vnet/remote_osconfig_provider.go b/lib/vnet/osconfig_provider.go similarity index 83% rename from lib/vnet/remote_osconfig_provider.go rename to lib/vnet/osconfig_provider.go index 8a91db28603d9..3a45e8aa89000 100644 --- a/lib/vnet/remote_osconfig_provider.go +++ b/lib/vnet/osconfig_provider.go @@ -24,9 +24,9 @@ import ( vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" ) -// remoteOSConfigProvider fetches a target OS configuration based on cluster +// osConfigProvider fetches a target OS configuration based on cluster // configuration fetched via the client application process available over gRPC. -type remoteOSConfigProvider struct { +type osConfigProvider struct { clt targetOSConfigGetter tunName string dnsAddr string @@ -38,12 +38,12 @@ type targetOSConfigGetter interface { GetTargetOSConfiguration(context.Context) (*vnetv1.TargetOSConfiguration, error) } -func newRemoteOSConfigProvider(clt targetOSConfigGetter, tunName, ipv6Prefix, dnsAddr string) (*remoteOSConfigProvider, error) { +func newOSConfigProvider(clt targetOSConfigGetter, tunName, ipv6Prefix, dnsAddr string) (*osConfigProvider, error) { tunIPv6, err := tunIPv6ForPrefix(ipv6Prefix) if err != nil { return nil, trace.Wrap(err) } - return &remoteOSConfigProvider{ + return &osConfigProvider{ clt: clt, tunName: tunName, dnsAddr: dnsAddr, @@ -51,7 +51,7 @@ func newRemoteOSConfigProvider(clt targetOSConfigGetter, tunName, ipv6Prefix, dn }, nil } -func (p *remoteOSConfigProvider) targetOSConfig(ctx context.Context) (*osConfig, error) { +func (p *osConfigProvider) targetOSConfig(ctx context.Context) (*osConfig, error) { targetOSConfig, err := p.clt.GetTargetOSConfiguration(ctx) if err != nil { return nil, trace.Wrap(err, "getting target OS configuration from client application") @@ -73,7 +73,7 @@ func (p *remoteOSConfigProvider) targetOSConfig(ctx context.Context) (*osConfig, }, nil } -func (p *remoteOSConfigProvider) setTunIPv4FromCIDR(cidrRange string) error { +func (p *osConfigProvider) setTunIPv4FromCIDR(cidrRange string) error { if p.tunIPv4 != "" { return nil } diff --git a/lib/vnet/remote_osconfig_provider_test.go b/lib/vnet/osconfig_provider_test.go similarity index 93% rename from lib/vnet/remote_osconfig_provider_test.go rename to lib/vnet/osconfig_provider_test.go index aaba5f79a079b..af49b67aaa203 100644 --- a/lib/vnet/remote_osconfig_provider_test.go +++ b/lib/vnet/osconfig_provider_test.go @@ -25,7 +25,7 @@ import ( vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" ) -func TestRemoteOSConfigProvider(t *testing.T) { +func TestOSConfigProvider(t *testing.T) { ctx := context.Background() for _, tc := range []struct { desc string @@ -97,10 +97,10 @@ func TestRemoteOSConfigProvider(t *testing.T) { }, err: tc.getTargetOSConfigErr, } - remoteOSConfigProvider, err := newRemoteOSConfigProvider(targetOSConfigGetter, tc.tunName, tc.ipv6Prefix, tc.dnsAddr) + osConfigProvider, err := newOSConfigProvider(targetOSConfigGetter, tc.tunName, tc.ipv6Prefix, tc.dnsAddr) require.NoError(t, err) - targetOSConfig, err := remoteOSConfigProvider.targetOSConfig(ctx) + targetOSConfig, err := osConfigProvider.targetOSConfig(ctx) if tc.expectErr != nil { require.ErrorIs(t, err, tc.expectErr) return diff --git a/lib/vnet/unified_cluster_config_provider.go b/lib/vnet/unified_cluster_config_provider.go new file mode 100644 index 0000000000000..bb6cdf1f4f470 --- /dev/null +++ b/lib/vnet/unified_cluster_config_provider.go @@ -0,0 +1,152 @@ +// 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 vnet + +import ( + "context" + "slices" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/utils" +) + +// UnifiedClusterConfig is a unified VNet configuration for all clusters +// the user is logged in to. +type UnifiedClusterConfig struct { + // ClusterNames contains the name of each root or leaf cluster the user is + // logged in to. SSH hosts are reachable via VNet SSH at subdomains of + // these cluster names. + ClusterNames []string + // ProxyPublicAddrs contains the proxy public address of root and leaf + // cluster the user is logged in to. These are always valid DNS suffixes for + // TCP apps. + ProxyPublicAddrs []string + // CustomDNSZones is the unified set of custom DNS zones configured in all clusters. + CustomDNSZones []string + // IPv4CidrRanges is the unified set of IPv4 CIDR ranges configured in all + // clusters, VNet will try to route all of these to the TUN interface. + IPv4CidrRanges []string +} + +// AppDNSZones returns the DNS suffixes valid for TCP apps. +func (c *UnifiedClusterConfig) AppDNSZones() []string { + return utils.Deduplicate(slices.Concat(c.CustomDNSZones, c.ProxyPublicAddrs)) +} + +// AllDNSZones return all DNS suffixes VNet handles. +func (c *UnifiedClusterConfig) AllDNSZones() []string { + return utils.Deduplicate(slices.Concat(c.CustomDNSZones, c.ProxyPublicAddrs, c.ClusterNames)) +} + +// UnifiedClusterConfigProvider fetches the [UnifiedClusterConfig]. +type UnifiedClusterConfigProvider struct { + cfg *UnifiedClusterConfigProviderConfig +} + +// UnifiedClusterConfigProviderConfig holds configuration parameters for +// [UnifiedClusterConfigProvider]. +type UnifiedClusterConfigProviderConfig struct { + clientApplication ClientApplication + clusterConfigCache *ClusterConfigCache + leafClusterCache *leafClusterCache +} + +// NewUnifiedClusterConfigProvider returns a new [UnifiedClusterConfigProvider]. +func NewUnifiedClusterConfigProvider(cfg *UnifiedClusterConfigProviderConfig) *UnifiedClusterConfigProvider { + return &UnifiedClusterConfigProvider{ + cfg: cfg, + } +} + +// GetUnifiedClusterConfig returns the unified VNet configuration of all +// clusters the user is logged in to. +func (p *UnifiedClusterConfigProvider) GetUnifiedClusterConfig(ctx context.Context) (*UnifiedClusterConfig, error) { + profiles, err := p.cfg.clientApplication.ListProfiles() + if err != nil { + return nil, trace.Wrap(err, "listing profiles") + } + var unifiedClusterConfig UnifiedClusterConfig + for _, profileName := range profiles { + if err := p.fetchForProfile(ctx, profileName, &unifiedClusterConfig); err != nil { + log.WarnContext(ctx, + "Failed to fetch VNet configuration, profile may be expired, not configuring VNet for this profile", + "profile", profileName, "error", err) + } + } + unifiedClusterConfig.ClusterNames = utils.Deduplicate(unifiedClusterConfig.ClusterNames) + unifiedClusterConfig.ProxyPublicAddrs = utils.Deduplicate(unifiedClusterConfig.ProxyPublicAddrs) + unifiedClusterConfig.CustomDNSZones = utils.Deduplicate(unifiedClusterConfig.CustomDNSZones) + unifiedClusterConfig.IPv4CidrRanges = utils.Deduplicate(unifiedClusterConfig.IPv4CidrRanges) + return &unifiedClusterConfig, nil +} + +func (p *UnifiedClusterConfigProvider) fetchForProfile( + ctx context.Context, + profileName string, + unifiedClusterConfig *UnifiedClusterConfig, +) error { + rootClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, "" /*leafClusterName*/) + if err != nil { + return trace.Wrap(err, "getting root cluster client from cache") + } + rootClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, rootClusterClient) + if err != nil { + return trace.Wrap(err, "fetching root cluster VNet config") + } + unifiedClusterConfig.ClusterNames = append(unifiedClusterConfig.ClusterNames, rootClusterClient.ClusterName()) + unifiedClusterConfig.ProxyPublicAddrs = append(unifiedClusterConfig.ProxyPublicAddrs, rootClusterConfig.ProxyPublicAddr) + unifiedClusterConfig.CustomDNSZones = append(unifiedClusterConfig.CustomDNSZones, rootClusterConfig.CustomDNSZones...) + unifiedClusterConfig.IPv4CidrRanges = append(unifiedClusterConfig.IPv4CidrRanges, rootClusterConfig.IPv4CIDRRange) + + leafClusterNames, err := p.cfg.leafClusterCache.getLeafClusters(ctx, rootClusterClient) + if err != nil { + log.WarnContext(ctx, + "Failed to list leaf clusters, profile may be expired, not configuring VNet for leaf clusters in this profile", + "profile", profileName, "error", err) + return nil + } + for _, leafClusterName := range leafClusterNames { + if err := p.fetchForLeafCluster(ctx, profileName, leafClusterName, unifiedClusterConfig); err != nil { + log.WarnContext(ctx, + "Failed to fetch VNet configuration for leaf cluster, VNet will not be configured for this cluster", + "profile", profileName, "leaf_cluster", leafClusterName, "error", err) + } + } + return nil +} + +func (p *UnifiedClusterConfigProvider) fetchForLeafCluster( + ctx context.Context, + profileName string, + leafClusterName string, + unifiedClusterConfig *UnifiedClusterConfig, +) error { + leafClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, leafClusterName) + if err != nil { + return trace.Wrap(err, "getting leaf cluster client from cache") + } + leafClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, leafClusterClient) + if err != nil { + return trace.Wrap(err, "fetching leaf cluster VNet config from cache") + } + unifiedClusterConfig.ClusterNames = append(unifiedClusterConfig.ClusterNames, leafClusterName) + unifiedClusterConfig.ProxyPublicAddrs = append(unifiedClusterConfig.ProxyPublicAddrs, leafClusterConfig.ProxyPublicAddr) + unifiedClusterConfig.CustomDNSZones = append(unifiedClusterConfig.CustomDNSZones, leafClusterConfig.CustomDNSZones...) + unifiedClusterConfig.IPv4CidrRanges = append(unifiedClusterConfig.IPv4CidrRanges, leafClusterConfig.IPv4CIDRRange) + return nil +} diff --git a/lib/vnet/user_process.go b/lib/vnet/user_process.go index dc84f2b6c2f2c..f0c502f18e822 100644 --- a/lib/vnet/user_process.go +++ b/lib/vnet/user_process.go @@ -98,16 +98,16 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) - osConfigProvider := NewLocalOSConfigProvider(&LocalOSConfigProviderConfig{ + unifiedClusterConfigProvider := NewUnifiedClusterConfigProvider(&UnifiedClusterConfigProviderConfig{ clientApplication: clientApplication, clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{ - clientApplication: clientApplication, - fqdnResolver: fqdnResolver, - localOSConfigProvider: osConfigProvider, - clock: clock, + clientApplication: clientApplication, + fqdnResolver: fqdnResolver, + unifiedClusterConfigProvider: unifiedClusterConfigProvider, + clock: clock, }) if err != nil { return nil, trace.Wrap(err) @@ -123,11 +123,11 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* }) userProcess := &UserProcess{ - clientApplication: clientApplication, - osConfigProvider: osConfigProvider, - clientApplicationService: clientApplicationService, - clock: clock, - processManager: processManager, + clientApplication: clientApplication, + unifiedClusterConfigProvider: unifiedClusterConfigProvider, + clientApplicationService: clientApplicationService, + clock: clock, + processManager: processManager, } if err := userProcess.runPlatformUserProcess(processCtx); err != nil { return nil, trace.Wrap(err) @@ -140,9 +140,9 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* type UserProcess struct { clientApplication ClientApplication - clock clockwork.Clock - osConfigProvider *LocalOSConfigProvider - clientApplicationService *clientApplicationService + clock clockwork.Clock + unifiedClusterConfigProvider *UnifiedClusterConfigProvider + clientApplicationService *clientApplicationService processManager *ProcessManager networkStackInfo *vnetv1.NetworkStackInfo @@ -163,6 +163,6 @@ func (p *UserProcess) NetworkStackInfo() *vnetv1.NetworkStackInfo { // GetTargetOSConfiguration returns the LocalOSConfigProvider which clients may // use to report the proxied DNS zones, run diagnostics, etc. The returned // *LocalOSConfigProvider will remain valid even if the UserProcess is closed. -func (p *UserProcess) GetOSConfigProvider() *LocalOSConfigProvider { - return p.osConfigProvider +func (p *UserProcess) GetUnifiedClusterConfigProvider() *UnifiedClusterConfigProvider { + return p.unifiedClusterConfigProvider } diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index b980a5c8bf46f..25950d049b7c6 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -282,11 +282,10 @@ func runTestClientApplicationService(t *testing.T, ctx context.Context, cfg test leafClusterCache: leafClusterCache, }) clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{ - clientApplication: cfg.fakeClientApp, - fqdnResolver: fqdnResolver, - localOSConfigProvider: nil, // OS configuration is not needed in tests. - homePath: cfg.homePath, - clock: cfg.clock, + clientApplication: cfg.fakeClientApp, + fqdnResolver: fqdnResolver, + homePath: cfg.homePath, + clock: cfg.clock, }) require.NoError(t, err) From 440d564445272d7174d39e3428f63fcb45cad14f Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Wed, 18 Jun 2025 15:20:50 -0700 Subject: [PATCH 10/25] [v18][vnet] feat: add SSH configuration diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport #55594 to branch/v18 Co-authored-by: Rafał Cieślak --- api/profile/profile.go | 25 +- api/utils/keypaths/keypaths.go | 6 +- .../go/teleport/lib/vnet/diag/v1/diag.pb.go | 164 ++++++++++-- .../ts/teleport/lib/vnet/diag/v1/diag_pb.ts | 141 +++++++++- lib/teleterm/vnet/service.go | 8 + lib/teleterm/vnet/service_darwin.go | 12 +- lib/vnet/diag/ssh.go | 253 ++++++++++++++++++ lib/vnet/diag/ssh_test.go | 229 ++++++++++++++++ proto/teleport/lib/vnet/diag/v1/diag.proto | 21 ++ web/packages/teleterm/src/helpers.ts | 10 + .../vnet/__snapshots__/diag.test.ts.snap | 16 ++ .../teleterm/src/services/vnet/diag.test.ts | 23 +- .../teleterm/src/services/vnet/diag.ts | 49 +++- .../ui/Vnet/DocumentVnetDiagReport.story.tsx | 49 ++++ .../src/ui/Vnet/DocumentVnetDiagReport.tsx | 97 ++++++- 15 files changed, 1058 insertions(+), 45 deletions(-) create mode 100644 lib/vnet/diag/ssh.go create mode 100644 lib/vnet/diag/ssh_test.go diff --git a/api/profile/profile.go b/api/profile/profile.go index ed9020e433b61..ade1410fec17c 100644 --- a/api/profile/profile.go +++ b/api/profile/profile.go @@ -317,19 +317,26 @@ func FullProfilePath(dir string) string { // defaultProfilePath retrieves the default path of the TSH profile. func defaultProfilePath() string { - // start with UserHomeDir, which is the fastest option as it - // relies only on environment variables and does not perform - // a user lookup (which can be very slow on large AD environments) - home, err := os.UserHomeDir() - if err == nil && home != "" { - return filepath.Join(home, profileDir) + home, ok := UserHomeDir() + if !ok { + home = os.TempDir() } + return filepath.Join(home, profileDir) +} - home = os.TempDir() +// UserHomeDir returns the current user's home directory if it can be found. +func UserHomeDir() (string, bool) { + // Start with os.UserHomeDir, which is the fastest option as it relies only + // on environment variables and does not perform a user lookup (which can be + // very slow on large AD environments). + if home, err := os.UserHomeDir(); err == nil && home != "" { + return home, true + } + // Fall back to the user lookup. if u, err := user.Current(); err == nil && u.HomeDir != "" { - home = u.HomeDir + return u.HomeDir, true } - return filepath.Join(home, profileDir) + return "", false } // FromDir reads the user profile from a given directory. If dir is empty, diff --git a/api/utils/keypaths/keypaths.go b/api/utils/keypaths/keypaths.go index 60b2c472f8df5..ff619e49ebd8a 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -82,9 +82,9 @@ const ( // vnetKnownHosts is the file name of the known_hosts file trusted by // third-party SSH clients connecting to VNet SSH. vnetKnownHosts = "vnet_known_hosts" - // vnetSSHConfig is the file name of the generated OpenSSH-compatible config + // VNetSSHConfig is the file name of the generated OpenSSH-compatible config // file to be used by third-party SSH clients connecting to VNet SSH. - vnetSSHConfig = "vnet_ssh_config" + VNetSSHConfig = "vnet_ssh_config" ) // Here's the file layout of all these keypaths. @@ -463,7 +463,7 @@ func VNetKnownHostsPath(baseDir string) string { // VNetSSHConfigPath returns the path to VNet's generated OpenSSH-compatible // config file. func VNetSSHConfigPath(baseDir string) string { - return filepath.Join(baseDir, vnetSSHConfig) + return filepath.Join(baseDir, VNetSSHConfig) } // TrimKeyPathSuffix returns the given path with any key suffix/extension trimmed off. diff --git a/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go b/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go index 16e907e5481b7..1eb4f2d78f753 100644 --- a/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go +++ b/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go @@ -496,6 +496,7 @@ type CheckReport struct { // Types that are valid to be assigned to Report: // // *CheckReport_RouteConflictReport + // *CheckReport_SshConfigurationReport Report isCheckReport_Report `protobuf_oneof:"report"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -554,6 +555,15 @@ func (x *CheckReport) GetRouteConflictReport() *RouteConflictReport { return nil } +func (x *CheckReport) GetSshConfigurationReport() *SSHConfigurationReport { + if x != nil { + if x, ok := x.Report.(*CheckReport_SshConfigurationReport); ok { + return x.SshConfigurationReport + } + } + return nil +} + type isCheckReport_Report interface { isCheckReport_Report() } @@ -564,8 +574,15 @@ type CheckReport_RouteConflictReport struct { RouteConflictReport *RouteConflictReport `protobuf:"bytes,2,opt,name=route_conflict_report,json=routeConflictReport,proto3,oneof"` } +type CheckReport_SshConfigurationReport struct { + // ssh_configuration_report reports the status of the system's SSH configuration. + SshConfigurationReport *SSHConfigurationReport `protobuf:"bytes,3,opt,name=ssh_configuration_report,json=sshConfigurationReport,proto3,oneof"` +} + func (*CheckReport_RouteConflictReport) isCheckReport_Report() {} +func (*CheckReport_SshConfigurationReport) isCheckReport_Report() {} + // CommandAttempt describes the attempt at running a particular command associated with a diagnostic // check. type CommandAttempt struct { @@ -761,6 +778,93 @@ func (x *RouteConflict) GetInterfaceApp() string { return "" } +// SSHConfigurationReport describes the state of the system's SSH configuration. +type SSHConfigurationReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + // user_openssh_config_path is the full path to the user's default OpenSSH + // config file (~/.ssh/config). + UserOpensshConfigPath string `protobuf:"bytes,1,opt,name=user_openssh_config_path,json=userOpensshConfigPath,proto3" json:"user_openssh_config_path,omitempty"` + // vnet_ssh_config_path is the path to VNet's generated OpenSSH-compatible + // config file. + VnetSshConfigPath string `protobuf:"bytes,2,opt,name=vnet_ssh_config_path,json=vnetSshConfigPath,proto3" json:"vnet_ssh_config_path,omitempty"` + // user_openssh_config_includes_vnet_ssh_config is true if the default + // OpenSSH user configuration file includes VNet's SSH config file. + UserOpensshConfigIncludesVnetSshConfig bool `protobuf:"varint,3,opt,name=user_openssh_config_includes_vnet_ssh_config,json=userOpensshConfigIncludesVnetSshConfig,proto3" json:"user_openssh_config_includes_vnet_ssh_config,omitempty"` + // user_openssh_config_exists is true if a file exists at + // user_openssh_config_path (~/.ssh/config). + UserOpensshConfigExists bool `protobuf:"varint,4,opt,name=user_openssh_config_exists,json=userOpensshConfigExists,proto3" json:"user_openssh_config_exists,omitempty"` + // user_openssh_config_contents contains the contents of the file at + // user_openssh_config_path if it exists. + UserOpensshConfigContents string `protobuf:"bytes,5,opt,name=user_openssh_config_contents,json=userOpensshConfigContents,proto3" json:"user_openssh_config_contents,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHConfigurationReport) Reset() { + *x = SSHConfigurationReport{} + mi := &file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHConfigurationReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHConfigurationReport) ProtoMessage() {} + +func (x *SSHConfigurationReport) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes[8] + 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 SSHConfigurationReport.ProtoReflect.Descriptor instead. +func (*SSHConfigurationReport) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_diag_v1_diag_proto_rawDescGZIP(), []int{8} +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigPath() string { + if x != nil { + return x.UserOpensshConfigPath + } + return "" +} + +func (x *SSHConfigurationReport) GetVnetSshConfigPath() string { + if x != nil { + return x.VnetSshConfigPath + } + return "" +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigIncludesVnetSshConfig() bool { + if x != nil { + return x.UserOpensshConfigIncludesVnetSshConfig + } + return false +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigExists() bool { + if x != nil { + return x.UserOpensshConfigExists + } + return false +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigContents() string { + if x != nil { + return x.UserOpensshConfigContents + } + return "" +} + var File_teleport_lib_vnet_diag_v1_diag_proto protoreflect.FileDescriptor const file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc = "" + @@ -785,10 +889,11 @@ const file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc = "" + "\x06status\x18\x01 \x01(\x0e2-.teleport.lib.vnet.diag.v1.CheckAttemptStatusR\x06status\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12I\n" + "\fcheck_report\x18\x03 \x01(\v2&.teleport.lib.vnet.diag.v1.CheckReportR\vcheckReport\x12E\n" + - "\bcommands\x18\x04 \x03(\v2).teleport.lib.vnet.diag.v1.CommandAttemptR\bcommands\"\xc3\x01\n" + + "\bcommands\x18\x04 \x03(\v2).teleport.lib.vnet.diag.v1.CommandAttemptR\bcommands\"\xb2\x02\n" + "\vCheckReport\x12D\n" + "\x06status\x18\x01 \x01(\x0e2,.teleport.lib.vnet.diag.v1.CheckReportStatusR\x06status\x12d\n" + - "\x15route_conflict_report\x18\x02 \x01(\v2..teleport.lib.vnet.diag.v1.RouteConflictReportH\x00R\x13routeConflictReportB\b\n" + + "\x15route_conflict_report\x18\x02 \x01(\v2..teleport.lib.vnet.diag.v1.RouteConflictReportH\x00R\x13routeConflictReport\x12m\n" + + "\x18ssh_configuration_report\x18\x03 \x01(\v21.teleport.lib.vnet.diag.v1.SSHConfigurationReportH\x00R\x16sshConfigurationReportB\b\n" + "\x06report\"\xa1\x01\n" + "\x0eCommandAttempt\x12G\n" + "\x06status\x18\x01 \x01(\x0e2/.teleport.lib.vnet.diag.v1.CommandAttemptStatusR\x06status\x12\x14\n" + @@ -801,7 +906,13 @@ const file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc = "" + "\x04dest\x18\x01 \x01(\tR\x04dest\x12\x1b\n" + "\tvnet_dest\x18\x02 \x01(\tR\bvnetDest\x12%\n" + "\x0einterface_name\x18\x03 \x01(\tR\rinterfaceName\x12#\n" + - "\rinterface_app\x18\x04 \x01(\tR\finterfaceApp*w\n" + + "\rinterface_app\x18\x04 \x01(\tR\finterfaceApp\"\xde\x02\n" + + "\x16SSHConfigurationReport\x127\n" + + "\x18user_openssh_config_path\x18\x01 \x01(\tR\x15userOpensshConfigPath\x12/\n" + + "\x14vnet_ssh_config_path\x18\x02 \x01(\tR\x11vnetSshConfigPath\x12\\\n" + + ",user_openssh_config_includes_vnet_ssh_config\x18\x03 \x01(\bR&userOpensshConfigIncludesVnetSshConfig\x12;\n" + + "\x1auser_openssh_config_exists\x18\x04 \x01(\bR\x17userOpensshConfigExists\x12?\n" + + "\x1cuser_openssh_config_contents\x18\x05 \x01(\tR\x19userOpensshConfigContents*w\n" + "\x12CheckAttemptStatus\x12$\n" + " CHECK_ATTEMPT_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17CHECK_ATTEMPT_STATUS_OK\x10\x01\x12\x1e\n" + @@ -828,23 +939,24 @@ func file_teleport_lib_vnet_diag_v1_diag_proto_rawDescGZIP() []byte { } var file_teleport_lib_vnet_diag_v1_diag_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_teleport_lib_vnet_diag_v1_diag_proto_goTypes = []any{ - (CheckAttemptStatus)(0), // 0: teleport.lib.vnet.diag.v1.CheckAttemptStatus - (CheckReportStatus)(0), // 1: teleport.lib.vnet.diag.v1.CheckReportStatus - (CommandAttemptStatus)(0), // 2: teleport.lib.vnet.diag.v1.CommandAttemptStatus - (*Report)(nil), // 3: teleport.lib.vnet.diag.v1.Report - (*NetworkStackAttempt)(nil), // 4: teleport.lib.vnet.diag.v1.NetworkStackAttempt - (*NetworkStack)(nil), // 5: teleport.lib.vnet.diag.v1.NetworkStack - (*CheckAttempt)(nil), // 6: teleport.lib.vnet.diag.v1.CheckAttempt - (*CheckReport)(nil), // 7: teleport.lib.vnet.diag.v1.CheckReport - (*CommandAttempt)(nil), // 8: teleport.lib.vnet.diag.v1.CommandAttempt - (*RouteConflictReport)(nil), // 9: teleport.lib.vnet.diag.v1.RouteConflictReport - (*RouteConflict)(nil), // 10: teleport.lib.vnet.diag.v1.RouteConflict - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (CheckAttemptStatus)(0), // 0: teleport.lib.vnet.diag.v1.CheckAttemptStatus + (CheckReportStatus)(0), // 1: teleport.lib.vnet.diag.v1.CheckReportStatus + (CommandAttemptStatus)(0), // 2: teleport.lib.vnet.diag.v1.CommandAttemptStatus + (*Report)(nil), // 3: teleport.lib.vnet.diag.v1.Report + (*NetworkStackAttempt)(nil), // 4: teleport.lib.vnet.diag.v1.NetworkStackAttempt + (*NetworkStack)(nil), // 5: teleport.lib.vnet.diag.v1.NetworkStack + (*CheckAttempt)(nil), // 6: teleport.lib.vnet.diag.v1.CheckAttempt + (*CheckReport)(nil), // 7: teleport.lib.vnet.diag.v1.CheckReport + (*CommandAttempt)(nil), // 8: teleport.lib.vnet.diag.v1.CommandAttempt + (*RouteConflictReport)(nil), // 9: teleport.lib.vnet.diag.v1.RouteConflictReport + (*RouteConflict)(nil), // 10: teleport.lib.vnet.diag.v1.RouteConflict + (*SSHConfigurationReport)(nil), // 11: teleport.lib.vnet.diag.v1.SSHConfigurationReport + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp } var file_teleport_lib_vnet_diag_v1_diag_proto_depIdxs = []int32{ - 11, // 0: teleport.lib.vnet.diag.v1.Report.created_at:type_name -> google.protobuf.Timestamp + 12, // 0: teleport.lib.vnet.diag.v1.Report.created_at:type_name -> google.protobuf.Timestamp 4, // 1: teleport.lib.vnet.diag.v1.Report.network_stack_attempt:type_name -> teleport.lib.vnet.diag.v1.NetworkStackAttempt 6, // 2: teleport.lib.vnet.diag.v1.Report.checks:type_name -> teleport.lib.vnet.diag.v1.CheckAttempt 0, // 3: teleport.lib.vnet.diag.v1.NetworkStackAttempt.status:type_name -> teleport.lib.vnet.diag.v1.CheckAttemptStatus @@ -854,13 +966,14 @@ var file_teleport_lib_vnet_diag_v1_diag_proto_depIdxs = []int32{ 8, // 7: teleport.lib.vnet.diag.v1.CheckAttempt.commands:type_name -> teleport.lib.vnet.diag.v1.CommandAttempt 1, // 8: teleport.lib.vnet.diag.v1.CheckReport.status:type_name -> teleport.lib.vnet.diag.v1.CheckReportStatus 9, // 9: teleport.lib.vnet.diag.v1.CheckReport.route_conflict_report:type_name -> teleport.lib.vnet.diag.v1.RouteConflictReport - 2, // 10: teleport.lib.vnet.diag.v1.CommandAttempt.status:type_name -> teleport.lib.vnet.diag.v1.CommandAttemptStatus - 10, // 11: teleport.lib.vnet.diag.v1.RouteConflictReport.route_conflicts:type_name -> teleport.lib.vnet.diag.v1.RouteConflict - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 11, // 10: teleport.lib.vnet.diag.v1.CheckReport.ssh_configuration_report:type_name -> teleport.lib.vnet.diag.v1.SSHConfigurationReport + 2, // 11: teleport.lib.vnet.diag.v1.CommandAttempt.status:type_name -> teleport.lib.vnet.diag.v1.CommandAttemptStatus + 10, // 12: teleport.lib.vnet.diag.v1.RouteConflictReport.route_conflicts:type_name -> teleport.lib.vnet.diag.v1.RouteConflict + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_teleport_lib_vnet_diag_v1_diag_proto_init() } @@ -870,6 +983,7 @@ func file_teleport_lib_vnet_diag_v1_diag_proto_init() { } file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes[4].OneofWrappers = []any{ (*CheckReport_RouteConflictReport)(nil), + (*CheckReport_SshConfigurationReport)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -877,7 +991,7 @@ func file_teleport_lib_vnet_diag_v1_diag_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc), len(file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc)), NumEnums: 3, - NumMessages: 8, + NumMessages: 9, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts b/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts index dd193b224d559..e41bee08a843b 100644 --- a/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts +++ b/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts @@ -183,6 +183,14 @@ export interface CheckReport { * @generated from protobuf field: teleport.lib.vnet.diag.v1.RouteConflictReport route_conflict_report = 2; */ routeConflictReport: RouteConflictReport; + } | { + oneofKind: "sshConfigurationReport"; + /** + * ssh_configuration_report reports the status of the system's SSH configuration. + * + * @generated from protobuf field: teleport.lib.vnet.diag.v1.SSHConfigurationReport ssh_configuration_report = 3; + */ + sshConfigurationReport: SSHConfigurationReport; } | { oneofKind: undefined; }; @@ -263,6 +271,48 @@ export interface RouteConflict { */ interfaceApp: string; } +/** + * SSHConfigurationReport describes the state of the system's SSH configuration. + * + * @generated from protobuf message teleport.lib.vnet.diag.v1.SSHConfigurationReport + */ +export interface SSHConfigurationReport { + /** + * user_openssh_config_path is the full path to the user's default OpenSSH + * config file (~/.ssh/config). + * + * @generated from protobuf field: string user_openssh_config_path = 1; + */ + userOpensshConfigPath: string; + /** + * vnet_ssh_config_path is the path to VNet's generated OpenSSH-compatible + * config file. + * + * @generated from protobuf field: string vnet_ssh_config_path = 2; + */ + vnetSshConfigPath: string; + /** + * user_openssh_config_includes_vnet_ssh_config is true if the default + * OpenSSH user configuration file includes VNet's SSH config file. + * + * @generated from protobuf field: bool user_openssh_config_includes_vnet_ssh_config = 3; + */ + userOpensshConfigIncludesVnetSshConfig: boolean; + /** + * user_openssh_config_exists is true if a file exists at + * user_openssh_config_path (~/.ssh/config). + * + * @generated from protobuf field: bool user_openssh_config_exists = 4; + */ + userOpensshConfigExists: boolean; + /** + * user_openssh_config_contents contains the contents of the file at + * user_openssh_config_path if it exists. + * + * @generated from protobuf field: string user_openssh_config_contents = 5; + */ + userOpensshConfigContents: string; +} /** * CheckAttemptStatus describes whether CheckAttempt finished successfully. This is different from * CheckReportStatus, which describes whether a successful attempt at running a check has found any @@ -599,7 +649,8 @@ class CheckReport$Type extends MessageType { constructor() { super("teleport.lib.vnet.diag.v1.CheckReport", [ { no: 1, name: "status", kind: "enum", T: () => ["teleport.lib.vnet.diag.v1.CheckReportStatus", CheckReportStatus, "CHECK_REPORT_STATUS_"] }, - { no: 2, name: "route_conflict_report", kind: "message", oneof: "report", T: () => RouteConflictReport } + { no: 2, name: "route_conflict_report", kind: "message", oneof: "report", T: () => RouteConflictReport }, + { no: 3, name: "ssh_configuration_report", kind: "message", oneof: "report", T: () => SSHConfigurationReport } ]); } create(value?: PartialMessage): CheckReport { @@ -624,6 +675,12 @@ class CheckReport$Type extends MessageType { routeConflictReport: RouteConflictReport.internalBinaryRead(reader, reader.uint32(), options, (message.report as any).routeConflictReport) }; break; + case /* teleport.lib.vnet.diag.v1.SSHConfigurationReport ssh_configuration_report */ 3: + message.report = { + oneofKind: "sshConfigurationReport", + sshConfigurationReport: SSHConfigurationReport.internalBinaryRead(reader, reader.uint32(), options, (message.report as any).sshConfigurationReport) + }; + break; default: let u = options.readUnknownField; if (u === "throw") @@ -642,6 +699,9 @@ class CheckReport$Type extends MessageType { /* teleport.lib.vnet.diag.v1.RouteConflictReport route_conflict_report = 2; */ if (message.report.oneofKind === "routeConflictReport") RouteConflictReport.internalBinaryWrite(message.report.routeConflictReport, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* teleport.lib.vnet.diag.v1.SSHConfigurationReport ssh_configuration_report = 3; */ + if (message.report.oneofKind === "sshConfigurationReport") + SSHConfigurationReport.internalBinaryWrite(message.report.sshConfigurationReport, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -841,3 +901,82 @@ class RouteConflict$Type extends MessageType { * @generated MessageType for protobuf message teleport.lib.vnet.diag.v1.RouteConflict */ export const RouteConflict = new RouteConflict$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class SSHConfigurationReport$Type extends MessageType { + constructor() { + super("teleport.lib.vnet.diag.v1.SSHConfigurationReport", [ + { no: 1, name: "user_openssh_config_path", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "vnet_ssh_config_path", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "user_openssh_config_includes_vnet_ssh_config", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 4, name: "user_openssh_config_exists", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 5, name: "user_openssh_config_contents", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): SSHConfigurationReport { + const message = globalThis.Object.create((this.messagePrototype!)); + message.userOpensshConfigPath = ""; + message.vnetSshConfigPath = ""; + message.userOpensshConfigIncludesVnetSshConfig = false; + message.userOpensshConfigExists = false; + message.userOpensshConfigContents = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SSHConfigurationReport): SSHConfigurationReport { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string user_openssh_config_path */ 1: + message.userOpensshConfigPath = reader.string(); + break; + case /* string vnet_ssh_config_path */ 2: + message.vnetSshConfigPath = reader.string(); + break; + case /* bool user_openssh_config_includes_vnet_ssh_config */ 3: + message.userOpensshConfigIncludesVnetSshConfig = reader.bool(); + break; + case /* bool user_openssh_config_exists */ 4: + message.userOpensshConfigExists = reader.bool(); + break; + case /* string user_openssh_config_contents */ 5: + message.userOpensshConfigContents = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: SSHConfigurationReport, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string user_openssh_config_path = 1; */ + if (message.userOpensshConfigPath !== "") + writer.tag(1, WireType.LengthDelimited).string(message.userOpensshConfigPath); + /* string vnet_ssh_config_path = 2; */ + if (message.vnetSshConfigPath !== "") + writer.tag(2, WireType.LengthDelimited).string(message.vnetSshConfigPath); + /* bool user_openssh_config_includes_vnet_ssh_config = 3; */ + if (message.userOpensshConfigIncludesVnetSshConfig !== false) + writer.tag(3, WireType.Varint).bool(message.userOpensshConfigIncludesVnetSshConfig); + /* bool user_openssh_config_exists = 4; */ + if (message.userOpensshConfigExists !== false) + writer.tag(4, WireType.Varint).bool(message.userOpensshConfigExists); + /* string user_openssh_config_contents = 5; */ + if (message.userOpensshConfigContents !== "") + writer.tag(5, WireType.LengthDelimited).string(message.userOpensshConfigContents); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.lib.vnet.diag.v1.SSHConfigurationReport + */ +export const SSHConfigurationReport = new SSHConfigurationReport$Type(); diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index f54565c2684e1..29341f7153470 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -20,6 +20,7 @@ import ( "context" "crypto/tls" "errors" + "os" "sync" "sync/atomic" "time" @@ -29,6 +30,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/types" prehogv1alpha "github.com/gravitational/teleport/gen/proto/go/prehog/v1alpha" apiteleterm "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1" @@ -90,6 +93,7 @@ type Config struct { // reporting. InstallationID string Clock clockwork.Clock + profilePath string } // CheckAndSetDefaults checks and sets the defaults @@ -110,6 +114,10 @@ func (c *Config) CheckAndSetDefaults() error { c.Clock = clockwork.NewRealClock() } + if c.profilePath == "" { + c.profilePath = profile.FullProfilePath(os.Getenv(types.HomeEnvVar)) + } + return nil } diff --git a/lib/teleterm/vnet/service_darwin.go b/lib/teleterm/vnet/service_darwin.go index 9eaddaf31dba8..362246bf292c9 100644 --- a/lib/teleterm/vnet/service_darwin.go +++ b/lib/teleterm/vnet/service_darwin.go @@ -62,10 +62,20 @@ func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsReq return nil, trace.Wrap(err) } + sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ + ProfilePath: s.cfg.profilePath, + }) + if err != nil { + return nil, trace.Wrap(err) + } + report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ Clock: s.cfg.Clock, NetworkStackAttempt: nsa, - DiagChecks: []diag.DiagCheck{routeConflictDiag}, + DiagChecks: []diag.DiagCheck{ + routeConflictDiag, + sshDiag, + }, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/vnet/diag/ssh.go b/lib/vnet/diag/ssh.go new file mode 100644 index 0000000000000..27be9b1afd741 --- /dev/null +++ b/lib/vnet/diag/ssh.go @@ -0,0 +1,253 @@ +// 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 diag + +import ( + "bufio" + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "unicode/utf8" + + "github.com/dustin/go-humanize" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/utils/keypaths" + diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" +) + +const ( + maxOpenSSHConfigFileSize = 1 * 1024 * 1024 // 1 MiB +) + +// SSHConfig includes everything that [SSHDiag] needs to run. +type SSHConfig struct { + // ProfilePath is the path to the user profile (TELEPORT_HOME) where VNet's + // SSH configuration file is stored. + ProfilePath string +} + +// SSHDiag is a diagnostic check that inspects whether the default user OpenSSH +// config file includes VNet's generated SSH config file. +type SSHDiag struct { + cfg *SSHConfig + userHome string + userOpenSSHConfigPath string + vnetSSHConfigPath string + isWindows bool +} + +// NewSSHDiag returns a new [SSHDiag]. +func NewSSHDiag(cfg *SSHConfig) (*SSHDiag, error) { + userHome, ok := profile.UserHomeDir() + if !ok { + return nil, trace.Errorf("unable to find user's home directory") + } + userOpenSSHConfigPath := filepath.Join(userHome, ".ssh", "config") + vnetSSHConfigPath := filepath.Join(cfg.ProfilePath, keypaths.VNetSSHConfig) + return &SSHDiag{ + cfg: cfg, + userHome: userHome, + userOpenSSHConfigPath: userOpenSSHConfigPath, + vnetSSHConfigPath: vnetSSHConfigPath, + isWindows: runtime.GOOS == "windows", + }, nil +} + +// Commands returns no commands for this diagnostic. +func (d *SSHDiag) Commands(ctx context.Context) []*exec.Cmd { + return nil +} + +// EmptyCheckReport returns an empty SSH configuration report. +func (d *SSHDiag) EmptyCheckReport() *diagv1.CheckReport { + return &diagv1.CheckReport{Report: &diagv1.CheckReport_SshConfigurationReport{}} +} + +// Run runs the diagnostic. +func (d *SSHDiag) Run(ctx context.Context) (*diagv1.CheckReport, error) { + report, err := d.run(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + return &diagv1.CheckReport{ + // This intentionally always returns CHECK_REPORT_STATUS_OK even if + // ~/.ssh/config does not include the VNet generated SSH config. It is + // not mandatory to configure SSH and returning an error status would + // cause an alert and notification in Connect. + Status: diagv1.CheckReportStatus_CHECK_REPORT_STATUS_OK, + Report: &diagv1.CheckReport_SshConfigurationReport{ + SshConfigurationReport: report, + }, + }, nil +} + +func (d *SSHDiag) run(ctx context.Context) (*diagv1.SSHConfigurationReport, error) { + _, err := os.Stat(d.userOpenSSHConfigPath) + userOpenSSHConfigExists := err == nil + if !userOpenSSHConfigExists { + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.userOpenSSHConfigPath, + VnetSshConfigPath: d.vnetSSHConfigPath, + }, nil + } + + userOpenSSHConfigFile, err := os.Open(d.userOpenSSHConfigPath) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "opening %s for reading", d.userOpenSSHConfigPath) + } + defer userOpenSSHConfigFile.Close() + + userOpenSSHConfigContents, err := io.ReadAll(io.LimitReader(userOpenSSHConfigFile, maxOpenSSHConfigFileSize)) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "reading %s", d.userOpenSSHConfigPath) + } + if len(userOpenSSHConfigContents) == maxOpenSSHConfigFileSize { + return nil, trace.Errorf("%s is too large to (max size %s)", + d.userOpenSSHConfigPath, humanize.Bytes(maxOpenSSHConfigFileSize)) + } + if !utf8.Valid(userOpenSSHConfigContents) { + return nil, trace.Errorf("%s is not valid UTF-8", d.userOpenSSHConfigPath) + } + + included, err := d.openSSHConfigIncludesVNetSSHConfig(bytes.NewReader(userOpenSSHConfigContents)) + if err != nil { + return nil, trace.Wrap(err, "checking if the default user OpenSSH config includes VNet's SSH configuration") + } + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.userOpenSSHConfigPath, + VnetSshConfigPath: d.vnetSSHConfigPath, + UserOpensshConfigIncludesVnetSshConfig: included, + UserOpensshConfigExists: true, + UserOpensshConfigContents: string(userOpenSSHConfigContents), + }, nil +} + +func (d *SSHDiag) openSSHConfigIncludesVNetSSHConfig(r io.Reader) (bool, error) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + if d.openSSHConfigLineIncludesPath(scanner.Text(), d.vnetSSHConfigPath) { + return true, nil + } + } + return false, trace.Wrap(scanner.Err()) +} + +// openSSHConfigLineIncludesPath returns true if the given line of an OpenSSH +// configuration file is an include statement for the given path. +func (d *SSHDiag) openSSHConfigLineIncludesPath(line, wantPath string) bool { + wantPath = d.normalizePath(wantPath) + line = strings.TrimSpace(line) + + // Only consider lines that begin with "include" (case-insensitive). + i := strings.IndexFunc(line, isSpace) + if i == -1 { + return false + } + if strings.ToLower(line[:i]) != "include" { + return false + } + // Consider the rest of the line after "include". + line = line[i+1:] + + // Include lines may specify multiple pathnames and each pathname may + // contain glob wildcards, tokens, environment variables, ~, escaped + // characters and may or may not be quoted. This function does not support + // glob wildcards, tokens, or environment variables. It splits each argument + // at unescaped and unquoted whitespace and if the argument matches wantPath + // returns true. It does support ~ as an alias for the user's home + // directory. + var ( + // b is a running buffer holding the current argument as parsed up to + // the current point. + b strings.Builder + // quote holds the opening quote character if one has been found. + quote = byte(0) + ) +loop: + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case c == '\\' && i < len(line)-1 && canBeEscaped(line[i+1]): + // Skip the escape char and write the next char literally. + i++ + b.WriteByte(line[i]) + case quote == 0 && (c == '"' || c == '\''): + // Start of quote + quote = c + case quote != 0 && c == quote: + // End of quote + quote = 0 + case b.Len() == 0 && c == '~': + // Support ~ as an alias for the user's home directory. + b.WriteString(d.userHome) + case quote == 0 && c == '#': + // Found an unquoted comment in the middle of the line, ignore the rest. + break loop + case quote == 0 && isSpace(rune(c)): + // Reached the end of this argument, check if it matches wantPath. + if d.normalizePath(b.String()) == wantPath { + return true + } + b.Reset() + default: + // By default just append the current character to the current + // argument. + b.WriteByte(c) + } + } + if quote != 0 { + // Unmatched quote. + return false + } + // Handle an argument that ends at the end of the line. + return d.normalizePath(b.String()) == wantPath +} + +func (d *SSHDiag) normalizePath(path string) string { + if d.isWindows { + // Normalize all paths to use unix-style separators since OpenSSH + // supports / or \\ on Windows. + path = strings.ReplaceAll(path, `\`, `/`) + // Windows paths are case-insensitive. + path = strings.ToLower(path) + } + return filepath.Clean(path) +} + +func isSpace(r rune) bool { + switch r { + case ' ', '\t': + return true + } + return false +} + +func canBeEscaped(c byte) bool { + // https://github.com/openssh/openssh-portable/blob/5f761cdb2331a12318bde24db5ca84ee144a51d1/misc.c#L2089-L2099 + switch c { + case ' ', '\\', '\'', '"': + return true + } + return false +} diff --git a/lib/vnet/diag/ssh_test.go b/lib/vnet/diag/ssh_test.go new file mode 100644 index 0000000000000..2a1549a49c172 --- /dev/null +++ b/lib/vnet/diag/ssh_test.go @@ -0,0 +1,229 @@ +// 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 diag + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keypaths" + diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" +) + +// TestSSHDiag tests the SSH configuration diagnostic, specifically its ability +// to check whether an OpenSSH config file includes the VNet SSH config file. +func TestSSHDiag(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + desc string + profilePath string + userHome string + isWindows bool + input string + expect bool + }{ + { + desc: "empty", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + }, + { + desc: "macos tsh", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include /Users/user/.tsh/vnet_ssh_config`, + expect: true, + }, + { + desc: "macos tsh ~", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include ~/.tsh/vnet_ssh_config`, + expect: true, + }, + { + desc: "macos connect", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "macos connect ~", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include "~/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "macos tsh not match connect", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + }, + { + desc: "macos connect not match tsh", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include /Users/user/.tsh/vnet_ssh_config`, + }, + { + desc: "windows tsh", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh unescaped", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\Users\User\.tsh\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh unix path", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:/Users/User/.tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh ~", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "~\\.tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect unescaped", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\Users\User\AppData\Roaming\Teleport Connect\tsh\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect unix path", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:/Users/User/AppData/Roaming/Teleport\ Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect ~", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "~\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh not match connect", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + }, + { + desc: "windows connect not match tsh", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, + }, + { + desc: "some other file", + profilePath: `/Users/user/.tsh`, + input: `Include /Users/user/.tsh/ssh_config`, + }, + { + desc: "multiple includes", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: ` +Include ~/.ssh/include/* +Include /Users/user/ssh_config +Include /Users/user/.tsh/vnet_ssh_config +`, + expect: true, + }, + { + desc: "commented", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include #/Users/user/.tsh/vnet_ssh_config`, + }, + { + desc: "single quotes", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include '/Users/user/.tsh/vnet_ssh_config'`, + expect: true, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + diag, err := NewSSHDiag(&SSHConfig{ + ProfilePath: tc.profilePath, + }) + require.NoError(t, err) + userOpenSSHConfigPath := filepath.Join(t.TempDir(), "test_ssh_config") + + // Override isWindows and paths for the purpose of the test. + diag.isWindows = tc.isWindows + diag.userHome = tc.userHome + diag.userOpenSSHConfigPath = userOpenSSHConfigPath + + if len(tc.input) > 0 { + require.NoError(t, os.WriteFile(userOpenSSHConfigPath, []byte(tc.input), 0o600)) + } + + expectReport := &diagv1.CheckReport{ + Status: diagv1.CheckReportStatus_CHECK_REPORT_STATUS_OK, + Report: &diagv1.CheckReport_SshConfigurationReport{ + SshConfigurationReport: &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: userOpenSSHConfigPath, + VnetSshConfigPath: keypaths.VNetSSHConfigPath(tc.profilePath), + UserOpensshConfigIncludesVnetSshConfig: tc.expect, + UserOpensshConfigExists: len(tc.input) > 0, + UserOpensshConfigContents: tc.input, + }, + }, + } + + report, err := diag.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, expectReport, report) + }) + } +} diff --git a/proto/teleport/lib/vnet/diag/v1/diag.proto b/proto/teleport/lib/vnet/diag/v1/diag.proto index 6d96cf1958a78..ce2407ded2e03 100644 --- a/proto/teleport/lib/vnet/diag/v1/diag.proto +++ b/proto/teleport/lib/vnet/diag/v1/diag.proto @@ -107,6 +107,8 @@ message CheckReport { // route_conflict reports whether there are routes that might conflict with routes set up by // VNet. RouteConflictReport route_conflict_report = 2; + // ssh_configuration_report reports the status of the system's SSH configuration. + SSHConfigurationReport ssh_configuration_report = 3; } } @@ -158,3 +160,22 @@ message RouteConflict { // it's likely to be empty. string interface_app = 4; } + +// SSHConfigurationReport describes the state of the system's SSH configuration. +message SSHConfigurationReport { + // user_openssh_config_path is the full path to the user's default OpenSSH + // config file (~/.ssh/config). + string user_openssh_config_path = 1; + // vnet_ssh_config_path is the path to VNet's generated OpenSSH-compatible + // config file. + string vnet_ssh_config_path = 2; + // user_openssh_config_includes_vnet_ssh_config is true if the default + // OpenSSH user configuration file includes VNet's SSH config file. + bool user_openssh_config_includes_vnet_ssh_config = 3; + // user_openssh_config_exists is true if a file exists at + // user_openssh_config_path (~/.ssh/config). + bool user_openssh_config_exists = 4; + // user_openssh_config_contents contains the contents of the file at + // user_openssh_config_path if it exists. + string user_openssh_config_contents = 5; +} diff --git a/web/packages/teleterm/src/helpers.ts b/web/packages/teleterm/src/helpers.ts index 207bd43271f28..f21ec32989aad 100644 --- a/web/packages/teleterm/src/helpers.ts +++ b/web/packages/teleterm/src/helpers.ts @@ -26,6 +26,7 @@ import { WindowsDesktop } from 'gen-proto-ts/teleport/lib/teleterm/v1/windows_de import { CheckReport, RouteConflictReport, + SSHConfigurationReport, } from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; import { @@ -194,3 +195,12 @@ export function reportOneOfIsRouteConflictReport( } { return report.oneofKind === 'routeConflictReport'; } + +export function reportOneOfIsSSHConfigurationReport( + report: CheckReport['report'] +): report is { + oneofKind: 'sshConfigurationReport'; + sshConfigurationReport: SSHConfigurationReport; +} { + return report.oneofKind === 'sshConfigurationReport'; +} diff --git a/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap b/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap index 77776b66d752a..4d55bb3866f8a 100644 --- a/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap +++ b/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap @@ -33,5 +33,21 @@ default link#25 UCSIg utun4 100.100.100.100 link#25 UHWIi utun4 \`\`\` + +--- +⚠️ VNet SSH is not configured. + + The user's default SSH configuration file does not include VNet's + generated configuration file and connections to VNet SSH hosts will + not work by default. + +| File description | Path | +| ------------------------ | ---- | +| User OpenSSH config file | ~/.ssh/config | +| VNet SSH config file | /Users/user/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config | + +~/.ssh/config does not exist + + " `; diff --git a/web/packages/teleterm/src/services/vnet/diag.test.ts b/web/packages/teleterm/src/services/vnet/diag.test.ts index 5d7d12ad1d3b8..937632b0386ac 100644 --- a/web/packages/teleterm/src/services/vnet/diag.test.ts +++ b/web/packages/teleterm/src/services/vnet/diag.test.ts @@ -29,10 +29,10 @@ import { describe('reportToText', () => { it('converts report correctly', () => { - const checkReport = makeCheckReport({ + const routeConflictReport = makeCheckReport({ status: diag.CheckReportStatus.ISSUES_FOUND, }); - checkReport.report = { + routeConflictReport.report = { oneofKind: 'routeConflictReport', routeConflictReport: { routeConflicts: [ @@ -51,12 +51,29 @@ describe('reportToText', () => { ], }, }; + const sshConfigReport = makeCheckReport({ + status: diag.CheckReportStatus.OK, + }); + sshConfigReport.report = { + oneofKind: 'sshConfigurationReport', + sshConfigurationReport: { + userOpensshConfigPath: '~/.ssh/config', + vnetSshConfigPath: + '/Users/user/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config', + userOpensshConfigIncludesVnetSshConfig: false, + userOpensshConfigExists: false, + userOpensshConfigContents: '', + }, + }; const report = makeReport({ checks: [ makeCheckAttempt({ - checkReport, + checkReport: routeConflictReport, commands: [makeCommandAttempt()], }), + makeCheckAttempt({ + checkReport: sshConfigReport, + }), ], }); diff --git a/web/packages/teleterm/src/services/vnet/diag.ts b/web/packages/teleterm/src/services/vnet/diag.ts index 6313c556c895f..98043f7293ecb 100644 --- a/web/packages/teleterm/src/services/vnet/diag.ts +++ b/web/packages/teleterm/src/services/vnet/diag.ts @@ -20,7 +20,10 @@ import { displayDateTime } from 'design/datetime'; import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb'; import * as diag from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; -import { reportOneOfIsRouteConflictReport } from 'teleterm/helpers'; +import { + reportOneOfIsRouteConflictReport, + reportOneOfIsSSHConfigurationReport, +} from 'teleterm/helpers'; export const hasReportFoundIssues = (report: diag.Report): boolean => report.checks.some( @@ -122,6 +125,10 @@ const reportOneofToDisplayDetails: Record< errorTitle: 'inspect network routes', reportToText: routeConflictReportToText, }, + sshConfigurationReport: { + errorTitle: 'inspect SSH configuration', + reportToText: sshConfigurationReportToText, + }, }; function routeConflictReportToText({ @@ -149,3 +156,43 @@ function routeConflictReportToText({ | ---------------- | ----------------------- | --------- | --------- | ${tableRows}`; } + +function sshConfigurationReportToText({ report }: diag.CheckReport): string { + if (!reportOneOfIsSSHConfigurationReport(report)) { + return null; + } + const { + userOpensshConfigPath, + vnetSshConfigPath, + userOpensshConfigIncludesVnetSshConfig, + userOpensshConfigExists, + userOpensshConfigContents, + } = report.sshConfigurationReport; + + const status = userOpensshConfigIncludesVnetSshConfig + ? '✅ VNet SSH is configured correctly.' + : `⚠️ VNet SSH is not configured. + + The user's default SSH configuration file does not include VNet's + generated configuration file and connections to VNet SSH hosts will + not work by default.`; + + const pathsTable = ` +| File description | Path | +| ------------------------ | ---- | +| User OpenSSH config file | ${userOpensshConfigPath} | +| VNet SSH config file | ${vnetSshConfigPath} |`; + + const currentContents = userOpensshConfigExists + ? `Current contents of ${userOpensshConfigPath}: + +\`\`\` +${userOpensshConfigContents} +\`\`\`` + : `${userOpensshConfigPath} does not exist`; + + return `${status} +${pathsTable} + +${currentContents}`; +} diff --git a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx index 3edf82f6683ff..e84383cdd321f 100644 --- a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx +++ b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx @@ -26,6 +26,7 @@ import { CommandAttemptStatus, RouteConflict, RouteConflictReport, + SSHConfigurationReport, } from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; import { usePromiseRejectedOnUnmount } from 'shared/utils/wait'; @@ -48,6 +49,12 @@ import { ConnectionsContextProvider } from 'teleterm/ui/TopBar/Connections/conne import { DocumentVnetDiagReport as Component } from './DocumentVnetDiagReport'; import { useVnetContext, VnetContextProvider } from './vnetContext'; +const defaultUserSSHConfigContents = `Include "/Users/User/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config" + +Host github.com + IdentityFile ~/.ssh/id_ed25519 +`; + type StoryProps = { asText: boolean; networkStackAttempt: 'ok' | 'error'; @@ -56,6 +63,10 @@ type StoryProps = { routeConflictAttempt: 'ok' | 'issues-found' | 'error'; routeConflicts: RouteConflict[]; routeConflictCommandAttempt: 'ok' | 'error'; + sshConfigAttempt: 'ok' | 'error'; + sshConfigured: boolean; + userOpenSSHConfigExists: boolean; + userOpenSSHConfigContents: string; displayUnsupportedCheckAttempt: boolean; vnetRunning: boolean; reRunDiagnostics: 'success' | 'error' | 'processing'; @@ -91,6 +102,15 @@ const meta: Meta = { control: { type: 'inline-radio' }, options: ['ok', 'error'], }, + sshConfigAttempt: { + control: { type: 'inline-radio' }, + options: ['ok', 'error'], + }, + userOpenSSHConfigExists: {}, + userOpenSSHConfigContents: {}, + sshConfigured: { + control: { type: 'boolean' }, + }, displayUnsupportedCheckAttempt: { description: "Simulate the component receiving a report with a check attempt that's not supported in the current version", @@ -121,6 +141,10 @@ const meta: Meta = { }), ], routeConflictCommandAttempt: 'ok', + sshConfigAttempt: 'ok', + sshConfigured: false, + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: defaultUserSSHConfigContents, displayUnsupportedCheckAttempt: false, vnetRunning: true, reRunDiagnostics: 'success', @@ -208,6 +232,31 @@ export function DocumentVnetDiagReport(props: StoryProps) { } report.checks.push(routeConflictCheckAttempt); + const sshConfigReport: SSHConfigurationReport = { + userOpensshConfigIncludesVnetSshConfig: props.sshConfigured, + userOpensshConfigPath: '/Users/User/.ssh/config', + vnetSshConfigPath: + '/Users/User/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config', + userOpensshConfigExists: props.userOpenSSHConfigExists, + userOpensshConfigContents: props.userOpenSSHConfigContents, + }; + const sshConfigCheckAttempt = makeCheckAttempt({ + status: + props.sshConfigAttempt === 'ok' + ? CheckAttemptStatus.OK + : CheckAttemptStatus.ERROR, + error: + props.sshConfigAttempt === 'error' ? 'something went wrong' : undefined, + checkReport: makeCheckReport({ + status: CheckReportStatus.OK, + report: { + oneofKind: 'sshConfigurationReport', + sshConfigurationReport: sshConfigReport, + }, + }), + }); + report.checks.push(sshConfigCheckAttempt); + if (props.displayUnsupportedCheckAttempt) { report.checks.push({ status: CheckAttemptStatus.OK, diff --git a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx index a74ed7feeb4c4..b90f34f0dfd40 100644 --- a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx +++ b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx @@ -21,7 +21,7 @@ import styled from 'styled-components'; import { Button, Alert as DesignAlert, Flex, H1, Link, Stack } from 'design'; import { AlertProps } from 'design/Alert/Alert'; -import Table, { TextCell } from 'design/DataTable'; +import Table, { Cell, TextCell } from 'design/DataTable'; import { displayDateTime } from 'design/datetime'; import { Copy, @@ -35,10 +35,14 @@ import { HoverTooltip } from 'design/Tooltip'; import { copyToClipboard } from 'design/utils/copyToClipboard'; import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb'; import * as diag from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; +import { TextSelectCopy } from 'shared/components/TextSelectCopy'; import { CanceledError, useAsync } from 'shared/hooks/useAsync'; import { pluralize } from 'shared/utils/text'; -import { reportOneOfIsRouteConflictReport } from 'teleterm/helpers'; +import { + reportOneOfIsRouteConflictReport, + reportOneOfIsSSHConfigurationReport, +} from 'teleterm/helpers'; import { getReportFilename, reportToText } from 'teleterm/services/vnet/diag'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import Document from 'teleterm/ui/Document'; @@ -297,6 +301,10 @@ const reportOneofDisplayDetails: Record< errorTitle: 'inspect network routes', Component: CheckReportRouteConflict, }, + sshConfigurationReport: { + errorTitle: 'inspect SSH configuration', + Component: CheckReportSSHConfiguration, + }, }; /** @@ -367,6 +375,91 @@ function CheckReportRouteConflict({ ); } +function CheckReportSSHConfiguration({ + checkReport: { report }, +}: { + checkReport: diag.CheckReport; +}) { + if (!reportOneOfIsSSHConfigurationReport(report)) { + return null; + } + const { + userOpensshConfigPath, + vnetSshConfigPath, + userOpensshConfigIncludesVnetSshConfig, + userOpensshConfigExists, + userOpensshConfigContents, + } = report.sshConfigurationReport; + const pathsTable = ( + ( + + {row.path} + + ), + }, + ]} + /> + ); + if (userOpensshConfigIncludesVnetSshConfig) { + return ( + <> + + + VNet SSH is configured correctly. + + + The user's default SSH configuration file correctly includes VNet's + generated configuration file. + + + {pathsTable} + + ); + } + return ( + <> + + + VNet SSH is not configured. + + + The user's default SSH configuration file does not include VNet's + generated SSH configuration file. SSH clients will not be able to make + connections to VNet SSH addresses by default. Add the following line + to {userOpensshConfigPath} to configure + OpenSSH-compatible clients for VNet: + + + + {userOpensshConfigExists ? ( +
+ + Current contents of {userOpensshConfigPath} + +
{userOpensshConfigContents}
+
+ ) : null} + + ); +} + const Summary = styled.summary` cursor: pointer; `; From f2c9e0acc2cf39136e15bb886d5c63bcba3fb228 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Thu, 19 Jun 2025 08:49:11 -0700 Subject: [PATCH 11/25] [v18][vnet] feat: show SSH status in VNet slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport #55755 to branch/v18 Co-authored-by: Rafał Cieślak --- .../lib/teleterm/vnet/v1/vnet_service.pb.go | 88 +++++++---- .../teleterm/vnet/v1/vnet_service_grpc.pb.go | 50 ++---- .../vnet/v1/vnet_service_pb.client.ts | 34 ++-- .../vnet/v1/vnet_service_pb.grpc-server.ts | 32 ++-- .../lib/teleterm/vnet/v1/vnet_service_pb.ts | 96 ++++++++---- lib/teleterm/vnet/service.go | 31 ++-- .../lib/teleterm/vnet/v1/vnet_service.proto | 32 ++-- .../src/services/tshd/fixtures/mocks.ts | 7 +- .../ConnectionItem.tsx | 24 +-- .../ConnectionStatusIndicator.tsx | 9 ++ .../src/ui/Vnet/VnetSliderStep.story.tsx | 136 ++++++++++++---- .../teleterm/src/ui/Vnet/VnetSliderStep.tsx | 148 +++++++++++++----- .../teleterm/src/ui/Vnet/integration.test.tsx | 24 +-- .../teleterm/src/ui/Vnet/vnetContext.tsx | 17 +- 14 files changed, 457 insertions(+), 271 deletions(-) diff --git a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go index 39c2f91caecc4..f1103603824cf 100644 --- a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go @@ -248,27 +248,27 @@ func (*StopResponse) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{3} } -// Request for ListDNSZones. -type ListDNSZonesRequest struct { +// Request for GetServiceInfo. +type GetServiceInfoRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListDNSZonesRequest) Reset() { - *x = ListDNSZonesRequest{} +func (x *GetServiceInfoRequest) Reset() { + *x = GetServiceInfoRequest{} mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListDNSZonesRequest) String() string { +func (x *GetServiceInfoRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListDNSZonesRequest) ProtoMessage() {} +func (*GetServiceInfoRequest) ProtoMessage() {} -func (x *ListDNSZonesRequest) ProtoReflect() protoreflect.Message { +func (x *GetServiceInfoRequest) ProtoReflect() protoreflect.Message { mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -280,34 +280,40 @@ func (x *ListDNSZonesRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListDNSZonesRequest.ProtoReflect.Descriptor instead. -func (*ListDNSZonesRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use GetServiceInfoRequest.ProtoReflect.Descriptor instead. +func (*GetServiceInfoRequest) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{4} } -// Response for ListDNSZones. -type ListDNSZonesResponse struct { +// GetServiceInfoResponse contains the status of the running VNet service. +type GetServiceInfoResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // dns_zones is a deduplicated list of DNS zones. - DnsZones []string `protobuf:"bytes,1,rep,name=dns_zones,json=dnsZones,proto3" json:"dns_zones,omitempty"` + // app_dns_zones is a deduplicated list of all DNS zones valid as DNS + // suffixes for connections to TCP apps. + AppDnsZones []string `protobuf:"bytes,1,rep,name=app_dns_zones,json=appDnsZones,proto3" json:"app_dns_zones,omitempty"` + // clusters is a list of cluster names valid as DNS suffixes for SSH hosts. + Clusters []string `protobuf:"bytes,2,rep,name=clusters,proto3" json:"clusters,omitempty"` + // ssh_configured is true if the user's SSH config file includes VNet's + // generated SSH config necessary for SSH access. + SshConfigured bool `protobuf:"varint,3,opt,name=ssh_configured,json=sshConfigured,proto3" json:"ssh_configured,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListDNSZonesResponse) Reset() { - *x = ListDNSZonesResponse{} +func (x *GetServiceInfoResponse) Reset() { + *x = GetServiceInfoResponse{} mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListDNSZonesResponse) String() string { +func (x *GetServiceInfoResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListDNSZonesResponse) ProtoMessage() {} +func (*GetServiceInfoResponse) ProtoMessage() {} -func (x *ListDNSZonesResponse) ProtoReflect() protoreflect.Message { +func (x *GetServiceInfoResponse) ProtoReflect() protoreflect.Message { mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -319,18 +325,32 @@ func (x *ListDNSZonesResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListDNSZonesResponse.ProtoReflect.Descriptor instead. -func (*ListDNSZonesResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use GetServiceInfoResponse.ProtoReflect.Descriptor instead. +func (*GetServiceInfoResponse) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{5} } -func (x *ListDNSZonesResponse) GetDnsZones() []string { +func (x *GetServiceInfoResponse) GetAppDnsZones() []string { if x != nil { - return x.DnsZones + return x.AppDnsZones } return nil } +func (x *GetServiceInfoResponse) GetClusters() []string { + if x != nil { + return x.Clusters + } + return nil +} + +func (x *GetServiceInfoResponse) GetSshConfigured() bool { + if x != nil { + return x.SshConfigured + } + return false +} + // Request for GetBackgroundItemStatus. type GetBackgroundItemStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -503,10 +523,12 @@ const file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = "" + "\fStartRequest\"\x0f\n" + "\rStartResponse\"\r\n" + "\vStopRequest\"\x0e\n" + - "\fStopResponse\"\x15\n" + - "\x13ListDNSZonesRequest\"3\n" + - "\x14ListDNSZonesResponse\x12\x1b\n" + - "\tdns_zones\x18\x01 \x03(\tR\bdnsZones\" \n" + + "\fStopResponse\"\x17\n" + + "\x15GetServiceInfoRequest\"\x7f\n" + + "\x16GetServiceInfoResponse\x12\"\n" + + "\rapp_dns_zones\x18\x01 \x03(\tR\vappDnsZones\x12\x1a\n" + + "\bclusters\x18\x02 \x03(\tR\bclusters\x12%\n" + + "\x0essh_configured\x18\x03 \x01(\bR\rsshConfigured\" \n" + "\x1eGetBackgroundItemStatusRequest\"n\n" + "\x1fGetBackgroundItemStatusResponse\x12K\n" + "\x06status\x18\x01 \x01(\x0e23.teleport.lib.teleterm.vnet.v1.BackgroundItemStatusR\x06status\"\x17\n" + @@ -519,11 +541,11 @@ const file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = "" + "\x1eBACKGROUND_ITEM_STATUS_ENABLED\x10\x02\x12,\n" + "(BACKGROUND_ITEM_STATUS_REQUIRES_APPROVAL\x10\x03\x12$\n" + " BACKGROUND_ITEM_STATUS_NOT_FOUND\x10\x04\x12(\n" + - "$BACKGROUND_ITEM_STATUS_NOT_SUPPORTED\x10\x052\xe5\x04\n" + + "$BACKGROUND_ITEM_STATUS_NOT_SUPPORTED\x10\x052\xeb\x04\n" + "\vVnetService\x12b\n" + "\x05Start\x12+.teleport.lib.teleterm.vnet.v1.StartRequest\x1a,.teleport.lib.teleterm.vnet.v1.StartResponse\x12_\n" + - "\x04Stop\x12*.teleport.lib.teleterm.vnet.v1.StopRequest\x1a+.teleport.lib.teleterm.vnet.v1.StopResponse\x12w\n" + - "\fListDNSZones\x122.teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest\x1a3.teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse\x12\x98\x01\n" + + "\x04Stop\x12*.teleport.lib.teleterm.vnet.v1.StopRequest\x1a+.teleport.lib.teleterm.vnet.v1.StopResponse\x12}\n" + + "\x0eGetServiceInfo\x124.teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest\x1a5.teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse\x12\x98\x01\n" + "\x17GetBackgroundItemStatus\x12=.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest\x1a>.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse\x12}\n" + "\x0eRunDiagnostics\x124.teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest\x1a5.teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponseBUZSgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1;vnetv1b\x06proto3" @@ -547,8 +569,8 @@ var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_goTypes = []any{ (*StartResponse)(nil), // 2: teleport.lib.teleterm.vnet.v1.StartResponse (*StopRequest)(nil), // 3: teleport.lib.teleterm.vnet.v1.StopRequest (*StopResponse)(nil), // 4: teleport.lib.teleterm.vnet.v1.StopResponse - (*ListDNSZonesRequest)(nil), // 5: teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest - (*ListDNSZonesResponse)(nil), // 6: teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + (*GetServiceInfoRequest)(nil), // 5: teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest + (*GetServiceInfoResponse)(nil), // 6: teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse (*GetBackgroundItemStatusRequest)(nil), // 7: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest (*GetBackgroundItemStatusResponse)(nil), // 8: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse (*RunDiagnosticsRequest)(nil), // 9: teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest @@ -560,12 +582,12 @@ var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_depIdxs = []int32{ 11, // 1: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse.report:type_name -> teleport.lib.vnet.diag.v1.Report 1, // 2: teleport.lib.teleterm.vnet.v1.VnetService.Start:input_type -> teleport.lib.teleterm.vnet.v1.StartRequest 3, // 3: teleport.lib.teleterm.vnet.v1.VnetService.Stop:input_type -> teleport.lib.teleterm.vnet.v1.StopRequest - 5, // 4: teleport.lib.teleterm.vnet.v1.VnetService.ListDNSZones:input_type -> teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + 5, // 4: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:input_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest 7, // 5: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:input_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest 9, // 6: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:input_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest 2, // 7: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse 4, // 8: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse - 6, // 9: teleport.lib.teleterm.vnet.v1.VnetService.ListDNSZones:output_type -> teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + 6, // 9: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:output_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse 8, // 10: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:output_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse 10, // 11: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:output_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse 7, // [7:12] is the sub-list for method output_type diff --git a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go index d14f95a833ae4..dc680f3295f6b 100644 --- a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go @@ -37,7 +37,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( VnetService_Start_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Start" VnetService_Stop_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Stop" - VnetService_ListDNSZones_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/ListDNSZones" + VnetService_GetServiceInfo_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetServiceInfo" VnetService_GetBackgroundItemStatus_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetBackgroundItemStatus" VnetService_RunDiagnostics_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/RunDiagnostics" ) @@ -52,16 +52,8 @@ type VnetServiceClient interface { Start(ctx context.Context, in *StartRequest, opts ...grpc.CallOption) (*StartResponse, error) // Stop stops VNet. Stop(ctx context.Context, in *StopRequest, opts ...grpc.CallOption) (*StopResponse, error) - // ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - // includes the proxy service hostnames and custom DNS zones configured in vnet_config. - // - // This is fetched independently of what the Electron app thinks the current state of the cluster - // looks like, since the VNet admin process also fetches this data independently of the Electron - // app. - // - // Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - // be fetched (due to e.g., a network error or an expired cert). - ListDNSZones(ctx context.Context, in *ListDNSZonesRequest, opts ...grpc.CallOption) (*ListDNSZonesResponse, error) + // GetServiceInfo returns info about the running VNet service. + GetServiceInfo(ctx context.Context, in *GetServiceInfoRequest, opts ...grpc.CallOption) (*GetServiceInfoResponse, error) // GetBackgroundItemStatus returns the status of the background item responsible for launching // VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. GetBackgroundItemStatus(ctx context.Context, in *GetBackgroundItemStatusRequest, opts ...grpc.CallOption) (*GetBackgroundItemStatusResponse, error) @@ -98,10 +90,10 @@ func (c *vnetServiceClient) Stop(ctx context.Context, in *StopRequest, opts ...g return out, nil } -func (c *vnetServiceClient) ListDNSZones(ctx context.Context, in *ListDNSZonesRequest, opts ...grpc.CallOption) (*ListDNSZonesResponse, error) { +func (c *vnetServiceClient) GetServiceInfo(ctx context.Context, in *GetServiceInfoRequest, opts ...grpc.CallOption) (*GetServiceInfoResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListDNSZonesResponse) - err := c.cc.Invoke(ctx, VnetService_ListDNSZones_FullMethodName, in, out, cOpts...) + out := new(GetServiceInfoResponse) + err := c.cc.Invoke(ctx, VnetService_GetServiceInfo_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -138,16 +130,8 @@ type VnetServiceServer interface { Start(context.Context, *StartRequest) (*StartResponse, error) // Stop stops VNet. Stop(context.Context, *StopRequest) (*StopResponse, error) - // ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - // includes the proxy service hostnames and custom DNS zones configured in vnet_config. - // - // This is fetched independently of what the Electron app thinks the current state of the cluster - // looks like, since the VNet admin process also fetches this data independently of the Electron - // app. - // - // Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - // be fetched (due to e.g., a network error or an expired cert). - ListDNSZones(context.Context, *ListDNSZonesRequest) (*ListDNSZonesResponse, error) + // GetServiceInfo returns info about the running VNet service. + GetServiceInfo(context.Context, *GetServiceInfoRequest) (*GetServiceInfoResponse, error) // GetBackgroundItemStatus returns the status of the background item responsible for launching // VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. GetBackgroundItemStatus(context.Context, *GetBackgroundItemStatusRequest) (*GetBackgroundItemStatusResponse, error) @@ -170,8 +154,8 @@ func (UnimplementedVnetServiceServer) Start(context.Context, *StartRequest) (*St func (UnimplementedVnetServiceServer) Stop(context.Context, *StopRequest) (*StopResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Stop not implemented") } -func (UnimplementedVnetServiceServer) ListDNSZones(context.Context, *ListDNSZonesRequest) (*ListDNSZonesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListDNSZones not implemented") +func (UnimplementedVnetServiceServer) GetServiceInfo(context.Context, *GetServiceInfoRequest) (*GetServiceInfoResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetServiceInfo not implemented") } func (UnimplementedVnetServiceServer) GetBackgroundItemStatus(context.Context, *GetBackgroundItemStatusRequest) (*GetBackgroundItemStatusResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetBackgroundItemStatus not implemented") @@ -236,20 +220,20 @@ func _VnetService_Stop_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } -func _VnetService_ListDNSZones_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListDNSZonesRequest) +func _VnetService_GetServiceInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetServiceInfoRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(VnetServiceServer).ListDNSZones(ctx, in) + return srv.(VnetServiceServer).GetServiceInfo(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: VnetService_ListDNSZones_FullMethodName, + FullMethod: VnetService_GetServiceInfo_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VnetServiceServer).ListDNSZones(ctx, req.(*ListDNSZonesRequest)) + return srv.(VnetServiceServer).GetServiceInfo(ctx, req.(*GetServiceInfoRequest)) } return interceptor(ctx, in, info, handler) } @@ -306,8 +290,8 @@ var VnetService_ServiceDesc = grpc.ServiceDesc{ Handler: _VnetService_Stop_Handler, }, { - MethodName: "ListDNSZones", - Handler: _VnetService_ListDNSZones_Handler, + MethodName: "GetServiceInfo", + Handler: _VnetService_GetServiceInfo_Handler, }, { MethodName: "GetBackgroundItemStatus", diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts index f0240cdf36c14..fdd21d783c4ff 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts @@ -27,8 +27,8 @@ import type { RunDiagnosticsResponse } from "./vnet_service_pb"; import type { RunDiagnosticsRequest } from "./vnet_service_pb"; import type { GetBackgroundItemStatusResponse } from "./vnet_service_pb"; import type { GetBackgroundItemStatusRequest } from "./vnet_service_pb"; -import type { ListDNSZonesResponse } from "./vnet_service_pb"; -import type { ListDNSZonesRequest } from "./vnet_service_pb"; +import type { GetServiceInfoResponse } from "./vnet_service_pb"; +import type { GetServiceInfoRequest } from "./vnet_service_pb"; import type { StopResponse } from "./vnet_service_pb"; import type { StopRequest } from "./vnet_service_pb"; import { stackIntercept } from "@protobuf-ts/runtime-rpc"; @@ -55,19 +55,11 @@ export interface IVnetServiceClient { */ stop(input: StopRequest, options?: RpcOptions): UnaryCall; /** - * ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - * includes the proxy service hostnames and custom DNS zones configured in vnet_config. + * GetServiceInfo returns info about the running VNet service. * - * This is fetched independently of what the Electron app thinks the current state of the cluster - * looks like, since the VNet admin process also fetches this data independently of the Electron - * app. - * - * Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - * be fetched (due to e.g., a network error or an expired cert). - * - * @generated from protobuf rpc: ListDNSZones(teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest) returns (teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse); + * @generated from protobuf rpc: GetServiceInfo(teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest) returns (teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse); */ - listDNSZones(input: ListDNSZonesRequest, options?: RpcOptions): UnaryCall; + getServiceInfo(input: GetServiceInfoRequest, options?: RpcOptions): UnaryCall; /** * GetBackgroundItemStatus returns the status of the background item responsible for launching * VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. @@ -113,21 +105,13 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { return stackIntercept("unary", this._transport, method, opt, input); } /** - * ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - * includes the proxy service hostnames and custom DNS zones configured in vnet_config. - * - * This is fetched independently of what the Electron app thinks the current state of the cluster - * looks like, since the VNet admin process also fetches this data independently of the Electron - * app. - * - * Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - * be fetched (due to e.g., a network error or an expired cert). + * GetServiceInfo returns info about the running VNet service. * - * @generated from protobuf rpc: ListDNSZones(teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest) returns (teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse); + * @generated from protobuf rpc: GetServiceInfo(teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest) returns (teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse); */ - listDNSZones(input: ListDNSZonesRequest, options?: RpcOptions): UnaryCall { + getServiceInfo(input: GetServiceInfoRequest, options?: RpcOptions): UnaryCall { const method = this.methods[2], opt = this._transport.mergeOptions(options); - return stackIntercept("unary", this._transport, method, opt, input); + return stackIntercept("unary", this._transport, method, opt, input); } /** * GetBackgroundItemStatus returns the status of the background item responsible for launching diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts index ffc239038e5f1..1457c1913f871 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts @@ -24,8 +24,8 @@ import { RunDiagnosticsResponse } from "./vnet_service_pb"; import { RunDiagnosticsRequest } from "./vnet_service_pb"; import { GetBackgroundItemStatusResponse } from "./vnet_service_pb"; import { GetBackgroundItemStatusRequest } from "./vnet_service_pb"; -import { ListDNSZonesResponse } from "./vnet_service_pb"; -import { ListDNSZonesRequest } from "./vnet_service_pb"; +import { GetServiceInfoResponse } from "./vnet_service_pb"; +import { GetServiceInfoRequest } from "./vnet_service_pb"; import { StopResponse } from "./vnet_service_pb"; import { StopRequest } from "./vnet_service_pb"; import { StartResponse } from "./vnet_service_pb"; @@ -50,19 +50,11 @@ export interface IVnetService extends grpc.UntypedServiceImplementation { */ stop: grpc.handleUnaryCall; /** - * ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - * includes the proxy service hostnames and custom DNS zones configured in vnet_config. + * GetServiceInfo returns info about the running VNet service. * - * This is fetched independently of what the Electron app thinks the current state of the cluster - * looks like, since the VNet admin process also fetches this data independently of the Electron - * app. - * - * Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - * be fetched (due to e.g., a network error or an expired cert). - * - * @generated from protobuf rpc: ListDNSZones(teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest) returns (teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse); + * @generated from protobuf rpc: GetServiceInfo(teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest) returns (teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse); */ - listDNSZones: grpc.handleUnaryCall; + getServiceInfo: grpc.handleUnaryCall; /** * GetBackgroundItemStatus returns the status of the background item responsible for launching * VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. @@ -110,15 +102,15 @@ export const vnetServiceDefinition: grpc.ServiceDefinition = { responseSerialize: value => Buffer.from(StopResponse.toBinary(value)), requestSerialize: value => Buffer.from(StopRequest.toBinary(value)) }, - listDNSZones: { - path: "/teleport.lib.teleterm.vnet.v1.VnetService/ListDNSZones", - originalName: "ListDNSZones", + getServiceInfo: { + path: "/teleport.lib.teleterm.vnet.v1.VnetService/GetServiceInfo", + originalName: "GetServiceInfo", requestStream: false, responseStream: false, - responseDeserialize: bytes => ListDNSZonesResponse.fromBinary(bytes), - requestDeserialize: bytes => ListDNSZonesRequest.fromBinary(bytes), - responseSerialize: value => Buffer.from(ListDNSZonesResponse.toBinary(value)), - requestSerialize: value => Buffer.from(ListDNSZonesRequest.toBinary(value)) + responseDeserialize: bytes => GetServiceInfoResponse.fromBinary(bytes), + requestDeserialize: bytes => GetServiceInfoRequest.fromBinary(bytes), + responseSerialize: value => Buffer.from(GetServiceInfoResponse.toBinary(value)), + requestSerialize: value => Buffer.from(GetServiceInfoRequest.toBinary(value)) }, getBackgroundItemStatus: { path: "/teleport.lib.teleterm.vnet.v1.VnetService/GetBackgroundItemStatus", diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts index 08f7483ad36b1..772371fc2f714 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts @@ -60,24 +60,38 @@ export interface StopRequest { export interface StopResponse { } /** - * Request for ListDNSZones. + * Request for GetServiceInfo. * - * @generated from protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest */ -export interface ListDNSZonesRequest { +export interface GetServiceInfoRequest { } /** - * Response for ListDNSZones. + * GetServiceInfoResponse contains the status of the running VNet service. * - * @generated from protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse */ -export interface ListDNSZonesResponse { +export interface GetServiceInfoResponse { /** - * dns_zones is a deduplicated list of DNS zones. + * app_dns_zones is a deduplicated list of all DNS zones valid as DNS + * suffixes for connections to TCP apps. * - * @generated from protobuf field: repeated string dns_zones = 1; + * @generated from protobuf field: repeated string app_dns_zones = 1; */ - dnsZones: string[]; + appDnsZones: string[]; + /** + * clusters is a list of cluster names valid as DNS suffixes for SSH hosts. + * + * @generated from protobuf field: repeated string clusters = 2; + */ + clusters: string[]; + /** + * ssh_configured is true if the user's SSH config file includes VNet's + * generated SSH config necessary for SSH access. + * + * @generated from protobuf field: bool ssh_configured = 3; + */ + sshConfigured: boolean; } /** * Request for GetBackgroundItemStatus. @@ -251,20 +265,20 @@ class StopResponse$Type extends MessageType { */ export const StopResponse = new StopResponse$Type(); // @generated message type with reflection information, may provide speed optimized methods -class ListDNSZonesRequest$Type extends MessageType { +class GetServiceInfoRequest$Type extends MessageType { constructor() { - super("teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest", []); + super("teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest", []); } - create(value?: PartialMessage): ListDNSZonesRequest { + create(value?: PartialMessage): GetServiceInfoRequest { const message = globalThis.Object.create((this.messagePrototype!)); if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListDNSZonesRequest): ListDNSZonesRequest { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetServiceInfoRequest): GetServiceInfoRequest { return target ?? this.create(); } - internalBinaryWrite(message: ListDNSZonesRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + internalBinaryWrite(message: GetServiceInfoRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -272,30 +286,40 @@ class ListDNSZonesRequest$Type extends MessageType { } } /** - * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest */ -export const ListDNSZonesRequest = new ListDNSZonesRequest$Type(); +export const GetServiceInfoRequest = new GetServiceInfoRequest$Type(); // @generated message type with reflection information, may provide speed optimized methods -class ListDNSZonesResponse$Type extends MessageType { +class GetServiceInfoResponse$Type extends MessageType { constructor() { - super("teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse", [ - { no: 1, name: "dns_zones", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + super("teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse", [ + { no: 1, name: "app_dns_zones", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "clusters", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "ssh_configured", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } ]); } - create(value?: PartialMessage): ListDNSZonesResponse { + create(value?: PartialMessage): GetServiceInfoResponse { const message = globalThis.Object.create((this.messagePrototype!)); - message.dnsZones = []; + message.appDnsZones = []; + message.clusters = []; + message.sshConfigured = false; if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListDNSZonesResponse): ListDNSZonesResponse { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetServiceInfoResponse): GetServiceInfoResponse { let message = target ?? this.create(), end = reader.pos + length; while (reader.pos < end) { let [fieldNo, wireType] = reader.tag(); switch (fieldNo) { - case /* repeated string dns_zones */ 1: - message.dnsZones.push(reader.string()); + case /* repeated string app_dns_zones */ 1: + message.appDnsZones.push(reader.string()); + break; + case /* repeated string clusters */ 2: + message.clusters.push(reader.string()); + break; + case /* bool ssh_configured */ 3: + message.sshConfigured = reader.bool(); break; default: let u = options.readUnknownField; @@ -308,10 +332,16 @@ class ListDNSZonesResponse$Type extends MessageType { } return message; } - internalBinaryWrite(message: ListDNSZonesResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* repeated string dns_zones = 1; */ - for (let i = 0; i < message.dnsZones.length; i++) - writer.tag(1, WireType.LengthDelimited).string(message.dnsZones[i]); + internalBinaryWrite(message: GetServiceInfoResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated string app_dns_zones = 1; */ + for (let i = 0; i < message.appDnsZones.length; i++) + writer.tag(1, WireType.LengthDelimited).string(message.appDnsZones[i]); + /* repeated string clusters = 2; */ + for (let i = 0; i < message.clusters.length; i++) + writer.tag(2, WireType.LengthDelimited).string(message.clusters[i]); + /* bool ssh_configured = 3; */ + if (message.sshConfigured !== false) + writer.tag(3, WireType.Varint).bool(message.sshConfigured); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -319,9 +349,9 @@ class ListDNSZonesResponse$Type extends MessageType { } } /** - * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse */ -export const ListDNSZonesResponse = new ListDNSZonesResponse$Type(); +export const GetServiceInfoResponse = new GetServiceInfoResponse$Type(); // @generated message type with reflection information, may provide speed optimized methods class GetBackgroundItemStatusRequest$Type extends MessageType { constructor() { @@ -471,7 +501,7 @@ export const RunDiagnosticsResponse = new RunDiagnosticsResponse$Type(); export const VnetService = new ServiceType("teleport.lib.teleterm.vnet.v1.VnetService", [ { name: "Start", options: {}, I: StartRequest, O: StartResponse }, { name: "Stop", options: {}, I: StopRequest, O: StopResponse }, - { name: "ListDNSZones", options: {}, I: ListDNSZonesRequest, O: ListDNSZonesResponse }, + { name: "GetServiceInfo", options: {}, I: GetServiceInfoRequest, O: GetServiceInfoResponse }, { name: "GetBackgroundItemStatus", options: {}, I: GetBackgroundItemStatusRequest, O: GetBackgroundItemStatusResponse }, { name: "RunDiagnostics", options: {}, I: RunDiagnosticsRequest, O: RunDiagnosticsResponse } ]); diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index 29341f7153470..7b41d7d278005 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -35,6 +35,7 @@ import ( prehogv1alpha "github.com/gravitational/teleport/gen/proto/go/prehog/v1alpha" apiteleterm "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1" + diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/teleterm/api/uri" @@ -43,6 +44,7 @@ import ( "github.com/gravitational/teleport/lib/teleterm/daemon" logutils "github.com/gravitational/teleport/lib/utils/log" "github.com/gravitational/teleport/lib/vnet" + "github.com/gravitational/teleport/lib/vnet/diag" ) var log = logutils.NewPackageLogger(teleport.ComponentKey, "term:vnet") @@ -219,13 +221,8 @@ func (s *Service) Stop(ctx context.Context, req *api.StopRequest) (*api.StopResp return &api.StopResponse{}, nil } -// ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This -// includes the proxy service hostnames and custom DNS zones configured in vnet_config. -// -// This is fetched exactly the same way the VNet process fetches the DNS zones -// but may be slightly out of sync with the OS configuration if the admin -// process hasn't configured a recent change yet. -func (s *Service) ListDNSZones(ctx context.Context, req *api.ListDNSZonesRequest) (*api.ListDNSZonesResponse, error) { +// GetServiceInfo returns info about the running VNet service. +func (s *Service) GetServiceInfo(ctx context.Context, _ *api.GetServiceInfoRequest) (*api.GetServiceInfoResponse, error) { // Acquire the lock just to check the status of the service. We don't want the actual process of // listing DNS zones to block the user from performing other operations. s.mu.Lock() @@ -240,8 +237,24 @@ func (s *Service) ListDNSZones(ctx context.Context, req *api.ListDNSZonesRequest if err != nil { return nil, trace.Wrap(err) } - return &api.ListDNSZonesResponse{ - DnsZones: unifiedClusterConfig.AppDNSZones(), + + sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ + ProfilePath: s.cfg.profilePath, + }) + if err != nil { + return nil, trace.Wrap(err, "building SSH diagnostic") + } + sshReport, err := sshDiag.Run(ctx) + if err != nil { + return nil, trace.Wrap(err, "running SSH diagnostic") + } + sshConfigured := sshReport.Status == diagv1.CheckReportStatus_CHECK_REPORT_STATUS_OK && + sshReport.GetSshConfigurationReport().UserOpensshConfigIncludesVnetSshConfig + + return &api.GetServiceInfoResponse{ + AppDnsZones: unifiedClusterConfig.AppDNSZones(), + Clusters: unifiedClusterConfig.ClusterNames, + SshConfigured: sshConfigured, }, nil } diff --git a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto index 05acea0a5c6ca..d63a140678c62 100644 --- a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto +++ b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto @@ -30,16 +30,8 @@ service VnetService { // Stop stops VNet. rpc Stop(StopRequest) returns (StopResponse); - // ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - // includes the proxy service hostnames and custom DNS zones configured in vnet_config. - // - // This is fetched independently of what the Electron app thinks the current state of the cluster - // looks like, since the VNet admin process also fetches this data independently of the Electron - // app. - // - // Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - // be fetched (due to e.g., a network error or an expired cert). - rpc ListDNSZones(ListDNSZonesRequest) returns (ListDNSZonesResponse); + // GetServiceInfo returns info about the running VNet service. + rpc GetServiceInfo(GetServiceInfoRequest) returns (GetServiceInfoResponse); // GetBackgroundItemStatus returns the status of the background item responsible for launching // VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. @@ -62,13 +54,19 @@ message StopRequest {} // Response for Stop. message StopResponse {} -// Request for ListDNSZones. -message ListDNSZonesRequest {} - -// Response for ListDNSZones. -message ListDNSZonesResponse { - // dns_zones is a deduplicated list of DNS zones. - repeated string dns_zones = 1; +// Request for GetServiceInfo. +message GetServiceInfoRequest {} + +// GetServiceInfoResponse contains the status of the running VNet service. +message GetServiceInfoResponse { + // app_dns_zones is a deduplicated list of all DNS zones valid as DNS + // suffixes for connections to TCP apps. + repeated string app_dns_zones = 1; + // clusters is a list of cluster names valid as DNS suffixes for SSH hosts. + repeated string clusters = 2; + // ssh_configured is true if the user's SSH config file includes VNet's + // generated SSH config necessary for SSH access. + bool ssh_configured = 3; } // Request for GetBackgroundItemStatus. diff --git a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts index a53701d1bf292..3520508111585 100644 --- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -118,7 +118,12 @@ export class MockTshClient implements TshdClient { export class MockVnetClient implements VnetClient { start = () => new MockedUnaryCall({}); stop = () => new MockedUnaryCall({}); - listDNSZones = () => new MockedUnaryCall({ dnsZones: [] }); + getServiceInfo = () => + new MockedUnaryCall({ + appDnsZones: [], + clusters: [], + sshConfigured: false, + }); getBackgroundItemStatus = () => new MockedUnaryCall({ status: 0 }); runDiagnostics() { diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index 99d9a6ec9322e..d5c1953afdad2 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -21,6 +21,7 @@ import styled from 'styled-components'; import { ButtonIcon, Flex, Text } from 'design'; import { Trash, Unlink } from 'design/Icon'; +import { typography, TypographyProps } from 'design/system'; import { useKeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; import { ListItem } from 'teleterm/ui/components/ListItem'; @@ -98,18 +99,9 @@ export function ConnectionItem(props: { line-height: 16px; `} > - props.theme.colors.spotBackground[2]}; - opacity: 0.85; - padding: 1px 2px; - margin-right: 4px; - border-radius: 4px; - `} - > + {getKindName(props.item)} - + ` height: unset; `; +export const ConnectionKindIndicator = styled.span` + font-size: 10px; + background: ${props => props.theme.colors.spotBackground[2]}; + opacity: 0.85; + padding: 1px 2px; + margin-right: 4px; + border-radius: 4px; + ${typography} +`; + function getKindName(connection: ExtendedTrackedConnection): string { switch (connection.kind) { case 'connection.gateway': diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator.tsx index 25eb8297975bc..f55e5831889a1 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator.tsx @@ -109,6 +109,15 @@ const StyledStatus = styled(Box)<{ &:after { content: '⚠'; font-size: 12px; + ${props.$inline && + ` + // This cuts out a little portion of the icon on the left. This is most clearly visible + // on Windows. But at least it better aligns with the other statuses. + // + // TODO(ravicious): Rewrite this to not use weird characters to represent different + // statuses so that all statuses properly align together. + margin: -1px; + `} ${!props.$inline && ` diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx index cab7d7a71e2fd..8ec5a33d8f246 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx @@ -15,7 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { Meta } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; import { useEffect } from 'react'; import { Box } from 'design'; @@ -38,8 +38,10 @@ import { VnetSliderStep as Component } from './VnetSliderStep'; type StoryProps = { startVnet: 'success' | 'error' | 'processing'; autoStart: boolean; - dnsZones: string[]; - listDnsZones: + appDnsZones: string[]; + clusters: string[]; + sshConfigured: boolean; + fetchStatus: | 'success' | 'error' | 'processing' @@ -51,6 +53,20 @@ type StoryProps = { unexpectedShutdown: boolean; }; +const defaultArgs: StoryProps = { + startVnet: 'success', + autoStart: true, + appDnsZones: ['teleport.example.com', 'company.test'], + clusters: ['teleport.example.com'], + sshConfigured: false, + fetchStatus: 'success', + vnetDiag: true, + runDiagnostics: 'success', + diagReport: 'ok', + isWorkspacePresent: true, + unexpectedShutdown: false, +}; + const meta: Meta = { title: 'Teleterm/Vnet/VnetSliderStep', component: VnetSliderStep, @@ -68,10 +84,13 @@ const meta: Meta = { control: { type: 'inline-radio' }, options: ['success', 'error', 'processing'], }, - dnsZones: { + appDnsZones: { + control: { type: 'object' }, + }, + clusters: { control: { type: 'object' }, }, - listDnsZones: { + fetchStatus: { control: { type: 'inline-radio' }, options: [ 'success', @@ -93,21 +112,11 @@ const meta: Meta = { "If there's no workspace, the button to open the diag report is disabled.", }, }, - args: { - startVnet: 'success', - autoStart: true, - dnsZones: ['teleport.example.com', 'company.test'], - listDnsZones: 'success', - vnetDiag: true, - runDiagnostics: 'success', - diagReport: 'ok', - isWorkspacePresent: true, - unexpectedShutdown: false, - }, + render: props => , }; export default meta; -export function VnetSliderStep(props: StoryProps) { +function VnetSliderStep(props: StoryProps) { const appContext = new MockAppContext({ platform: props.vnetDiag ? 'darwin' : 'win32', }); @@ -148,22 +157,30 @@ export function VnetSliderStep(props: StoryProps) { }; } - if (props.listDnsZones === 'processing') { - appContext.vnet.listDNSZones = () => pendingPromise; + if (props.fetchStatus === 'processing') { + appContext.vnet.getServiceInfo = () => pendingPromise; } else { let firstCall = true; - appContext.vnet.listDNSZones = () => { - if (props.listDnsZones === 'processing-with-previous-results') { + appContext.vnet.getServiceInfo = () => { + if (props.fetchStatus === 'processing-with-previous-results') { if (firstCall) { firstCall = false; - return new MockedUnaryCall({ dnsZones: props.dnsZones }); + return new MockedUnaryCall({ + appDnsZones: props.appDnsZones, + clusters: props.clusters, + sshConfigured: props.sshConfigured, + }); } return pendingPromise; } return new MockedUnaryCall( - { dnsZones: props.dnsZones }, - props.listDnsZones === 'error' + { + appDnsZones: props.appDnsZones, + clusters: props.clusters, + sshConfigured: props.sshConfigured, + }, + props.fetchStatus === 'error' ? new Error('something went wrong') : undefined ); @@ -204,8 +221,8 @@ export function VnetSliderStep(props: StoryProps) { > - {props.listDnsZones === 'processing-with-previous-results' && ( - + {props.fetchStatus === 'processing-with-previous-results' && ( + )} { - const { listDNSZones, listDNSZonesAttempt } = useVnetContext(); +const RerequestServiceInfo = () => { + const { getServiceInfo, serviceInfoAttempt } = useVnetContext(); useEffect(() => { - if (listDNSZonesAttempt.status === 'success') { - listDNSZones(); + if (serviceInfoAttempt.status === 'success') { + getServiceInfo(); } - }, [listDNSZonesAttempt, listDNSZones]); + }, [serviceInfoAttempt, getServiceInfo]); return null; }; const noop = () => {}; + +export const CloudCustomer: StoryObj = { + args: { + ...defaultArgs, + appDnsZones: ['example.teleport.sh'], + clusters: ['example.teleport.sh'], + }, +}; + +export const SelfHostedWithDifferentClusterName: StoryObj = { + args: { + ...defaultArgs, + appDnsZones: ['teleport.example.com'], + clusters: ['teleport-example'], + }, +}; + +export const SelfHostedWithEqualNameAndLeaf: StoryObj = { + args: { + ...defaultArgs, + appDnsZones: ['teleport.example.com', 'leaf.example.com'], + clusters: ['teleport.example.com', 'leaf.example.com'], + }, +}; + +export const SelfHostedWithEqualNameAndDifferentLeaf: StoryObj = { + args: { + ...defaultArgs, + appDnsZones: ['teleport.example.com', 'leaf.example.com'], + clusters: ['teleport.example.com', 'teleport-leaf'], + }, +}; + +export const SelfHostedWithEqualNameAndCustomDNSZones: StoryObj = { + args: { + ...defaultArgs, + appDnsZones: ['teleport.example.com', 'company.com', 'apps.company'], + clusters: ['teleport.example.com'], + }, +}; + +export const SelfHostedWithManyLeavesAndZones: StoryObj = { + args: { + ...defaultArgs, + appDnsZones: [ + 'teleport.example.com', + 'leaf.example.com', + 'second-leaf.example.com', + 'company.com', + ], + clusters: [ + 'teleport.example.com', + 'teleport-leaf', + 'second-leaf.example.com', + ], + }, +}; diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx index 89fe7bde6cec1..ffc7bd59e9c92 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx @@ -24,6 +24,7 @@ import { useRefAutoFocus } from 'shared/hooks'; import { useDelayedRepeatedAttempt } from 'shared/hooks/useAsync'; import { mergeRefs } from 'shared/libs/mergeRefs'; +import { ConnectionKindIndicator } from 'teleterm/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem'; import { ConnectionStatusIndicator } from 'teleterm/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator'; import { DiagnosticsAlert } from './DiagnosticsAlert'; @@ -104,18 +105,19 @@ export const VnetSliderStep = (props: StepComponentProps) => { ) : ( - VNet enables any program to connect to TCP apps protected by - Teleport. + VNet enables any program to connect to TCP apps or SSH servers + protected by Teleport. - Start VNet and connect to any TCP app over its public address – - VNet authenticates the connection for you under the hood. + Start VNet and connect to any TCP app or SSH server at its own + DNS address – VNet authenticates the connection for you under + the hood. ))} - {status.value === 'running' && } + {status.value === 'running' && } ( ); /** - * DnsZones displays the list of currently proxied DNS zones, as understood by the VNet admin - * process. The list is cached in the context and updated when the VNet panel gets opened. + * VnetStatus displays the status of the running VNet service. The list is cached in the context and + * updated when the VNet panel gets opened. * * As for 95% of users the list will never change during the lifespan of VNet, the VNet panel always * optimistically displays previously fetched results while fetching new list. */ -const DnsZones = () => { - const { listDNSZones, listDNSZonesAttempt: eagerListDNSZonesAttempt } = +const VnetStatus = () => { + const { getServiceInfo, serviceInfoAttempt: eagerServiceInfoAttempt } = useVnetContext(); - const listDNSZonesAttempt = useDelayedRepeatedAttempt( - eagerListDNSZonesAttempt - ); - const dnsZonesRefreshRequestedRef = useRef(false); + const serviceInfoAttempt = useDelayedRepeatedAttempt(eagerServiceInfoAttempt); + const serviceInfoRefreshRequestedRef = useRef(false); useEffect( function refreshListOnOpen() { - if (!dnsZonesRefreshRequestedRef.current) { - dnsZonesRefreshRequestedRef.current = true; - listDNSZones(); + if (!serviceInfoRefreshRequestedRef.current) { + serviceInfoRefreshRequestedRef.current = true; + getServiceInfo(); } }, - [listDNSZones] + [getServiceInfo] ); - if (listDNSZonesAttempt.status === 'error') { + if (serviceInfoAttempt.status === 'error') { return ( - VNet is working, but Teleport Connect could not fetch DNS zones:{' '} - {listDNSZonesAttempt.statusText} + VNet is running, but Teleport Connect could not fetch its status:{' '} + {serviceInfoAttempt.statusText} Retry @@ -175,36 +175,100 @@ const DnsZones = () => { } if ( - listDNSZonesAttempt.status === '' || - (listDNSZonesAttempt.status === 'processing' && !listDNSZonesAttempt.data) + serviceInfoAttempt.status === '' || + (serviceInfoAttempt.status === 'processing' && !serviceInfoAttempt.data) ) { return ( - Updating the list of DNS zones… + Updating VNet status… + + ); + } + + const statusIndicator = ( + + ); + + const serviceInfo = serviceInfoAttempt.data; + + const sshConfiguredIndicator = serviceInfo.sshConfigured ? null : ( + + + SSH clients are not configured to use VNet (see diag report). + + ); + + if (serviceInfo.appDnsZones.length == 0 && serviceInfo.clusters.length == 0) { + return ( + + {statusIndicator} + No clusters connected yet, VNet is not proxying any connections. + + ); + } + + const appDNSZones = new Set(serviceInfo.appDnsZones); + const sshClusters = new Set(serviceInfo.clusters); + const appAndSshAreEqual = + appDNSZones.size == sshClusters.size && appDNSZones.isSubsetOf(sshClusters); + + if (appAndSshAreEqual) { + return ( + + + {statusIndicator} + Proxying TCP and SSH connections to {[...appDNSZones].join(', ')} + + {sshConfiguredIndicator} ); } - const dnsZones = listDNSZonesAttempt.data; + const both = [...appDNSZones.intersection(sshClusters)].sort(); + const justTCP = [...appDNSZones.difference(sshClusters)].sort(); + const justSSH = [...sshClusters.difference(appDNSZones)].sort(); return ( - - - {dnsZones.length === 0 ? ( - <>No clusters connected yet, VNet is not proxying any connections. - ) : ( - <>Proxying TCP connections to {dnsZones.join(', ')} - )} - + + + {statusIndicator} + + Proxying TCP and SSH connections to: + + {both.length ? ( + + TCP + SSH + {both.join(', ')} + + ) : null} + {justTCP.length ? ( + + TCP + {justTCP.join(', ')} + + ) : null} + {justSSH.length ? ( + + SSH + {justSSH.join(', ')} + + ) : null} + + + + {sshConfiguredIndicator} + ); }; diff --git a/web/packages/teleterm/src/ui/Vnet/integration.test.tsx b/web/packages/teleterm/src/ui/Vnet/integration.test.tsx index 275b7e41c8fdd..1c9cf1e6bb2b6 100644 --- a/web/packages/teleterm/src/ui/Vnet/integration.test.tsx +++ b/web/packages/teleterm/src/ui/Vnet/integration.test.tsx @@ -88,9 +88,11 @@ test.each(tests)( clusters: [rootCluster], }) ); - jest.spyOn(ctx.vnet, 'listDNSZones').mockReturnValue( + jest.spyOn(ctx.vnet, 'getServiceInfo').mockReturnValue( new MockedUnaryCall({ - dnsZones: [proxyHostname(rootCluster.proxyHost)], + appDnsZones: [proxyHostname(rootCluster.proxyHost)], + clusters: [rootCluster.name], + sshConfigured: true, }) ); @@ -120,7 +122,7 @@ test.each(tests)( ).toBeInTheDocument(); await user.click(within(docVnetInfo).getByText('Start VNet')); expect( - await screen.findByText(/Proxying TCP connections/) + await screen.findByText(/Proxying TCP and SSH connections/) ).toBeInTheDocument(); // Verify that a notification is shown and that the address is in the clipboard. @@ -141,7 +143,7 @@ test.each(tests)( await user.click(within(docVnetInfo).getByText('Stop VNet')); await user.click(await within(docVnetInfo).findByText('Start VNet')); expect( - await screen.findByText(/Proxying TCP connections/) + await screen.findByText(/Proxying TCP and SSH connections/) ).toBeInTheDocument(); // Verify that the address was not copied to the clipboard after the second start from the "Start @@ -182,9 +184,11 @@ test.each(tests)( clusters: [rootCluster], }) ); - jest.spyOn(ctx.vnet, 'listDNSZones').mockReturnValue( + jest.spyOn(ctx.vnet, 'getServiceInfo').mockReturnValue( new MockedUnaryCall({ - dnsZones: [proxyHostname(rootCluster.proxyHost)], + appDnsZones: [proxyHostname(rootCluster.proxyHost)], + clusters: [rootCluster.name], + sshConfigured: true, }) ); @@ -209,7 +213,7 @@ test.each(tests)( // Verify that VNet is running and that the public address was copied to the clipboard. expect( - await screen.findByText(/Proxying TCP connections/) + await screen.findByText(/Proxying TCP and SSH connections/) ).toBeInTheDocument(); expect( await screen.findByText( @@ -241,9 +245,11 @@ test('launching VNet for the first time from the connections panel does not open clusters: [rootCluster], }) ); - jest.spyOn(ctx.vnet, 'listDNSZones').mockReturnValue( + jest.spyOn(ctx.vnet, 'getServiceInfo').mockReturnValue( new MockedUnaryCall({ - dnsZones: [proxyHostname(rootCluster.proxyHost)], + appDnsZones: [proxyHostname(rootCluster.proxyHost)], + clusters: [rootCluster.name], + sshConfigured: true, }) ); diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx index 4deba14e0d802..1f20aa48be18b 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -28,7 +28,10 @@ import { useState, } from 'react'; -import { BackgroundItemStatus } from 'gen-proto-ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb'; +import { + BackgroundItemStatus, + GetServiceInfoResponse, +} from 'gen-proto-ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb'; import { Report } from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; import { useStateRef } from 'shared/hooks'; import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync'; @@ -60,8 +63,8 @@ export type VnetContext = { startAttempt: Attempt; stop: () => Promise<[void, Error]>; stopAttempt: Attempt; - listDNSZones: () => Promise<[string[], Error]>; - listDNSZonesAttempt: Attempt; + getServiceInfo: () => Promise<[GetServiceInfoResponse, Error]>; + serviceInfoAttempt: Attempt; runDiagnostics: () => Promise<[Report, Error]>; diagnosticsAttempt: Attempt; /** @@ -234,9 +237,9 @@ export const VnetContextProvider: FC< ]) ); - const [listDNSZonesAttempt, listDNSZones] = useAsync( + const [serviceInfoAttempt, getServiceInfo] = useAsync( useCallback( - () => vnet.listDNSZones({}).then(({ response }) => response.dnsZones), + () => vnet.getServiceInfo({}).then(({ response }) => response), [vnet] ) ); @@ -469,8 +472,8 @@ export const VnetContextProvider: FC< startAttempt, stop, stopAttempt, - listDNSZones, - listDNSZonesAttempt, + getServiceInfo, + serviceInfoAttempt, runDiagnostics, diagnosticsAttempt, getDisabledDiagnosticsReason, From 1669b77d60c144a947c765f7a0db83df55163575 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Fri, 20 Jun 2025 09:07:29 -0700 Subject: [PATCH 12/25] [v18][vnet] feat: support proxy recording mode with VNet SSH Backport #55788 to branch/v18 --- lib/vnet/client_application_service.go | 9 +- lib/vnet/ssh_agent.go | 155 +++++++++++++++++++++++++ lib/vnet/ssh_handler.go | 12 +- lib/vnet/ssh_provider.go | 27 ++++- lib/vnet/tcp_handler_resolver.go | 5 +- lib/vnet/vnet_test.go | 73 ++++++++++-- 6 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 lib/vnet/ssh_agent.go diff --git a/lib/vnet/client_application_service.go b/lib/vnet/client_application_service.go index b612cc0891826..4aeeac71bbd5e 100644 --- a/lib/vnet/client_application_service.go +++ b/lib/vnet/client_application_service.go @@ -353,13 +353,18 @@ func (s *clientApplicationService) SessionSSHConfig(ctx context.Context, req *vn } var trustedCAs [][]byte for _, trustedCert := range keyRing.TrustedCerts { - if trustedCert.ClusterName != targetCluster { + switch trustedCert.ClusterName { + case targetCluster, req.GetRootCluster(): + // Always trust the target cluster and the root cluster in case the + // root proxy will terminate the connection in proxy recording mode. + default: + // Don't trust CAs for other leaf clusters or unknown clusters. continue } for _, authorizedKey := range trustedCert.AuthorizedKeys { trustedCA, _, _, _, err := ssh.ParseAuthorizedKey(authorizedKey) if err != nil { - return nil, trace.Wrap(err, "parsing CA cert") + return nil, trace.Wrap(err, "parsing CA public key") } trustedCAs = append(trustedCAs, trustedCA.Marshal()) } diff --git a/lib/vnet/ssh_agent.go b/lib/vnet/ssh_agent.go new file mode 100644 index 0000000000000..314db3373938e --- /dev/null +++ b/lib/vnet/ssh_agent.go @@ -0,0 +1,155 @@ +// 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 vnet + +import ( + "context" + "crypto/rand" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + + "github.com/gravitational/teleport/api/utils/sshutils" +) + +// sshAgent implements [agent.ExtendedAgent]. The sole purpose is to forward +// the user's Teleport SSH key to the proxy in case the cluster is in proxy +// recording mode. In this case there will be an SSH connection between VNet +// and the root cluster proxy terminated with the SSH key in the +// [ssh.ClientConfig], and then the key forwarded via this agent will be used +// to terminate the final SSH connection to the target node. +// +// It is not safe for concurrent use, setSessionKey must only be called before +// the agent will actually be used. +type sshAgent struct { + signer ssh.Signer +} + +func newSSHAgent() *sshAgent { + return &sshAgent{} +} + +// setSessionKey must be called at most once, before the agent will be used. +// It's not possible to initialize sshAgent with the SSH signer because the +// agent must be passed to [proxy.Client.DialHost] before the session SSH +// signer has been created. +func (a *sshAgent) setSessionKey(signer ssh.Signer) error { + if a.signer != nil { + return trace.Errorf("sshAgent.setSessionKey must be called at most once (this is a bug)") + } + a.signer = signer + return nil +} + +// List implements [agent.ExtendedAgent.List], it returns a single key if it +// has been set by setSessionKey. +func (a *sshAgent) List() ([]*agent.Key, error) { + if a.signer == nil { + return nil, nil + } + pub := a.signer.PublicKey() + return []*agent.Key{{ + Format: pub.Type(), + Blob: pub.Marshal(), + }}, nil +} + +// List implements [agent.ExtendedAgent.Signers], it returns a single key if it +// has been set by setSessionKey. +func (a *sshAgent) Signers() ([]ssh.Signer, error) { + if a.signer == nil { + return nil, nil + } + return []ssh.Signer{a.signer}, nil +} + +// SignWithFlags implements [agent.ExtendedAgent.Sign], it returns an SSH +// signature with a.signer if it has been set and matches the requested key. +func (a *sshAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return a.SignWithFlags(key, data, 0) +} + +// SignWithFlags implements [agent.ExtendedAgent.SignWithFlags], it returns an +// SSH signature with a.signer if it has been set and matches the requested +// key. +func (a *sshAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + if a.signer == nil { + return nil, trace.Errorf("VNet SSH agent has no signer") + } + if !sshutils.KeysEqual(a.signer.PublicKey(), key) { + return nil, trace.BadParameter("requested key does not equal VNet SSH agent key") + } + var algo string + switch flags { + case 0: + case agent.SignatureFlagRsaSha256: + algo = ssh.KeyAlgoRSASHA256 + case agent.SignatureFlagRsaSha512: + algo = ssh.KeyAlgoRSASHA512 + default: + return nil, trace.Errorf("unsupported signature flag %v", flags) + } + log.DebugContext(context.Background(), "VNet SSH agent signature requested", + "key_type", a.signer.PublicKey().Type(), "algo", algo) + if algo == "" { + sig, err := a.signer.Sign(rand.Reader, data) + return sig, trace.Wrap(err) + } + algorithmSigner, ok := a.signer.(ssh.AlgorithmSigner) + if !ok { + return nil, trace.Errorf("VNet SSH agent signer does not implement ssh.AlgorithmSigner") + } + sig, err := algorithmSigner.SignWithAlgorithm(rand.Reader, data, algo) + return sig, trace.Wrap(err, "signing with VNet SSH agent signer") +} + +// Add implements [agent.ExtendedAgent.Add]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Add(key agent.AddedKey) error { + return trace.NotImplemented("sshAgent.Add is not implemented") +} + +// Remove implements [agent.ExtendedAgent.Remove]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Remove(key ssh.PublicKey) error { + return trace.NotImplemented("sshAgent.Remove is not implemented") +} + +// RemoveAll implements [agent.ExtendedAgent.RemoveAll]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) RemoveAll() error { + return trace.NotImplemented("sshAgent.RemoveAll is not implemented") +} + +// Lock implements [agent.ExtendedAgent.Lock]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Lock(passphrase []byte) error { + return trace.NotImplemented("sshAgent.Lock is not implemented") +} + +// Unlock implements [agent.ExtendedAgent.Unlock]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Unlock(passphrase []byte) error { + return trace.NotImplemented("sshAgent.Unlock is not implemented") +} + +// Extension implements [agent.ExtendedAgent.Extension]. It is irrelevant for +// this implementation and always returns an error, it is not called. +func (a *sshAgent) Extension(extensionType string, contents []byte) ([]byte, error) { + return nil, trace.NotImplemented("sshAgent.Extension is not implemented") +} diff --git a/lib/vnet/ssh_handler.go b/lib/vnet/ssh_handler.go index 1ca080d294ee9..89efeb8d35652 100644 --- a/lib/vnet/ssh_handler.go +++ b/lib/vnet/ssh_handler.go @@ -54,12 +54,13 @@ func (h *sshHandler) handleTCPConnector(ctx context.Context, localPort uint16, c if localPort != 22 { return trace.BadParameter("SSH is only handled on port 22") } - targetConn, err := h.cfg.sshProvider.dial(ctx, h.cfg.target) + agent := newSSHAgent() + targetConn, err := h.cfg.sshProvider.dial(ctx, h.cfg.target, agent) if err != nil { return trace.Wrap(err) } defer targetConn.Close() - return trace.Wrap(h.handleTCPConnectorWithTargetConn(ctx, connector, targetConn)) + return trace.Wrap(h.handleTCPConnectorWithTargetConn(ctx, connector, targetConn, agent)) } // handleTCPConnectorWithTargetTCPConn handles an incoming TCP connection from @@ -68,6 +69,7 @@ func (h *sshHandler) handleTCPConnectorWithTargetConn( ctx context.Context, connector func() (net.Conn, error), targetConn net.Conn, + agent *sshAgent, ) error { target := h.cfg.target hostCert, err := newHostCert(target.fqdn, h.cfg.sshProvider.hostCASigner) @@ -107,7 +109,7 @@ func (h *sshHandler) handleTCPConnectorWithTargetConn( return nil, clientConnErr } initiatedSSHConn = true - clientConn, clientConnErr = h.initiateSSHConn(ctx, targetConn, conn.User()) + clientConn, clientConnErr = h.initiateSSHConn(ctx, targetConn, conn.User(), agent) if clientConnErr != nil { // Attempt to send a friendlier errer message if we failed to // initiate the SSH connection to the target by sending an auth @@ -159,9 +161,9 @@ func (h *sshHandler) handleTCPConnectorWithTargetConn( return nil } -func (h *sshHandler) initiateSSHConn(ctx context.Context, targetConn net.Conn, user string) (*sshConn, error) { +func (h *sshHandler) initiateSSHConn(ctx context.Context, targetConn net.Conn, user string, agent *sshAgent) (*sshConn, error) { target := h.cfg.target - clientConfig, err := h.cfg.sshProvider.sessionSSHConfig(ctx, target, user) + clientConfig, err := h.cfg.sshProvider.sessionSSHConfig(ctx, target, user, agent) if err != nil { return nil, trace.Wrap(err, "building SSH client config") } diff --git a/lib/vnet/ssh_provider.go b/lib/vnet/ssh_provider.go index 1c54bab4048a3..2584794cf2b75 100644 --- a/lib/vnet/ssh_provider.go +++ b/lib/vnet/ssh_provider.go @@ -53,6 +53,7 @@ type sshProviderConfig struct { target dialTarget, tlsConfig *tls.Config, dialOpts *vnetv1.DialOptions, + agent *sshAgent, ) (net.Conn, error) } @@ -77,7 +78,7 @@ func newSSHProvider(ctx context.Context, cfg sshProviderConfig) (*sshProvider, e } // dial dials the target SSH host. -func (p *sshProvider) dial(ctx context.Context, target dialTarget) (net.Conn, error) { +func (p *sshProvider) dial(ctx context.Context, target dialTarget, agent *sshAgent) (net.Conn, error) { userTLSCertResp, err := p.cfg.clt.UserTLSCert(ctx, target.profile) if err != nil { return nil, trace.Wrap(err) @@ -89,9 +90,10 @@ func (p *sshProvider) dial(ctx context.Context, target dialTarget) (net.Conn, er return nil, trace.Wrap(err) } if p.cfg.overrideNodeDialer != nil { - return p.cfg.overrideNodeDialer(ctx, target, tlsConfig, dialOpts) + conn, err := p.cfg.overrideNodeDialer(ctx, target, tlsConfig, dialOpts, agent) + return conn, trace.Wrap(err) } - return p.dialViaProxy(ctx, target, tlsConfig, dialOpts) + return p.dialViaProxy(ctx, target, tlsConfig, dialOpts, agent) } // dialViaProxy dials the target SSH host via the proxy transport service. @@ -100,6 +102,7 @@ func (p *sshProvider) dialViaProxy( target dialTarget, tlsConfig *tls.Config, dialOpts *vnetv1.DialOptions, + agent *sshAgent, ) (net.Conn, error) { // TODO(nklaassen): consider reusing proxy clients, need to figure out when // it's necessary to make a new client e.g. if the user's TLS credentials @@ -117,8 +120,14 @@ func (p *sshProvider) dialViaProxy( if err != nil { return nil, trace.Wrap(err, "building proxy client") } - // TODO(nklaassen): pass an SSH keyring to support proxy recording mode. - conn, _, err := pclt.DialHost(ctx, target.addr, target.cluster, nil /*keyRing*/) + // Forward an SSH agent in case proxy recording mode is enabled in the cluster. + // At this point there is no SSH key for the user yet and the agent is + // empty, the SSH key will be added to the agent in [sshProvider.sessionSSHConfig]. + // This forwarded agent will be used only to make the next SSH connection + // to the target SSH node, it is not actually forwarded to the target node + // and does not prevent the client from forwarding its own agent if + // requested. + conn, _, err := pclt.DialHost(ctx, target.addr, target.cluster, agent) if err != nil { pclt.Close() return nil, trace.Wrap(err, "dialing target via proxy") @@ -168,6 +177,7 @@ func (p *sshProvider) sessionSSHConfig( ctx context.Context, target dialTarget, user string, + agent *sshAgent, ) (*ssh.ClientConfig, error) { // TODO(nklaassen): cache session SSH configs so we don't have to regenerate // every time. @@ -202,6 +212,13 @@ func (p *sshProvider) sessionSSHConfig( if err != nil { return nil, trace.Wrap(err) } + // Add the session SSH key to the SSH agent in case proxy recording mode is + // enabled. Adding it to the agent here before returning an + // ssh.ClientConfig guarantees the key is added to the agent before the + // agent could be used. + if err := agent.setSessionKey(certSigner); err != nil { + return nil, trace.Wrap(err) + } hostKeyCallback, err := buildHostKeyCallback(resp.GetTrustedCas(), p.cfg.clock) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/vnet/tcp_handler_resolver.go b/lib/vnet/tcp_handler_resolver.go index 7d4d0dbe16696..ff679b48eb2d6 100644 --- a/lib/vnet/tcp_handler_resolver.go +++ b/lib/vnet/tcp_handler_resolver.go @@ -189,7 +189,8 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin log.DebugContext(ctx, "Resolved FQDN to a matched cluster") // Attempt a dial to the target SSH node to see if it exists. target := computeDialTarget(matchedCluster, h.cfg.fqdn) - targetConn, err := h.cfg.sshProvider.dial(ctx, target) + agent := newSSHAgent() + targetConn, err := h.cfg.sshProvider.dial(ctx, target, agent) if err != nil { if trace.IsConnectionProblem(err) { log.DebugContext(ctx, "Failed TCP dial to target, node might be offline") @@ -209,7 +210,7 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin h.setDecidedHandler(sshHandler) // Handle the incoming connection with the TCP connection to the target // SSH node that has already been established. - return sshHandler.handleTCPConnectorWithTargetConn(ctx, connector, targetConn) + return sshHandler.handleTCPConnectorWithTargetConn(ctx, connector, targetConn, agent) } return trace.Errorf("rejecting connection to %s:%d", h.cfg.fqdn, localPort) } diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index 926977f6b1049..0e3ef9c42636a 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -370,6 +370,8 @@ type fakeClientApp struct { // requestedRouteToApps indexed by public address. requestedRouteToApps map[string][]*proto.RouteToApp requestedRouteToAppsMu sync.RWMutex + + forwardedAgents *forwardedAgents } type fakeClientAppConfig struct { @@ -393,13 +395,16 @@ func newFakeClientApp(ctx context.Context, t *testing.T, cfg *fakeClientAppConfi teleportUserCA, err := ssh.NewSignerFromSigner(teleportUserCAKey) require.NoError(t, err) + forwardedAgents := &forwardedAgents{} + tlsCA := newSelfSignedCA(t) dialOpts := mustStartFakeWebProxy(ctx, t, fakeWebProxyConfig{ - tlsCA: tlsCA, - hostCA: teleportHostCA, - userCA: teleportUserCA, - clock: cfg.clock, - suite: cfg.signatureAlgorithmSuite, + tlsCA: tlsCA, + hostCA: teleportHostCA, + userCA: teleportUserCA, + clock: cfg.clock, + suite: cfg.signatureAlgorithmSuite, + forwardedAgents: forwardedAgents, }) return &fakeClientApp{ @@ -409,6 +414,7 @@ func newFakeClientApp(ctx context.Context, t *testing.T, cfg *fakeClientAppConfi teleportHostCA: teleportHostCA, teleportUserCA: teleportUserCA, requestedRouteToApps: make(map[string][]*proto.RouteToApp), + forwardedAgents: forwardedAgents, } } @@ -573,6 +579,7 @@ func (p *fakeClientApp) dialSSHNode( target dialTarget, tlsConfig *tls.Config, dialOpts *vnetv1.DialOptions, + agent *sshAgent, ) (net.Conn, error) { targetCluster, ok := p.cfg.clusters[target.profile] if !ok { @@ -587,6 +594,12 @@ func (p *fakeClientApp) dialSSHNode( if _, ok := targetCluster.nodes[target.hostname]; !ok { return nil, trace.NotFound("no such host") } + // In this test suite all SSH dials go to a single faked web proxy expecting + // the ALPN protocol alpncomm.ProtocolProxySSH for SSH dials. It doesn't + // run the real transport service that handles SSH agent forwarding over + // gRPC, but the test shares the forwarded agent with the fake proxy via + // the forwardedAgents collection. + p.forwardedAgents.add(agent) tlsConfig.NextProtos = []string{string(alpncommon.ProtocolProxySSH)} return tls.Dial("tcp", dialOpts.GetWebProxyAddr(), tlsConfig) } @@ -1598,11 +1611,12 @@ func newLeafCert( } type fakeWebProxyConfig struct { - tlsCA tls.Certificate - hostCA ssh.Signer - userCA ssh.Signer - clock clockwork.Clock - suite types.SignatureAlgorithmSuite + tlsCA tls.Certificate + hostCA ssh.Signer + userCA ssh.Signer + clock clockwork.Clock + suite types.SignatureAlgorithmSuite + forwardedAgents *forwardedAgents } func mustStartFakeWebProxy( @@ -1666,6 +1680,13 @@ func mustStartFakeWebProxy( if conn.User() == "denyuser" { return nil, trace.AccessDenied("access denied for denyuser") } + // The test suite doesn't implement real "proxy recording mode" + // or SSH agent forwarding, but at least test here that the + // user key used to make this connection was forwarded so that + // the real SSH forwarding proxy would have access to it. + if !cfg.forwardedAgents.forwarded(pubKey) { + return nil, trace.Errorf("user SSH key was not forwarded") + } return certChecker.Authenticate(conn, pubKey) }, } @@ -1751,3 +1772,35 @@ func mustStartFakeWebProxy( } return dialOpts } + +// forwardedAgents is a crude way of tracking all the forwarded SSH agents and +// checking if any of them forward a specific SSH key. +type forwardedAgents struct { + mu sync.Mutex + agents []*sshAgent +} + +func (a *forwardedAgents) add(agent *sshAgent) { + a.mu.Lock() + defer a.mu.Unlock() + a.agents = append(a.agents, agent) +} + +func (a *forwardedAgents) forwarded(key ssh.PublicKey) bool { + a.mu.Lock() + defer a.mu.Unlock() + blob := key.Marshal() + for _, agent := range a.agents { + agentKeys, err := agent.List() + if err != nil { + // sshAgent.List never returns an error. + continue + } + for _, agentKey := range agentKeys { + if slices.Equal(agentKey.Blob, blob) { + return true + } + } + } + return false +} From 90401fa7381e6ab930010fe70d3ac3d25b908f6a Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Fri, 20 Jun 2025 09:07:06 -0700 Subject: [PATCH 13/25] [v18][vnet] feat: support diag checks on windows Backport #55856 to branch/v18 --- lib/teleterm/vnet/service.go | 59 ++++++++++++++ lib/teleterm/vnet/service_darwin.go | 60 +------------- lib/teleterm/vnet/service_other.go | 29 +++++++ lib/teleterm/vnet/service_windows.go | 48 +++++++++++ lib/vnet/diag/routeconflict_other.go | 2 +- lib/vnet/diag/routeconflict_windows.go | 79 +++++++++++++++++++ .../src/ui/Vnet/VnetConnectionItem.tsx | 23 +++--- .../src/ui/Vnet/VnetSliderStep.story.tsx | 6 +- .../teleterm/src/ui/Vnet/vnetContext.tsx | 11 --- 9 files changed, 231 insertions(+), 86 deletions(-) create mode 100644 lib/teleterm/vnet/service_other.go create mode 100644 lib/teleterm/vnet/service_windows.go create mode 100644 lib/vnet/diag/routeconflict_windows.go diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index 7b41d7d278005..c3b17dd6f9a53 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -258,6 +258,65 @@ func (s *Service) GetServiceInfo(ctx context.Context, _ *api.GetServiceInfoReque }, nil } +// RunDiagnostics runs a set of heuristics to determine if VNet actually works +// on the device. It requires VNet to be started. +func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsRequest) (*api.RunDiagnosticsResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.status != statusRunning { + return nil, trace.CompareFailed("VNet is not running") + } + + if s.networkStackInfo.InterfaceName == "" { + return nil, trace.BadParameter("no interface name, this is a bug") + } + + if s.networkStackInfo.Ipv6Prefix == "" { + return nil, trace.BadParameter("no IPv6 prefix, this is a bug") + } + + nsa := &diagv1.NetworkStackAttempt{} + if ns, err := s.getNetworkStack(ctx); err != nil { + nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_ERROR + nsa.Error = err.Error() + } else { + nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_OK + nsa.NetworkStack = ns + } + + diagChecks, err := s.platformDiagChecks(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ + Clock: s.cfg.Clock, + NetworkStackAttempt: nsa, + DiagChecks: diagChecks, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return &api.RunDiagnosticsResponse{ + Report: report, + }, nil +} + +func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, error) { + unifiedClusterConfig, err := s.vnetProcess.GetUnifiedClusterConfigProvider().GetUnifiedClusterConfig(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + return &diagv1.NetworkStack{ + InterfaceName: s.networkStackInfo.InterfaceName, + Ipv6Prefix: s.networkStackInfo.Ipv6Prefix, + Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, + DnsZones: unifiedClusterConfig.AllDNSZones(), + }, nil +} + func (s *Service) stopLocked() error { if s.status == statusClosed { return trace.CompareFailed("VNet service has been closed") diff --git a/lib/teleterm/vnet/service_darwin.go b/lib/teleterm/vnet/service_darwin.go index 362246bf292c9..221350086ac5d 100644 --- a/lib/teleterm/vnet/service_darwin.go +++ b/lib/teleterm/vnet/service_darwin.go @@ -21,38 +21,10 @@ import ( "github.com/gravitational/trace" - api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1" - diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" "github.com/gravitational/teleport/lib/vnet/diag" ) -// RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that -// is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. -func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsRequest) (*api.RunDiagnosticsResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.status != statusRunning { - return nil, trace.CompareFailed("VNet is not running") - } - - if s.networkStackInfo.InterfaceName == "" { - return nil, trace.BadParameter("no interface name, this is a bug") - } - - if s.networkStackInfo.Ipv6Prefix == "" { - return nil, trace.BadParameter("no IPv6 prefix, this is a bug") - } - - nsa := &diagv1.NetworkStackAttempt{} - if ns, err := s.getNetworkStack(ctx); err != nil { - nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_ERROR - nsa.Error = err.Error() - } else { - nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_OK - nsa.NetworkStack = ns - } - +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { routeConflictDiag, err := diag.NewRouteConflictDiag(&diag.RouteConflictConfig{ VnetIfaceName: s.networkStackInfo.InterfaceName, Routing: &diag.DarwinRouting{}, @@ -69,32 +41,8 @@ func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsReq return nil, trace.Wrap(err) } - report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ - Clock: s.cfg.Clock, - NetworkStackAttempt: nsa, - DiagChecks: []diag.DiagCheck{ - routeConflictDiag, - sshDiag, - }, - }) - if err != nil { - return nil, trace.Wrap(err) - } - - return &api.RunDiagnosticsResponse{ - Report: report, - }, nil -} - -func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, error) { - unifiedClusterConfig, err := s.vnetProcess.GetUnifiedClusterConfigProvider().GetUnifiedClusterConfig(ctx) - if err != nil { - return nil, trace.Wrap(err) - } - return &diagv1.NetworkStack{ - InterfaceName: s.networkStackInfo.InterfaceName, - Ipv6Prefix: s.networkStackInfo.Ipv6Prefix, - Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, - DnsZones: unifiedClusterConfig.AllDNSZones(), + return []diag.DiagCheck{ + routeConflictDiag, + sshDiag, }, nil } diff --git a/lib/teleterm/vnet/service_other.go b/lib/teleterm/vnet/service_other.go new file mode 100644 index 0000000000000..e710809f91ffd --- /dev/null +++ b/lib/teleterm/vnet/service_other.go @@ -0,0 +1,29 @@ +// 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 . + +//go:build !darwin && !windows + +package vnet + +import ( + "context" + + "github.com/gravitational/teleport/lib/vnet/diag" +) + +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { + return nil, nil +} diff --git a/lib/teleterm/vnet/service_windows.go b/lib/teleterm/vnet/service_windows.go new file mode 100644 index 0000000000000..d41fe2ee00f7d --- /dev/null +++ b/lib/teleterm/vnet/service_windows.go @@ -0,0 +1,48 @@ +// 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 vnet + +import ( + "context" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/vnet/diag" +) + +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { + routeConflictDiag, err := diag.NewRouteConflictDiag(&diag.RouteConflictConfig{ + VnetIfaceName: s.networkStackInfo.InterfaceName, + Routing: &diag.WindowsRouting{}, + Interfaces: &diag.NetInterfaces{}, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ + ProfilePath: s.cfg.profilePath, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return []diag.DiagCheck{ + routeConflictDiag, + sshDiag, + }, nil +} diff --git a/lib/vnet/diag/routeconflict_other.go b/lib/vnet/diag/routeconflict_other.go index ce0acdaca1553..5ca8360dabd60 100644 --- a/lib/vnet/diag/routeconflict_other.go +++ b/lib/vnet/diag/routeconflict_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows // Teleport // Copyright (C) 2025 Gravitational, Inc. diff --git a/lib/vnet/diag/routeconflict_windows.go b/lib/vnet/diag/routeconflict_windows.go new file mode 100644 index 0000000000000..9dc89fc2d775d --- /dev/null +++ b/lib/vnet/diag/routeconflict_windows.go @@ -0,0 +1,79 @@ +// 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 diag + +import ( + "context" + "net/netip" + "os/exec" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows" + "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" +) + +var ipv4Broadcast = netip.AddrFrom4([4]byte{255, 255, 255, 255}) + +// WindowsRouting provides Windows-specific [Routing] implementation used by [RouteConflictDiag]. +type WindowsRouting struct{} + +// GetRouteDestinations gets routes from the OS and then extracts the only +// information needed from them: the route destination and the index of the +// network interface. It operates solely on IPv4 routes. +func (wr *WindowsRouting) GetRouteDestinations() ([]RouteDest, error) { + rows, err := winipcfg.GetIPForwardTable2(windows.AF_INET) + if err != nil { + return nil, trace.Wrap(err) + } + rds := make([]RouteDest, 0, len(rows)) + for _, row := range rows { + prefix := row.DestinationPrefix.Prefix() + addr := prefix.Addr() + if addr.IsLinkLocalMulticast() || addr == ipv4Broadcast { + // All interfaces seem to get a link local multicast and broadcast + // route assigned which would always appear as a conflict, so skip + // them. + continue + } + if prefix.IsSingleIP() { + rds = append(rds, &RouteDestIP{ + Addr: addr, + ifaceIndex: int(row.InterfaceIndex), + }) + } else { + rds = append(rds, &RouteDestPrefix{ + Prefix: prefix, + ifaceIndex: int(row.InterfaceIndex), + }) + } + } + return rds, nil +} + +func (n *NetInterfaces) interfaceApp(ctx context.Context, ifaceName string) (string, error) { + // Interfaces usually have descriptive names on Windows (the TUN interfaces + // used by VNet and Tailscale do, at least). + return ifaceName, nil +} + +func (c *RouteConflictDiag) commands(ctx context.Context) []*exec.Cmd { + return []*exec.Cmd{ + exec.CommandContext(ctx, "netstat.exe", "-rn"), + exec.CommandContext(ctx, "ipconfig.exe", "/all"), + exec.CommandContext(ctx, "netsh.exe", "namespace", "show", "effectivepolicy"), + } +} diff --git a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx index 9ef08eceffc83..ca303cd2942ca 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx @@ -114,7 +114,6 @@ const VnetConnectionItemBase = forwardRef< diagnosticsAttempt, getDisabledDiagnosticsReason, showDiagWarningIndicator, - isDiagSupported, } = useVnetContext(); const { close: closeConnectionsPanel } = useConnectionsContext(); const rootClusterUri = useStoreSelector( @@ -259,18 +258,16 @@ const VnetConnectionItemBase = forwardRef< )} - {isDiagSupported && ( - { - e.stopPropagation(); - props.runDiagnosticsFromVnetPanel(); - }} - > - - - )} + { + e.stopPropagation(); + props.runDiagnosticsFromVnetPanel(); + }} + > + + )} diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx index 8ec5a33d8f246..290698fa115c7 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx @@ -46,7 +46,6 @@ type StoryProps = { | 'error' | 'processing' | 'processing-with-previous-results'; - vnetDiag: boolean; runDiagnostics: 'success' | 'error' | 'processing'; diagReport: 'ok' | 'issues-found' | 'failed-checks'; isWorkspacePresent: boolean; @@ -60,7 +59,6 @@ const defaultArgs: StoryProps = { clusters: ['teleport.example.com'], sshConfigured: false, fetchStatus: 'success', - vnetDiag: true, runDiagnostics: 'success', diagReport: 'ok', isWorkspacePresent: true, @@ -117,9 +115,7 @@ const meta: Meta = { export default meta; function VnetSliderStep(props: StoryProps) { - const appContext = new MockAppContext({ - platform: props.vnetDiag ? 'darwin' : 'win32', - }); + const appContext = new MockAppContext(); if (props.isWorkspacePresent) { appContext.addRootCluster(makeRootCluster()); diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx index 1f20aa48be18b..3779cf77be607 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -54,10 +54,6 @@ export type VnetContext = { * Describes whether the given OS can run VNet. */ isSupported: boolean; - /** - * Describes whether the given OS can run VNet diagnostics. - */ - isDiagSupported: boolean; status: VnetStatus; start: () => Promise<[void, Error]>; startAttempt: Attempt; @@ -149,7 +145,6 @@ export const VnetContextProvider: FC< [mainProcessClient] ); const isSupported = platform === 'darwin' || platform === 'win32'; - const isDiagSupported = platform === 'darwin'; const [startAttempt, start] = useAsync( useCallback(async () => { @@ -429,10 +424,6 @@ export const VnetContextProvider: FC< useEffect( function periodicallyRunDiagnostics() { - if (!isDiagSupported) { - return; - } - if (status.value !== 'running') { return; } @@ -454,7 +445,6 @@ export const VnetContextProvider: FC< }; }, [ - isDiagSupported, diagnosticsIntervalMs, runDiagnosticsAndShowNotification, status.value, @@ -466,7 +456,6 @@ export const VnetContextProvider: FC< Date: Sun, 22 Jun 2025 11:22:22 -0700 Subject: [PATCH 14/25] [v18] fix: data race in vnet.TestSSH Backport #55980 to branch/v18 --- lib/vnet/ssh_agent.go | 13 ++++++++--- lib/vnet/vnet_test.go | 54 ++++++++++++++++++++++--------------------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/lib/vnet/ssh_agent.go b/lib/vnet/ssh_agent.go index 314db3373938e..1db229c1e82fa 100644 --- a/lib/vnet/ssh_agent.go +++ b/lib/vnet/ssh_agent.go @@ -19,6 +19,7 @@ package vnet import ( "context" "crypto/rand" + "sync" "github.com/gravitational/trace" "golang.org/x/crypto/ssh" @@ -33,10 +34,8 @@ import ( // and the root cluster proxy terminated with the SSH key in the // [ssh.ClientConfig], and then the key forwarded via this agent will be used // to terminate the final SSH connection to the target node. -// -// It is not safe for concurrent use, setSessionKey must only be called before -// the agent will actually be used. type sshAgent struct { + mu sync.Mutex signer ssh.Signer } @@ -49,6 +48,8 @@ func newSSHAgent() *sshAgent { // agent must be passed to [proxy.Client.DialHost] before the session SSH // signer has been created. func (a *sshAgent) setSessionKey(signer ssh.Signer) error { + a.mu.Lock() + defer a.mu.Unlock() if a.signer != nil { return trace.Errorf("sshAgent.setSessionKey must be called at most once (this is a bug)") } @@ -59,6 +60,8 @@ func (a *sshAgent) setSessionKey(signer ssh.Signer) error { // List implements [agent.ExtendedAgent.List], it returns a single key if it // has been set by setSessionKey. func (a *sshAgent) List() ([]*agent.Key, error) { + a.mu.Lock() + defer a.mu.Unlock() if a.signer == nil { return nil, nil } @@ -72,6 +75,8 @@ func (a *sshAgent) List() ([]*agent.Key, error) { // List implements [agent.ExtendedAgent.Signers], it returns a single key if it // has been set by setSessionKey. func (a *sshAgent) Signers() ([]ssh.Signer, error) { + a.mu.Lock() + defer a.mu.Unlock() if a.signer == nil { return nil, nil } @@ -88,6 +93,8 @@ func (a *sshAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) // SSH signature with a.signer if it has been set and matches the requested // key. func (a *sshAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + a.mu.Lock() + defer a.mu.Unlock() if a.signer == nil { return nil, trace.Errorf("VNet SSH agent has no signer") } diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index 0e3ef9c42636a..07f6075aed119 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -1158,8 +1158,7 @@ func testWithAlgorithmSuite(t *testing.T, suite types.SignatureAlgorithmSuite) { // TestSSH tests basic VNet SSH functionality. func TestSSH(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) + ctx := t.Context() clock := clockwork.NewRealClock() homePath := t.TempDir() @@ -1343,42 +1342,44 @@ func TestSSH(t *testing.T) { t.Run(fmt.Sprintf("%s@%s:%d", tc.sshUser, tc.dialAddr, tc.dialPort), func(t *testing.T) { t.Parallel() - lookupCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) - defer cancel() - // The DNS lookup for *. should resolve to an IP in - // the expected CIDR range for the cluster. - resolvedAddrs, err := p.lookupHost(lookupCtx, tc.dialAddr) if tc.expectLookupToFail { + // In these cases the DNS lookup is expected to fail, just run the DNS lookup. + ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + _, err := p.lookupHost(ctx, tc.dialAddr) require.Error(t, err) return } - require.NoError(t, err) - _, expectNet, err := net.ParseCIDR(tc.expectCIDR) - require.NoError(t, err) - - for _, resolvedAddr := range resolvedAddrs { - resolvedIP := net.ParseIP(resolvedAddr) - // The query may have resolved to a v4 or v6 address or both, - // either way the 4-byte suffix should be a valid IPv4 address - // in the expected CIDR range. - resolvedIPSuffix := resolvedIP[len(resolvedIP)-4:] - assert.True(t, expectNet.Contains(resolvedIPSuffix), - "expected CIDR range %s does not include resolved IP %s", expectNet, resolvedIPSuffix) - } - - // TCP dial the target address, it should fail if the node doesn't - // exist. - dialCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) - defer cancel() - conn, err := p.dialHost(dialCtx, tc.dialAddr, tc.dialPort) if tc.expectDialToFail { + // In these cases the DNS lookup should succeed but then the + // TCP dial should fail, do each separately to make sure we + // catch the error at the right step. + resolvedAddrs, err := p.lookupHost(ctx, tc.dialAddr) + require.NoError(t, err) + require.NotEmpty(t, resolvedAddrs) + + ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + _, err = p.dialHost(ctx, resolvedAddrs[0], tc.dialPort) require.Error(t, err) return } + + conn, err := p.dialHost(ctx, tc.dialAddr, tc.dialPort) require.NoError(t, err) defer conn.Close() + // The DNS query may have resolved to a v4 or v6 address, either + // way the 4-byte suffix should be a valid IPv4 address in the + // expected CIDR range. + resolvedIP := conn.RemoteAddr().(*net.TCPAddr).IP + resolvedIPSuffix := resolvedIP[len(resolvedIP)-4:] + _, expectNet, err := net.ParseCIDR(tc.expectCIDR) + require.NoError(t, err) + assert.True(t, expectNet.Contains(resolvedIPSuffix), + "expected CIDR range %s does not include resolved IP %s", expectNet, resolvedIPSuffix) + // Initiate an SSH connection to the target. At this point the // handshake should complete successfully as long as the right keys // are used, but the SSH connection will be immediately closed by @@ -1414,6 +1415,7 @@ func TestSSH(t *testing.T) { // Test that a fresh SSH host cert is used on each connection. t.Run("ephemeral certs", func(t *testing.T) { + t.Parallel() // Set up the SSH client config to capture the host certs it sees. var checkedHostCerts []*ssh.Certificate clientConfig := &ssh.ClientConfig{ From 6830541cb2ea9dd394a8bd7e94c2df830aaa84b5 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Mon, 23 Jun 2025 09:47:54 -0700 Subject: [PATCH 15/25] [v18][vnet] feat: mention SSH on VNet info page Backport #55973 to branch/v18 --- .../teleterm/src/ui/Vnet/DocumentVnetInfo.tsx | 77 ++++++++++++++++++- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx b/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx index 878ae406aa808..d21ed692f32a6 100644 --- a/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx +++ b/web/packages/teleterm/src/ui/Vnet/DocumentVnetInfo.tsx @@ -39,7 +39,7 @@ export function DocumentVnetInfo(props: { doc: docTypes.DocumentVnetInfo; }) { const { doc } = props; - const { mainProcessClient } = useAppContext(); + const { mainProcessClient, clustersService } = useAppContext(); const { startAttempt, stop: stopVnet, @@ -47,12 +47,14 @@ export function DocumentVnetInfo(props: { status, } = useVnetContext(); const { launchVnetWithoutFirstTimeCheck } = useVnetLauncher(); - const userAtHost = useMemo(() => { - const { hostname, username } = mainProcessClient.getRuntimeSettings(); - return `${username}@${hostname}`; + const { username, hostname } = useMemo(() => { + const { username, hostname } = mainProcessClient.getRuntimeSettings(); + return { username, hostname }; }, [mainProcessClient]); + const userAtHost = `${username}@${hostname}`; const { rootClusterUri, documentsService } = useWorkspaceContext(); const proxyHostname = routing.parseClusterName(rootClusterUri); + const clusterName = clustersService.findCluster(rootClusterUri).name; const startVnet = async () => { await launchVnetWithoutFirstTimeCheck(doc.launcherArgs); @@ -229,6 +231,60 @@ export function DocumentVnetInfo(props: { + + {/* VNet SSH */} + + +

SSH Servers With 3rd-Party SSH Clients

+ {/* TODO(nklaassen): link to new VNet SSH docs */} + Learn More +
+ + + +

With VNet

+ + Connect directly to Teleport SSH servers with any + OpenSSH-compatible client or your preferred editor's remote SSH + extension. VNet will intercept the connection to authenticate + with your SSH certificate and handle Teleport features like + hardware keys and per-session MFA with prompts displayed in + Connect. + +
+ + +
+ + + +

Without VNet

+ + You can still connect to Teleport SSH servers with many + OpenSSH-compatible clients, but you'll need to configure the + client to use your Teleport user SSH certificate and use a + ProxyCommand to make the connection, and Teleport features like + hardware keys and per-session MFA are not supported. + +
+ + + + +
+
); @@ -298,3 +354,16 @@ const curlWithoutVnet = `$ curl http://127.0.0.1:61397 "body": "" } `; + +const sshWithVNet = ( + username: string, + clusterName: string +) => `$ ssh ${username}@server.${clusterName} +${username}@server:~$ `; + +const sshWithoutVNet = ( + username: string, + clusterName: string +) => `$ tsh config > teleport_ssh_config +$ ssh -F ./teleport_ssh_config ${username}@server.${clusterName} +${username}@server:~$ `; From 11bd108e61373933d467320f68028ecebbb6ffd7 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Mon, 23 Jun 2025 10:33:33 -0700 Subject: [PATCH 16/25] [v18][vnet] feat: serve DNS on IPv4 Backport #55539 to branch/v18 --- lib/vnet/admin_process_darwin.go | 13 +++--- lib/vnet/admin_process_windows.go | 13 +++--- lib/vnet/network_stack.go | 35 +++++++++----- lib/vnet/osconfig.go | 17 +------ lib/vnet/osconfig_darwin.go | 30 ++++++++---- lib/vnet/osconfig_provider.go | 75 ++++++++++++++++++++++-------- lib/vnet/osconfig_provider_test.go | 40 ++++++++++++---- lib/vnet/osconfig_windows.go | 33 +++++++++---- lib/vnet/unsupported_os.go | 1 + 9 files changed, 169 insertions(+), 88 deletions(-) diff --git a/lib/vnet/admin_process_darwin.go b/lib/vnet/admin_process_darwin.go index 9161c45d94d4e..0465dd8e8aae5 100644 --- a/lib/vnet/admin_process_darwin.go +++ b/lib/vnet/admin_process_darwin.go @@ -75,12 +75,13 @@ func RunDarwinAdminProcess(ctx context.Context, config daemon.Config) error { return trace.Wrap(err, "reporting network stack info to client application") } - osConfigProvider, err := newOSConfigProvider( - clt, - tunName, - networkStackConfig.ipv6Prefix.String(), - networkStackConfig.dnsIPv6.String(), - ) + osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ + clt: clt, + tunName: tunName, + ipv6Prefix: networkStackConfig.ipv6Prefix.String(), + dnsIPv6: networkStackConfig.dnsIPv6.String(), + addDNSAddress: networkStack.addDNSAddress, + }) if err != nil { return trace.Wrap(err, "creating OS config provider") } diff --git a/lib/vnet/admin_process_windows.go b/lib/vnet/admin_process_windows.go index a386ae57d63e9..09afbf8619d48 100644 --- a/lib/vnet/admin_process_windows.go +++ b/lib/vnet/admin_process_windows.go @@ -116,12 +116,13 @@ func runWindowsAdminProcess(ctx context.Context, cfg *windowsAdminProcessConfig) return trace.Wrap(err, "reporting network stack info to client application") } - osConfigProvider, err := newOSConfigProvider( - clt, - tunName, - networkStackConfig.ipv6Prefix.String(), - networkStackConfig.dnsIPv6.String(), - ) + osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ + clt: clt, + tunName: tunName, + ipv6Prefix: networkStackConfig.ipv6Prefix.String(), + dnsIPv6: networkStackConfig.dnsIPv6.String(), + addDNSAddress: networkStack.addDNSAddress, + }) if err != nil { return trace.Wrap(err, "creating OS config provider") } diff --git a/lib/vnet/network_stack.go b/lib/vnet/network_stack.go index 25256b4a4eb59..6c7d38ba14415 100644 --- a/lib/vnet/network_stack.go +++ b/lib/vnet/network_stack.go @@ -158,6 +158,10 @@ type networkStack struct { // ipv6Prefix holds the 96-bit prefix that will be used for all IPv6 addresses assigned in the VNet. ipv6Prefix tcpip.Address + // dnsServer is the VNet's local DNS server that can handle UDP DNS + // requests. + dnsServer *dns.Server + // tcpHandlerResolver resolves FQDNs to a TCP handler that will be used to handle all future TCP // connections to IP addresses that will be assigned to that FQDN. tcpHandlerResolver *tcpHandlerResolver @@ -233,6 +237,19 @@ func newNetworkStack(cfg *networkStackConfig) (*networkStack, error) { slog: slog, } + upstreamNameserverSource := cfg.upstreamNameserverSource + if upstreamNameserverSource == nil { + upstreamNameserverSource, err = dns.NewOSUpstreamNameserverSource() + if err != nil { + return nil, trace.Wrap(err) + } + } + dnsServer, err := dns.NewServer(ns, upstreamNameserverSource) + if err != nil { + return nil, trace.Wrap(err) + } + ns.dnsServer = dnsServer + tcpForwarder := tcp.NewForwarder(ns.stack, tcpReceiveBufferSize, maxInFlightTCPConnectionAttempts, ns.handleTCP) ns.stack.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket) @@ -240,17 +257,6 @@ func newNetworkStack(cfg *networkStackConfig) (*networkStack, error) { ns.stack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket) if cfg.dnsIPv6 != (tcpip.Address{}) { - upstreamNameserverSource := cfg.upstreamNameserverSource - if upstreamNameserverSource == nil { - upstreamNameserverSource, err = dns.NewOSUpstreamNameserverSource() - if err != nil { - return nil, trace.Wrap(err) - } - } - dnsServer, err := dns.NewServer(ns, upstreamNameserverSource) - if err != nil { - return nil, trace.Wrap(err) - } if err := ns.assignUDPHandler(cfg.dnsIPv6, dnsServer); err != nil { return nil, trace.Wrap(err) } @@ -542,6 +548,13 @@ func (ns *networkStack) assignUDPHandler(addr tcpip.Address, handler udpHandler) return nil } +// addDNSAddress adds a DNS handler at the given IP. +func (ns *networkStack) addDNSAddress(ip net.IP) error { + slog.DebugContext(context.Background(), "Serving DNS on IPv4.", "dns_addr", ip.String()) + return trace.Wrap(ns.assignUDPHandler(tcpip.AddrFromSlice(ip), ns.dnsServer), + "adding UDP handler at %s", ip.String()) +} + // ResolveA implements [dns.Resolver.ResolveA]. func (ns *networkStack) ResolveA(ctx context.Context, fqdn string) (dns.Result, error) { // Do the actual resolution within a [singleflight.Group] keyed by [fqdn] to avoid concurrent requests to diff --git a/lib/vnet/osconfig.go b/lib/vnet/osconfig.go index 4b0a19eadd033..c27336fe33c17 100644 --- a/lib/vnet/osconfig.go +++ b/lib/vnet/osconfig.go @@ -18,7 +18,6 @@ package vnet import ( "context" - "net" "net/netip" "os/exec" "strings" @@ -32,7 +31,7 @@ type osConfig struct { tunIPv4 string tunIPv6 string cidrRanges []string - dnsAddr string + dnsAddrs []string dnsZones []string } @@ -125,20 +124,6 @@ func tunIPv6ForPrefix(ipv6Prefix string) (string, error) { return addr.Next().String(), nil } -// tunIPv4ForCIDR returns the IPv4 address to use for the TUN interface in -// cidrRange. It always returns the second address in the range. -func tunIPv4ForCIDR(cidrRange string) (string, error) { - _, ipnet, err := net.ParseCIDR(cidrRange) - if err != nil { - return "", trace.Wrap(err, "parsing CIDR %q", cidrRange) - } - // ipnet.IP is the network address, ending in 0s, like 100.64.0.0 - // Add 1 to assign the TUN address, like 100.64.0.1 - tunAddress := ipnet.IP - tunAddress[len(tunAddress)-1]++ - return tunAddress.String(), nil -} - func runCommand(ctx context.Context, path string, args ...string) error { cmdString := strings.Join(append([]string{path}, args...), " ") log.DebugContext(ctx, "Running command", "cmd", cmdString) diff --git a/lib/vnet/osconfig_darwin.go b/lib/vnet/osconfig_darwin.go index eb7b7307a19cc..4dcdd9e3da777 100644 --- a/lib/vnet/osconfig_darwin.go +++ b/lib/vnet/osconfig_darwin.go @@ -18,10 +18,12 @@ package vnet import ( "bufio" + "bytes" "context" "os" "path/filepath" + "github.com/google/renameio/v2" "github.com/gravitational/trace" ) @@ -69,7 +71,7 @@ func platformConfigureOS(ctx context.Context, cfg *osConfig, _ *platformOSConfig } } - if err := configureDNS(ctx, cfg.dnsAddr, cfg.dnsZones); err != nil { + if err := configureDNS(ctx, cfg.dnsAddrs, cfg.dnsZones); err != nil { return trace.Wrap(err, "configuring DNS") } @@ -80,12 +82,14 @@ const resolverFileComment = "# automatically installed by Teleport VNet" var resolverPath = filepath.Join("/", "etc", "resolver") -func configureDNS(ctx context.Context, nameserver string, zones []string) error { - if len(nameserver) == 0 && len(zones) > 0 { - return trace.BadParameter("empty nameserver with non-empty zones") +func configureDNS(ctx context.Context, nameservers []string, zones []string) error { + if len(nameservers) == 0 { + // There are no nameservers so VNet can't handle any DNS zones. Continue + // so that any VNet-managed resolver files can be deleted. + zones = nil } - log.DebugContext(ctx, "Configuring DNS.", "nameserver", nameserver, "zones", zones) + log.DebugContext(ctx, "Configuring DNS.", "nameservers", nameservers, "zones", zones) if err := os.MkdirAll(resolverPath, os.FileMode(0755)); err != nil { return trace.Wrap(err, "creating %s", resolverPath) } @@ -95,14 +99,22 @@ func configureDNS(ctx context.Context, nameserver string, zones []string) error return trace.Wrap(err, "finding VNet managed files in /etc/resolver") } - // Always attempt to write or clean up all files below, even if encountering errors with one or more of - // them. + // Always attempt to write or clean up all files below, even if encountering + // errors with one or more of them. var allErrors []error + var fileContents bytes.Buffer + fileContents.WriteString(resolverFileComment) + fileContents.WriteByte('\n') + for _, nameserver := range nameservers { + fileContents.WriteString("nameserver ") + fileContents.WriteString(nameserver) + fileContents.WriteByte('\n') + } + for _, zone := range zones { fileName := filepath.Join(resolverPath, zone) - contents := resolverFileComment + "\nnameserver " + nameserver - if err := os.WriteFile(fileName, []byte(contents), 0644); err != nil { + if err := renameio.WriteFile(fileName, fileContents.Bytes(), 0644); err != nil { allErrors = append(allErrors, trace.Wrap(err, "writing DNS configuration file %s", fileName)) } else { // Successfully wrote this file, don't clean it up below. diff --git a/lib/vnet/osconfig_provider.go b/lib/vnet/osconfig_provider.go index 3a45e8aa89000..a2f4bbf4233bd 100644 --- a/lib/vnet/osconfig_provider.go +++ b/lib/vnet/osconfig_provider.go @@ -18,6 +18,8 @@ package vnet import ( "context" + "net" + "slices" "github.com/gravitational/trace" @@ -27,60 +29,93 @@ import ( // osConfigProvider fetches a target OS configuration based on cluster // configuration fetched via the client application process available over gRPC. type osConfigProvider struct { - clt targetOSConfigGetter - tunName string - dnsAddr string - tunIPv6 string - tunIPv4 string + cfg osConfigProviderConfig + dnsAddrs []string + tunIPv6 string + tunIPv4 string +} + +// osConfigProviderConfig holds configuration parameters for an osConfigProvider. +type osConfigProviderConfig struct { + clt targetOSConfigGetter + tunName string + ipv6Prefix string + dnsIPv6 string + addDNSAddress func(net.IP) error } type targetOSConfigGetter interface { GetTargetOSConfiguration(context.Context) (*vnetv1.TargetOSConfiguration, error) } -func newOSConfigProvider(clt targetOSConfigGetter, tunName, ipv6Prefix, dnsAddr string) (*osConfigProvider, error) { - tunIPv6, err := tunIPv6ForPrefix(ipv6Prefix) +func newOSConfigProvider(cfg osConfigProviderConfig) (*osConfigProvider, error) { + tunIPv6, err := tunIPv6ForPrefix(cfg.ipv6Prefix) if err != nil { return nil, trace.Wrap(err) } return &osConfigProvider{ - clt: clt, - tunName: tunName, - dnsAddr: dnsAddr, - tunIPv6: tunIPv6, + cfg: cfg, + dnsAddrs: []string{cfg.dnsIPv6}, + tunIPv6: tunIPv6, }, nil } func (p *osConfigProvider) targetOSConfig(ctx context.Context) (*osConfig, error) { - targetOSConfig, err := p.clt.GetTargetOSConfiguration(ctx) + targetOSConfig, err := p.cfg.clt.GetTargetOSConfiguration(ctx) if err != nil { return nil, trace.Wrap(err, "getting target OS configuration from client application") } if p.tunIPv4 == "" && len(targetOSConfig.Ipv4CidrRanges) > 0 { - // Choose an IPv4 address for the TUN interface from the CIDR range of one arbitrary currently - // logged-in cluster. Only one IPv4 address is needed. - if err := p.setTunIPv4FromCIDR(targetOSConfig.Ipv4CidrRanges[0]); err != nil { + // Choose an IPv4 address for the TUN interface and the IPv4 DNS server + // from the CIDR range of one arbitrary currently logged-in cluster. + // We currently only assign one V4 address to the interface and only + // advertise DNS on one V4 address. + if err := p.setV4IPsFromFirstCIDR(targetOSConfig.Ipv4CidrRanges[0]); err != nil { return nil, trace.Wrap(err, "setting TUN IPv4 address") } } return &osConfig{ - tunName: p.tunName, + tunName: p.cfg.tunName, tunIPv6: p.tunIPv6, tunIPv4: p.tunIPv4, - dnsAddr: p.dnsAddr, + dnsAddrs: p.dnsAddrs, dnsZones: targetOSConfig.GetDnsZones(), cidrRanges: targetOSConfig.GetIpv4CidrRanges(), }, nil } -func (p *osConfigProvider) setTunIPv4FromCIDR(cidrRange string) error { +func (p *osConfigProvider) setV4IPsFromFirstCIDR(cidrRange string) error { if p.tunIPv4 != "" { + // Only set these once. return nil } - ip, err := tunIPv4ForCIDR(cidrRange) + tunIPv4, dnsIPv4, err := ipsForCIDR(cidrRange) if err != nil { return trace.Wrap(err, "setting TUN IPv4 address for range %s", cidrRange) } - p.tunIPv4 = ip + if err := p.cfg.addDNSAddress(dnsIPv4); err != nil { + return trace.Wrap(err, "adding IPv4 DNS server at %s", dnsIPv4.String()) + } + p.tunIPv4 = tunIPv4.String() + p.dnsAddrs = append(p.dnsAddrs, dnsIPv4.String()) return nil } + +// ipsForCIDR returns the V4 IPs to assign to the interface and use for DNS in +// cidrRange. +func ipsForCIDR(cidrRange string) (tunIP net.IP, dnsIP net.IP, err error) { + _, ipnet, err := net.ParseCIDR(cidrRange) + if err != nil { + return nil, nil, trace.Wrap(err, "parsing CIDR %q", cidrRange) + } + // ipnet.IP is the network address, ending in 0s, like 100.64.0.0 + // Add 1 to assign the TUN address, like 100.64.0.1 + tunIP = ipnet.IP + tunIP[len(tunIP)-1]++ + + // Add 1 again to assign the DNS address, like 100.64.0.2 + dnsIP = slices.Clone(tunIP) + dnsIP[len(dnsIP)-1]++ + + return tunIP, dnsIP, nil +} diff --git a/lib/vnet/osconfig_provider_test.go b/lib/vnet/osconfig_provider_test.go index af49b67aaa203..dd4014fd6123d 100644 --- a/lib/vnet/osconfig_provider_test.go +++ b/lib/vnet/osconfig_provider_test.go @@ -18,6 +18,7 @@ package vnet import ( "context" + "net" "testing" "github.com/stretchr/testify/require" @@ -31,7 +32,7 @@ func TestOSConfigProvider(t *testing.T) { desc string tunName string ipv6Prefix string - dnsAddr string + dnsIPv6 string dnsZones []string ipv4CIDRRanges []string getTargetOSConfigErr error @@ -44,13 +45,13 @@ func TestOSConfigProvider(t *testing.T) { desc: "no cidr ranges", tunName: "testtun1", ipv6Prefix: "fd01:2345:6789::", - dnsAddr: "fd01:2345:6789::2", + dnsIPv6: "fd01:2345:6789::2", dnsZones: []string{"test.example.com"}, expectTargetOSConfig: &osConfig{ tunName: "testtun1", // Should be the first non-broadcast address under the IPv6 prefix. tunIPv6: "fd01:2345:6789::1", - dnsAddr: "fd01:2345:6789::2", + dnsAddrs: []string{"fd01:2345:6789::2"}, dnsZones: []string{"test.example.com"}, }, }, @@ -58,15 +59,16 @@ func TestOSConfigProvider(t *testing.T) { desc: "with cidr range", tunName: "testtun1", ipv6Prefix: "fd01:2345:6789::", - dnsAddr: "fd01:2345:6789::2", + dnsIPv6: "fd01:2345:6789::2", dnsZones: []string{"test.example.com"}, ipv4CIDRRanges: []string{"192.168.1.0/24"}, expectTargetOSConfig: &osConfig{ tunName: "testtun1", // Should be the first non-broadcast address in the CIDR range. - tunIPv4: "192.168.1.1", - tunIPv6: "fd01:2345:6789::1", - dnsAddr: "fd01:2345:6789::2", + tunIPv4: "192.168.1.1", + tunIPv6: "fd01:2345:6789::1", + // Should include the second non-broadcast address in the CIDR range. + dnsAddrs: []string{"fd01:2345:6789::2", "192.168.1.2"}, dnsZones: []string{"test.example.com"}, cidrRanges: []string{"192.168.1.0/24"}, }, @@ -75,7 +77,7 @@ func TestOSConfigProvider(t *testing.T) { desc: "multiple cidr ranges", tunName: "testtun1", ipv6Prefix: "fd01:2345:6789::", - dnsAddr: "fd01:2345:6789::2", + dnsIPv6: "fd01:2345:6789::2", dnsZones: []string{"test.example.com"}, ipv4CIDRRanges: []string{"10.64.0.0/16", "192.168.1.0/24"}, expectTargetOSConfig: &osConfig{ @@ -83,7 +85,7 @@ func TestOSConfigProvider(t *testing.T) { // Should be chosen from the first CIDR range. tunIPv4: "10.64.0.1", tunIPv6: "fd01:2345:6789::1", - dnsAddr: "fd01:2345:6789::2", + dnsAddrs: []string{"fd01:2345:6789::2", "10.64.0.2"}, dnsZones: []string{"test.example.com"}, cidrRanges: []string{"10.64.0.0/16", "192.168.1.0/24"}, }, @@ -97,7 +99,18 @@ func TestOSConfigProvider(t *testing.T) { }, err: tc.getTargetOSConfigErr, } - osConfigProvider, err := newOSConfigProvider(targetOSConfigGetter, tc.tunName, tc.ipv6Prefix, tc.dnsAddr) + // Keep track of new DNS addresses the osConfigProvider tried to add. + var addedDNSAddrs []string + osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ + clt: targetOSConfigGetter, + tunName: tc.tunName, + ipv6Prefix: tc.ipv6Prefix, + dnsIPv6: tc.dnsIPv6, + addDNSAddress: func(ip net.IP) error { + addedDNSAddrs = append(addedDNSAddrs, ip.String()) + return nil + }, + }) require.NoError(t, err) targetOSConfig, err := osConfigProvider.targetOSConfig(ctx) @@ -106,6 +119,13 @@ func TestOSConfigProvider(t *testing.T) { return } require.Equal(t, tc.expectTargetOSConfig, targetOSConfig) + + // expectTargetOSConfig.dnsAddrs always starts with the IPv6 DNS + // addr, assert that any additional addrs were added to the network + // stack. + if len(tc.expectTargetOSConfig.dnsAddrs) > 1 { + require.ElementsMatch(t, tc.expectTargetOSConfig.dnsAddrs[1:], addedDNSAddrs) + } }) } } diff --git a/lib/vnet/osconfig_windows.go b/lib/vnet/osconfig_windows.go index 0b10495434e1a..06bfdb77d0d79 100644 --- a/lib/vnet/osconfig_windows.go +++ b/lib/vnet/osconfig_windows.go @@ -43,6 +43,7 @@ type platformOSConfigState struct { configuredV6Address bool configuredRanges []string configuredDNSZones []string + configuredDNSAddrs []string ifaceIndex string } @@ -114,10 +115,11 @@ func platformConfigureOS(ctx context.Context, cfg *osConfig, state *platformOSCo } if shouldUpdateDNSConfig(cfg, state) { - if err := configureDNS(ctx, cfg.dnsZones, cfg.dnsAddr); err != nil { + if err := configureDNS(ctx, cfg.dnsZones, cfg.dnsAddrs); err != nil { return trace.Wrap(err, "configuring DNS") } state.configuredDNSZones = cfg.dnsZones + state.configuredDNSAddrs = cfg.dnsAddrs } return nil @@ -135,20 +137,22 @@ func addrMaskForCIDR(cidr string) (string, string, error) { } func shouldUpdateDNSConfig(cfg *osConfig, state *platformOSConfigState) bool { - // Always reconfigure if there should be no zones, to make sure we clear - // any leftover state when starting up. - if len(cfg.dnsZones) == 0 { + // Always reconfigure if there should be no zones or nameservers to make + // sure we clear any leftover state when starting up. + if len(cfg.dnsAddrs) == 0 || len(cfg.dnsZones) == 0 { return true } // Otherwise, reconfigure if anything has changed. - return !utils.ContainSameUniqueElements(cfg.dnsZones, state.configuredDNSZones) + return !utils.ContainSameUniqueElements(cfg.dnsZones, state.configuredDNSZones) || + !utils.ContainSameUniqueElements(cfg.dnsAddrs, state.configuredDNSAddrs) } -func configureDNS(ctx context.Context, zones []string, nameserver string) (err error) { - if len(nameserver) == 0 && len(zones) > 0 { - return trace.BadParameter("empty nameserver with non-empty zones") +func configureDNS(ctx context.Context, zones, nameservers []string) (err error) { + if len(nameservers) == 0 { + // Can't handle any zones if there are no nameservers. + zones = nil } - log.InfoContext(ctx, "Configuring DNS.", "nameserver", nameserver, "zones", zones) + log.InfoContext(ctx, "Configuring DNS.", "zones", zones, "nameservers", nameservers) // Split DNS is configured via the Name Resolution Policy Table in the // Windows registry. The UUID at the end was randomly generated, we'll @@ -182,15 +186,24 @@ func configureDNS(ctx context.Context, zones []string, nameserver string) (err e err = trace.NewAggregate(origErr, deleteErr, closeErr) }() + // The NRPT version must be 1. if err := dnsKey.SetDWordValue("Version", 1); err != nil { return trace.Wrap(err, "failed to set Version in DNS registry key") } + // Name is a list of strings holding the DNS suffixes to match. + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/c1f8a4c0-d4e0-49b2-b4ef-87031be16662 if err := dnsKey.SetStringsValue("Name", normalizeDNSZones(zones)); err != nil { return trace.Wrap(err, "failed to set Name in DNS registry key") } - if err := dnsKey.SetStringValue("GenericDNSServers", nameserver); err != nil { + // GenericDNSServers is a string value holding a semicolon-delimited list of + // IP addresses of DNS nameservers. + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/06088ca3-4cf1-48fa-8837-ca8d853ee1e8 + if err := dnsKey.SetStringValue("GenericDNSServers", strings.Join(nameservers, ";")); err != nil { return trace.Wrap(err, "failed to set GenericDNSServers in DNS registry key") } + // Setting ConfigOptions to 8 tells NRPT that only GenericDNSServers is + // specified (DNSSEC, DirectAccess, and IDN options are not set). + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/2d34f260-1e9e-4a52-ac91-2056dfd29702 if err := dnsKey.SetDWordValue("ConfigOptions", 8); err != nil { return trace.Wrap(err, "failed to set ConfigOptions in DNS registry key") } diff --git a/lib/vnet/unsupported_os.go b/lib/vnet/unsupported_os.go index b21576076ff8a..a003fa6c4f4c3 100644 --- a/lib/vnet/unsupported_os.go +++ b/lib/vnet/unsupported_os.go @@ -44,4 +44,5 @@ var ( _ = (*osConfigurator).runOSConfigurationLoop _ = runCommand _ = newNetworkStackConfig + _ = (*networkStack).addDNSAddress ) From 11e8bc244e61c8e8df9f33c7f29d764c870bb8fc Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Tue, 24 Jun 2025 13:21:15 -0700 Subject: [PATCH 17/25] [v18][vnet] fix: close proxied channel only after data and requests are complete Backport #56020 to branch/v18 --- lib/vnet/ssh_proxy.go | 154 +++++++++++++++++++++++++++---------- lib/vnet/ssh_proxy_test.go | 104 +++++++++++++++++++++---- 2 files changed, 200 insertions(+), 58 deletions(-) diff --git a/lib/vnet/ssh_proxy.go b/lib/vnet/ssh_proxy.go index 9dd2c2a0235e6..df82175c25fd7 100644 --- a/lib/vnet/ssh_proxy.go +++ b/lib/vnet/ssh_proxy.go @@ -19,13 +19,12 @@ package vnet import ( "context" "errors" + "io" "log/slog" "sync" "github.com/gravitational/trace" "golang.org/x/crypto/ssh" - - "github.com/gravitational/teleport/lib/utils" ) // sshConn represents an established SSH client or server connection. @@ -171,60 +170,134 @@ func proxyChannel( return } - // Copy channel requests in both directions concurrently. If either fails or - // exits it will cancel the context so that utils.ProxyConn below will close - // both channels so the other goroutine can also exit. + // Copy channel data and requests from the incoming channel to the target + // channel, and vice-versa. + target := newSSHChan(targetChan, targetChanRequests, slog.With("direction", "client->target")) + incoming := newSSHChan(incomingChan, incomingChanRequests, slog.With("direction", "target->client")) + var wg sync.WaitGroup wg.Add(2) - ctx, cancel := context.WithCancel(ctx) go func() { - proxyChannelRequests(ctx, log, targetChan, incomingChanRequests, cancel) - cancel() + target.writeFrom(ctx, incoming) wg.Done() }() go func() { - proxyChannelRequests(ctx, log, incomingChan, targetChanRequests, cancel) - cancel() + incoming.writeFrom(ctx, target) wg.Done() }() + wg.Wait() +} - // ProxyConn copies channel data bidirectionally. If the context is - // canceled it will terminate, it always closes both channels before - // returning. - if err := utils.ProxyConn(ctx, incomingChan, targetChan); err != nil && - !utils.IsOKNetworkError(err) && !errors.Is(err, context.Canceled) { - log.DebugContext(ctx, "Unexpected error proxying channel data", "error", err) +// sshChan manages all writes to an SSH channel and handles closing the channel +// once no more data or requests will be written to it. +type sshChan struct { + ch ssh.Channel + requests <-chan *ssh.Request + log *slog.Logger +} + +func newSSHChan(ch ssh.Channel, requests <-chan *ssh.Request, log *slog.Logger) *sshChan { + return &sshChan{ + ch: ch, + requests: requests, + log: log, } +} + +// writeFrom writes channel data and requests from the source to this SSH channel. +// +// In the happy path it waits for: +// - channel data reads from source to return EOF +// - the source request channel to be closed +// and then closes this channel. +// +// Channel data reads from source can return EOF at any time if it has sent +// SSH_MSG_CHANNEL_EOF but it is still valid to send more channel requests +// after this. +// +// If an unrecoverable error is encountered it immediately closes both +// channels. +func (c *sshChan) writeFrom(ctx context.Context, source *sshChan) { + // Close the channel after all data and request writes are complete. + defer c.ch.Close() - // Wait for all goroutines to terminate. + var wg sync.WaitGroup + wg.Add(2) + go func() { + c.writeDataFrom(ctx, source) + wg.Done() + }() + go func() { + c.writeRequestsFrom(ctx, source) + wg.Done() + }() wg.Wait() } -func proxyChannelRequests( - ctx context.Context, - log *slog.Logger, - targetChan ssh.Channel, - reqs <-chan *ssh.Request, - closeChannels func(), -) { - log = log.With("request_layer", "channel") +// writeDataFrom writes channel data from source to this SSH channel. +// It handles standard channel data and extended channel data of type stderr. +func (c *sshChan) writeDataFrom(ctx context.Context, source *sshChan) { + // Close the channel for writes only after both the standard and stderr + // streams are finished writing. + defer c.ch.CloseWrite() + + errors := make(chan error, 2) + go func() { + _, err := io.Copy(c.ch, source.ch) + errors <- err + }() + go func() { + _, err := io.Copy(c.ch.Stderr(), source.ch.Stderr()) + errors <- err + }() + + // Read both errors to make sure both goroutines terminate, but only do + // anything on the first non-nil error, the second error is likely either + // the same as the first one or caused by closing the channel. + handledError := false + for range 2 { + err := <-errors + if err != nil && !handledError { + handledError = true + // Failed to write channel data from source to this channel. This was + // not an EOF from source or io.Copy would have returned nil. The + // stream might be missing data so close both channels. + // + // This should also unblock the stderr stream if the regular stream + // returned an error, and vice-versa. + c.log.ErrorContext(ctx, "Fatal error proxying SSH channel data", "error", err) + c.ch.Close() + source.ch.Close() + } + } +} + +// writeRequestsFrom forwards channel requests from source to this SSH channel. +func (c *sshChan) writeRequestsFrom(ctx context.Context, source *sshChan) { + log := c.log.With("request_layer", "channel") sendRequest := func(name string, wantReply bool, payload []byte) (bool, []byte, error) { - ok, err := targetChan.SendRequest(name, wantReply, payload) + ok, err := c.ch.SendRequest(name, wantReply, payload) // Replies to channel requests never have a payload. return ok, nil, err } - proxyRequests(ctx, log, sendRequest, reqs, closeChannels) + // Must forcibly close both channels if there was a fatal error proxying + // channel requests so that we don't continue in a bad state. + onFatalError := func() { + c.ch.Close() + source.ch.Close() + } + proxyRequests(ctx, log, sendRequest, source.requests, onFatalError) } func proxyGlobalRequests( ctx context.Context, targetConn ssh.Conn, reqs <-chan *ssh.Request, - closeConnections func(), + onFatalError func(), ) { log := log.With("request_layer", "global") sendRequest := targetConn.SendRequest - proxyRequests(ctx, log, sendRequest, reqs, closeConnections) + proxyRequests(ctx, log, sendRequest, reqs, onFatalError) } func proxyRequests( @@ -232,7 +305,7 @@ func proxyRequests( log *slog.Logger, sendRequest func(name string, wantReply bool, payload []byte) (bool, []byte, error), reqs <-chan *ssh.Request, - closeRequestSources func(), + onFatalError func(), ) { for req := range reqs { log := log.With("request_type", req.Type) @@ -240,23 +313,20 @@ func proxyRequests( ok, reply, err := sendRequest(req.Type, req.WantReply, req.Payload) if err != nil { // We failed to send the request, the target must be dead. - log.DebugContext(ctx, "Failed to forward SSH request", "request_type", req.Type, "error", err) - // Close both connections or channels to clean up but we must - // continue handling requests on the chan until it is closed by - // crypto/ssh. - closeRequestSources() - _ = req.Reply(false, nil) - continue + log.DebugContext(ctx, "Failed to forward SSH request", "error", err) + onFatalError() + req.Reply(false, nil) + ssh.DiscardRequests(reqs) + return } if err := req.Reply(ok, reply); err != nil { // A reply was expected and returned by the target but we failed to // forward it back, the connection that initiated the request must // be dead. - log.DebugContext(ctx, "Failed to reply to SSH request", "request_type", req.Type, "error", err) - // Close both connections or channels to clean up but we must - // continue handling requests on the chan until it is closed by - // crypto/ssh. - closeRequestSources() + log.DebugContext(ctx, "Failed to reply to SSH request", "error", err) + onFatalError() + ssh.DiscardRequests(reqs) + return } } } diff --git a/lib/vnet/ssh_proxy_test.go b/lib/vnet/ssh_proxy_test.go index 4c9b0e026ab80..d7eb8313c8a11 100644 --- a/lib/vnet/ssh_proxy_test.go +++ b/lib/vnet/ssh_proxy_test.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "net" + "sync" "testing" "github.com/gravitational/trace" @@ -108,11 +109,17 @@ func testSSHConnection(t *testing.T, dial dialer) { } func testConnectionToSshEchoServer(t *testing.T, sshConn ssh.Conn, chans <-chan ssh.NewChannel, reqs <-chan *ssh.Request) { - go ssh.DiscardRequests(reqs) + requestStreamEnded := make(chan struct{}) + go func() { + ssh.DiscardRequests(reqs) + close(requestStreamEnded) + }() + chanStreamEnded := make(chan struct{}) go func() { for newChan := range chans { newChan.Reject(ssh.Prohibited, "test") } + close(chanStreamEnded) }() // Try sending some global requests. @@ -136,6 +143,26 @@ func testConnectionToSshEchoServer(t *testing.T, sshConn ssh.Conn, chans <-chan t.Run("echo channel 2", func(t *testing.T) { testEchoChannel(t, sshConn) }) + + t.Run("closing", func(t *testing.T) { + // Send a request that causes the target server to close the connection + // immediately and make sure channel reads are unblocked, and the global + // request and channel request streams end. + ch, reqs, err := sshConn.OpenChannel("echo", nil) + require.NoError(t, err) + go ssh.DiscardRequests(reqs) + readErr := make(chan error) + go func() { + var b [1]byte + _, err := ch.Read(b[:]) + readErr <- err + }() + _, _, err = sshConn.SendRequest("close", false, nil) + require.NoError(t, err) + require.ErrorIs(t, <-readErr, io.EOF) + <-requestStreamEnded + <-chanStreamEnded + }) } func testGlobalRequests(t *testing.T, conn ssh.Conn) { @@ -156,7 +183,11 @@ func testGlobalRequests(t *testing.T, conn ssh.Conn) { func testEchoChannel(t *testing.T, conn ssh.Conn) { ch, reqs, err := conn.OpenChannel("echo", nil) require.NoError(t, err) - go ssh.DiscardRequests(reqs) + requestStreamEnded := make(chan struct{}) + go func() { + ssh.DiscardRequests(reqs) + close(requestStreamEnded) + }() defer ch.Close() // Try sending a message over the SSH channel and asserting that it is @@ -170,16 +201,43 @@ func testEchoChannel(t *testing.T, conn ssh.Conn) { require.Equal(t, len(msg), n) require.Equal(t, msg, buf[:n]) + // Try sending a message over stderr and asserting that it is echoed back. + _, err = ch.Stderr().Write(msg) + require.NoError(t, err) + n, err = ch.Stderr().Read(buf[:]) + require.NoError(t, err) + require.Equal(t, len(msg), n) + require.Equal(t, msg, buf[:n]) + // Try sending a channel request that expects a reply. reply, err := ch.SendRequest("echo", true, nil) require.NoError(t, err) require.True(t, reply) + // Close the channel for writes of in-band data and send another channel + // request, which should succeed. + require.NoError(t, ch.CloseWrite()) + reply, err = ch.SendRequest("echo", true, nil) + require.NoError(t, err) + require.True(t, reply) + // The test server replies false to channel requests with type other than // "echo". reply, err = ch.SendRequest("unknown", true, nil) require.NoError(t, err) require.False(t, reply) + + // Send a channel request that causes the server to close the channel and + // make sure channel reads get unblocked and the incoming request stream ends. + readErr := make(chan error) + go func() { + _, err := ch.Read(buf[:]) + readErr <- err + }() + _, err = ch.SendRequest("close", false, nil) + require.NoError(t, err) + require.ErrorIs(t, <-readErr, io.EOF) + <-requestStreamEnded } type dialer interface { @@ -282,7 +340,7 @@ func runTestSSHServerInstance(tcpConn net.Conn, cfg *ssh.ServerConfig) error { return trace.Wrap(err) } go func() { - handleEchoRequests(reqs) + handleSSHRequests(reqs, sshConn.Close) sshConn.Close() }() handleEchoChannels(chans) @@ -290,17 +348,6 @@ func runTestSSHServerInstance(tcpConn net.Conn, cfg *ssh.ServerConfig) error { return nil } -func handleEchoRequests(reqs <-chan *ssh.Request) { - for req := range reqs { - switch req.Type { - case "echo": - req.Reply(true, req.Payload) - default: - req.Reply(false, nil) - } - } -} - func handleEchoChannels(chans <-chan ssh.NewChannel) { for newChan := range chans { switch newChan.ChannelType() { @@ -317,8 +364,33 @@ func handleEchoChannel(newChan ssh.NewChannel) { if err != nil { return } - go handleEchoRequests(reqs) - io.Copy(ch, ch) + go handleSSHRequests(reqs, ch.Close) + defer ch.CloseWrite() + var wg sync.WaitGroup + wg.Add(2) + go func() { + io.Copy(ch, ch) + wg.Done() + }() + go func() { + io.Copy(ch.Stderr(), ch.Stderr()) + wg.Done() + }() + wg.Wait() +} + +func handleSSHRequests(reqs <-chan *ssh.Request, closeSource func() error) { + defer closeSource() + for req := range reqs { + switch req.Type { + case "echo": + req.Reply(true, req.Payload) + case "close": + closeSource() + default: + req.Reply(false, nil) + } + } } func sshServerConfig(t *testing.T) *ssh.ServerConfig { From fffa153f03afbf09b41b31b25e4ed17c5bc9d3c1 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Wed, 25 Jun 2025 12:53:42 -0700 Subject: [PATCH 18/25] [v18][vnet] feat: automatic SSH client configuration Backport #55923 to branch/v18 --- lib/teleterm/vnet/service.go | 14 +- lib/vnet/diag/ssh.go | 152 ++++++++------ lib/vnet/diag/ssh_test.go | 349 +++++++++++++++++---------------- lib/vnet/opensshconfig.go | 79 ++++++++ lib/vnet/opensshconfig_test.go | 85 ++++++++ tool/tsh/common/tsh.go | 3 + tool/tsh/common/vnet.go | 18 ++ 7 files changed, 464 insertions(+), 236 deletions(-) diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index c3b17dd6f9a53..e92f981187ff6 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -238,18 +238,14 @@ func (s *Service) GetServiceInfo(ctx context.Context, _ *api.GetServiceInfoReque return nil, trace.Wrap(err) } - sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ - ProfilePath: s.cfg.profilePath, - }) + sshConfigChecker, err := diag.NewSSHConfigChecker(s.cfg.profilePath) if err != nil { - return nil, trace.Wrap(err, "building SSH diagnostic") + return nil, trace.Wrap(err, "building SSH config checker") } - sshReport, err := sshDiag.Run(ctx) - if err != nil { - return nil, trace.Wrap(err, "running SSH diagnostic") + _, sshConfigured, err := sshConfigChecker.OpenSSHConfigIncludesVNetSSHConfig() + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err, "checking SSH configuration") } - sshConfigured := sshReport.Status == diagv1.CheckReportStatus_CHECK_REPORT_STATUS_OK && - sshReport.GetSshConfigurationReport().UserOpensshConfigIncludesVnetSshConfig return &api.GetServiceInfoResponse{ AppDnsZones: unifiedClusterConfig.AppDNSZones(), diff --git a/lib/vnet/diag/ssh.go b/lib/vnet/diag/ssh.go index 27be9b1afd741..c0708aa650b2e 100644 --- a/lib/vnet/diag/ssh.go +++ b/lib/vnet/diag/ssh.go @@ -31,6 +31,7 @@ import ( "github.com/dustin/go-humanize" "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils/keypaths" diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" @@ -50,27 +51,19 @@ type SSHConfig struct { // SSHDiag is a diagnostic check that inspects whether the default user OpenSSH // config file includes VNet's generated SSH config file. type SSHDiag struct { - cfg *SSHConfig - userHome string - userOpenSSHConfigPath string - vnetSSHConfigPath string - isWindows bool + cfg *SSHConfig + sshConfigChecker *SSHConfigChecker } // NewSSHDiag returns a new [SSHDiag]. func NewSSHDiag(cfg *SSHConfig) (*SSHDiag, error) { - userHome, ok := profile.UserHomeDir() - if !ok { - return nil, trace.Errorf("unable to find user's home directory") + sshConfigChecker, err := NewSSHConfigChecker(cfg.ProfilePath) + if err != nil { + return nil, trace.Wrap(err) } - userOpenSSHConfigPath := filepath.Join(userHome, ".ssh", "config") - vnetSSHConfigPath := filepath.Join(cfg.ProfilePath, keypaths.VNetSSHConfig) return &SSHDiag{ - cfg: cfg, - userHome: userHome, - userOpenSSHConfigPath: userOpenSSHConfigPath, - vnetSSHConfigPath: vnetSSHConfigPath, - isWindows: runtime.GOOS == "windows", + cfg: cfg, + sshConfigChecker: sshConfigChecker, }, nil } @@ -103,50 +96,84 @@ func (d *SSHDiag) Run(ctx context.Context) (*diagv1.CheckReport, error) { } func (d *SSHDiag) run(ctx context.Context) (*diagv1.SSHConfigurationReport, error) { - _, err := os.Stat(d.userOpenSSHConfigPath) - userOpenSSHConfigExists := err == nil - if !userOpenSSHConfigExists { - return &diagv1.SSHConfigurationReport{ - UserOpensshConfigPath: d.userOpenSSHConfigPath, - VnetSshConfigPath: d.vnetSSHConfigPath, - }, nil + userOpenSSHConfigContents, included, err := d.sshConfigChecker.OpenSSHConfigIncludesVNetSSHConfig() + if err != nil { + if trace.IsNotFound(err) { + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.sshConfigChecker.UserOpenSSHConfigPath, + VnetSshConfigPath: d.sshConfigChecker.VNetSSHConfigPath, + }, nil + } + return nil, trace.Wrap(err) + } + if !utf8.Valid(userOpenSSHConfigContents) { + return nil, trace.Errorf("%s is not valid UTF-8", d.sshConfigChecker.UserOpenSSHConfigPath) } + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.sshConfigChecker.UserOpenSSHConfigPath, + VnetSshConfigPath: d.sshConfigChecker.VNetSSHConfigPath, + UserOpensshConfigIncludesVnetSshConfig: included, + UserOpensshConfigExists: true, + UserOpensshConfigContents: string(userOpenSSHConfigContents), + }, nil +} - userOpenSSHConfigFile, err := os.Open(d.userOpenSSHConfigPath) +// SSHConfigChecker checks the state of the user's SSH configuration. +type SSHConfigChecker struct { + userHome string + UserOpenSSHConfigPath string + VNetSSHConfigPath string + isWindows bool +} + +// NewSSHConfigChecker returns a new SSHConfigChecker. +func NewSSHConfigChecker(profilePath string) (*SSHConfigChecker, error) { + userHome, ok := profile.UserHomeDir() + if !ok { + return nil, trace.Errorf("unable to find user's home directory") + } + userOpenSSHConfigPath := filepath.Join(userHome, ".ssh", "config") + vnetSSHConfigPath := keypaths.VNetSSHConfigPath(profilePath) + return &SSHConfigChecker{ + userHome: userHome, + UserOpenSSHConfigPath: userOpenSSHConfigPath, + VNetSSHConfigPath: vnetSSHConfigPath, + isWindows: runtime.GOOS == constants.WindowsOS, + }, nil +} + +// OpenSSHConfigIncludesVNetSSHConfig returns the current user OpenSSH +// configuration file contents (~/.ssh/config) and a boolean indicating whether +// it already includes VNet's generated OpenSSH-compatible configuration file. +// +// If ~/.ssh/config does not exist it returns a [trace.NotFoundError] +func (c *SSHConfigChecker) OpenSSHConfigIncludesVNetSSHConfig() ([]byte, bool, error) { + userOpenSSHConfigFile, err := os.Open(c.UserOpenSSHConfigPath) if err != nil { - return nil, trace.Wrap(trace.ConvertSystemError(err), "opening %s for reading", d.userOpenSSHConfigPath) + return nil, false, trace.Wrap(trace.ConvertSystemError(err), "opening %s for reading", c.UserOpenSSHConfigPath) } defer userOpenSSHConfigFile.Close() userOpenSSHConfigContents, err := io.ReadAll(io.LimitReader(userOpenSSHConfigFile, maxOpenSSHConfigFileSize)) if err != nil { - return nil, trace.Wrap(trace.ConvertSystemError(err), "reading %s", d.userOpenSSHConfigPath) + return nil, false, trace.Wrap(trace.ConvertSystemError(err), "reading %s", c.UserOpenSSHConfigPath) } if len(userOpenSSHConfigContents) == maxOpenSSHConfigFileSize { - return nil, trace.Errorf("%s is too large to (max size %s)", - d.userOpenSSHConfigPath, humanize.Bytes(maxOpenSSHConfigFileSize)) - } - if !utf8.Valid(userOpenSSHConfigContents) { - return nil, trace.Errorf("%s is not valid UTF-8", d.userOpenSSHConfigPath) + return nil, false, trace.Errorf("%s is too large to read (max size %s)", + c.UserOpenSSHConfigPath, humanize.Bytes(maxOpenSSHConfigFileSize)) } - included, err := d.openSSHConfigIncludesVNetSSHConfig(bytes.NewReader(userOpenSSHConfigContents)) + included, err := c.openSSHConfigIncludesVNetSSHConfig(bytes.NewReader(userOpenSSHConfigContents)) if err != nil { - return nil, trace.Wrap(err, "checking if the default user OpenSSH config includes VNet's SSH configuration") + return nil, false, trace.Wrap(err, "checking if the default user OpenSSH config includes VNet's SSH configuration") } - return &diagv1.SSHConfigurationReport{ - UserOpensshConfigPath: d.userOpenSSHConfigPath, - VnetSshConfigPath: d.vnetSSHConfigPath, - UserOpensshConfigIncludesVnetSshConfig: included, - UserOpensshConfigExists: true, - UserOpensshConfigContents: string(userOpenSSHConfigContents), - }, nil + return userOpenSSHConfigContents, included, nil } -func (d *SSHDiag) openSSHConfigIncludesVNetSSHConfig(r io.Reader) (bool, error) { +func (c *SSHConfigChecker) openSSHConfigIncludesVNetSSHConfig(r io.Reader) (bool, error) { scanner := bufio.NewScanner(r) for scanner.Scan() { - if d.openSSHConfigLineIncludesPath(scanner.Text(), d.vnetSSHConfigPath) { + if c.openSSHConfigLineIncludesPath(scanner.Text(), c.VNetSSHConfigPath) { return true, nil } } @@ -155,8 +182,8 @@ func (d *SSHDiag) openSSHConfigIncludesVNetSSHConfig(r io.Reader) (bool, error) // openSSHConfigLineIncludesPath returns true if the given line of an OpenSSH // configuration file is an include statement for the given path. -func (d *SSHDiag) openSSHConfigLineIncludesPath(line, wantPath string) bool { - wantPath = d.normalizePath(wantPath) +func (c *SSHConfigChecker) openSSHConfigLineIncludesPath(line, wantPath string) bool { + wantPath = c.normalizePath(wantPath) line = strings.TrimSpace(line) // Only consider lines that begin with "include" (case-insensitive). @@ -178,42 +205,41 @@ func (d *SSHDiag) openSSHConfigLineIncludesPath(line, wantPath string) bool { // returns true. It does support ~ as an alias for the user's home // directory. var ( - // b is a running buffer holding the current argument as parsed up to + // pathBuf is a running buffer holding the current argument as parsed up to // the current point. - b strings.Builder + pathBuf strings.Builder // quote holds the opening quote character if one has been found. quote = byte(0) ) loop: for i := 0; i < len(line); i++ { - c := line[i] + b := line[i] switch { - case c == '\\' && i < len(line)-1 && canBeEscaped(line[i+1]): + case b == '\\' && i < len(line)-1 && canBeEscaped(line[i+1]): // Skip the escape char and write the next char literally. i++ - b.WriteByte(line[i]) - case quote == 0 && (c == '"' || c == '\''): + pathBuf.WriteByte(line[i]) + case quote == 0 && (b == '"' || b == '\''): // Start of quote - quote = c - case quote != 0 && c == quote: + quote = b + case quote != 0 && b == quote: // End of quote quote = 0 - case b.Len() == 0 && c == '~': + case pathBuf.Len() == 0 && b == '~': // Support ~ as an alias for the user's home directory. - b.WriteString(d.userHome) - case quote == 0 && c == '#': + pathBuf.WriteString(c.userHome) + case quote == 0 && b == '#': // Found an unquoted comment in the middle of the line, ignore the rest. break loop - case quote == 0 && isSpace(rune(c)): + case quote == 0 && isSpace(rune(b)): // Reached the end of this argument, check if it matches wantPath. - if d.normalizePath(b.String()) == wantPath { + if c.normalizePath(pathBuf.String()) == wantPath { return true } - b.Reset() + pathBuf.Reset() default: - // By default just append the current character to the current - // argument. - b.WriteByte(c) + // By default just append the current byte to the path. + pathBuf.WriteByte(b) } } if quote != 0 { @@ -221,11 +247,11 @@ loop: return false } // Handle an argument that ends at the end of the line. - return d.normalizePath(b.String()) == wantPath + return c.normalizePath(pathBuf.String()) == wantPath } -func (d *SSHDiag) normalizePath(path string) string { - if d.isWindows { +func (c *SSHConfigChecker) normalizePath(path string) string { + if c.isWindows { // Normalize all paths to use unix-style separators since OpenSSH // supports / or \\ on Windows. path = strings.ReplaceAll(path, `\`, `/`) diff --git a/lib/vnet/diag/ssh_test.go b/lib/vnet/diag/ssh_test.go index 2a1549a49c172..a152f38c1ac90 100644 --- a/lib/vnet/diag/ssh_test.go +++ b/lib/vnet/diag/ssh_test.go @@ -19,6 +19,7 @@ package diag import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -27,171 +28,173 @@ import ( diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" ) -// TestSSHDiag tests the SSH configuration diagnostic, specifically its ability -// to check whether an OpenSSH config file includes the VNet SSH config file. -func TestSSHDiag(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - desc string - profilePath string - userHome string - isWindows bool - input string - expect bool - }{ - { - desc: "empty", - profilePath: `/Users/user/.tsh`, - userHome: `/Users/user`, - }, - { - desc: "macos tsh", - profilePath: `/Users/user/.tsh`, - userHome: `/Users/user`, - input: `Include /Users/user/.tsh/vnet_ssh_config`, - expect: true, - }, - { - desc: "macos tsh ~", - profilePath: `/Users/user/.tsh`, - userHome: `/Users/user`, - input: `Include ~/.tsh/vnet_ssh_config`, - expect: true, - }, - { - desc: "macos connect", - profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, - userHome: `/Users/user`, - input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, - expect: true, - }, - { - desc: "macos connect ~", - profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, - userHome: `/Users/user`, - input: `Include "~/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, - expect: true, - }, - { - desc: "macos tsh not match connect", - profilePath: `/Users/user/.tsh`, - userHome: `/Users/user`, - input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, - }, - { - desc: "macos connect not match tsh", - profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, - userHome: `/Users/user`, - input: `Include /Users/user/.tsh/vnet_ssh_config`, - }, - { - desc: "windows tsh", - profilePath: `C:\Users\User\.tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows tsh unescaped", - profilePath: `C:\Users\User\.tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:\Users\User\.tsh\vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows tsh unix path", - profilePath: `C:\Users\User\.tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:/Users/User/.tsh/vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows tsh ~", - profilePath: `C:\Users\User\.tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "~\\.tsh\\vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows connect", - profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows connect unescaped", - profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:\Users\User\AppData\Roaming\Teleport Connect\tsh\vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows connect unix path", - profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:/Users/User/AppData/Roaming/Teleport\ Connect/tsh/vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows connect ~", - profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "~\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, - expect: true, - }, - { - desc: "windows tsh not match connect", - profilePath: `C:\Users\User\.tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, - }, - { - desc: "windows connect not match tsh", - profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, - userHome: `C:\Users\User`, - isWindows: true, - input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, - }, - { - desc: "some other file", - profilePath: `/Users/user/.tsh`, - input: `Include /Users/user/.tsh/ssh_config`, - }, - { - desc: "multiple includes", - profilePath: `/Users/user/.tsh`, - userHome: `/Users/user`, - input: ` +var sshDiagTestCases = []struct { + desc string + profilePath string + userHome string + isWindows bool + input string + expect bool +}{ + { + desc: "empty", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + }, + { + desc: "macos tsh", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include /Users/user/.tsh/vnet_ssh_config`, + expect: true, + }, + { + desc: "macos tsh ~", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include ~/.tsh/vnet_ssh_config`, + expect: true, + }, + { + desc: "macos connect", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "macos connect ~", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include "~/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "macos tsh not match connect", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + }, + { + desc: "macos connect not match tsh", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include /Users/user/.tsh/vnet_ssh_config`, + }, + { + desc: "windows tsh", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh unescaped", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\Users\User\.tsh\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh unix path", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:/Users/User/.tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh ~", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "~\\.tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect unescaped", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\Users\User\AppData\Roaming\Teleport Connect\tsh\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect unix path", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:/Users/User/AppData/Roaming/Teleport\ Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect ~", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "~\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh not match connect", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + }, + { + desc: "windows connect not match tsh", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, + }, + { + desc: "some other file", + profilePath: `/Users/user/.tsh`, + input: `Include /Users/user/.tsh/ssh_config`, + }, + { + desc: "multiple includes", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: ` Include ~/.ssh/include/* Include /Users/user/ssh_config Include /Users/user/.tsh/vnet_ssh_config `, - expect: true, - }, - { - desc: "commented", - profilePath: `/Users/user/.tsh`, - userHome: `/Users/user`, - input: `Include #/Users/user/.tsh/vnet_ssh_config`, - }, - { - desc: "single quotes", - profilePath: `/Users/user/.tsh`, - userHome: `/Users/user`, - input: `Include '/Users/user/.tsh/vnet_ssh_config'`, - expect: true, - }, - } { + expect: true, + }, + { + desc: "commented", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include #/Users/user/.tsh/vnet_ssh_config`, + }, + { + desc: "single quotes", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include '/Users/user/.tsh/vnet_ssh_config'`, + expect: true, + }, +} + +// TestSSHDiag tests the SSH configuration diagnostic, specifically its ability +// to check whether an OpenSSH config file includes the VNet SSH config file. +func TestSSHDiag(t *testing.T) { + t.Parallel() + for _, tc := range sshDiagTestCases { t.Run(tc.desc, func(t *testing.T) { diag, err := NewSSHDiag(&SSHConfig{ ProfilePath: tc.profilePath, @@ -200,9 +203,9 @@ Include /Users/user/.tsh/vnet_ssh_config userOpenSSHConfigPath := filepath.Join(t.TempDir(), "test_ssh_config") // Override isWindows and paths for the purpose of the test. - diag.isWindows = tc.isWindows - diag.userHome = tc.userHome - diag.userOpenSSHConfigPath = userOpenSSHConfigPath + diag.sshConfigChecker.isWindows = tc.isWindows + diag.sshConfigChecker.userHome = tc.userHome + diag.sshConfigChecker.UserOpenSSHConfigPath = userOpenSSHConfigPath if len(tc.input) > 0 { require.NoError(t, os.WriteFile(userOpenSSHConfigPath, []byte(tc.input), 0o600)) @@ -227,3 +230,21 @@ Include /Users/user/.tsh/vnet_ssh_config }) } } + +// FuzzOpenSSHConfigIncludesPath fuzzes [SSHConfigChecker.openSSHConfigIncludesVNetSSHConfig] +// to make sure it won't panic on arbitrary input. +func FuzzOpenSSHConfigIncludesPath(f *testing.F) { + // Add all test cases as the base test corpus. + for _, tc := range sshDiagTestCases { + f.Add(tc.isWindows, tc.profilePath, tc.input) + } + f.Fuzz(func(t *testing.T, isWindows bool, profilePath, input string) { + vnetSSHConfigPath := keypaths.VNetSSHConfigPath(profilePath) + sshConfigChecker := &SSHConfigChecker{ + VNetSSHConfigPath: vnetSSHConfigPath, + isWindows: isWindows, + } + // Can't deterministically check the result for fuzzed inputs but it shouldn't panic. + sshConfigChecker.openSSHConfigIncludesVNetSSHConfig(strings.NewReader(input)) + }) +} diff --git a/lib/vnet/opensshconfig.go b/lib/vnet/opensshconfig.go index 65f2587f63796..e1ff49cb17222 100644 --- a/lib/vnet/opensshconfig.go +++ b/lib/vnet/opensshconfig.go @@ -21,6 +21,7 @@ import ( "cmp" "context" "encoding/pem" + "fmt" "io" "os" "path/filepath" @@ -40,6 +41,8 @@ import ( "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/lib/cryptosuites" + libutils "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/vnet/diag" ) const ( @@ -261,3 +264,79 @@ type configFileTemplateInput struct { PrivateKeyPath string KnownHostsPath string } + +type autoConfigureOpenSSHOptions struct { + overrideUserSSHConfigPath string +} +type autoConfigureOpenSSHOption func(*autoConfigureOpenSSHOptions) + +func withUserSSHConfigPathOverride(path string) autoConfigureOpenSSHOption { + return func(opts *autoConfigureOpenSSHOptions) { + opts.overrideUserSSHConfigPath = path + } +} + +// AutoConfigureOpenSSH adds an Include directive to the default user OpenSSH +// config file (~/.ssh/config) to include the vnet_ssh_config file found under +// profilePath. +func AutoConfigureOpenSSH(ctx context.Context, profilePath string, opts ...autoConfigureOpenSSHOption) (err error) { + var options autoConfigureOpenSSHOptions + for _, opt := range opts { + opt(&options) + } + + sshConfigChecker, err := diag.NewSSHConfigChecker(profilePath) + if err != nil { + return trace.Wrap(err) + } + + if options.overrideUserSSHConfigPath != "" { + sshConfigChecker.UserOpenSSHConfigPath = options.overrideUserSSHConfigPath + } + + // Create ~/.ssh if it does not exist yet. + err = trace.ConvertSystemError(os.Mkdir( + filepath.Dir(sshConfigChecker.UserOpenSSHConfigPath), os.FileMode(0o700))) + switch { + case trace.IsAlreadyExists(err): + // This is fine/expected. + case err != nil: + return trace.Wrap(err, "creating directory for %s", sshConfigChecker.UserOpenSSHConfigPath) + } + + // There should not be much lock contention on this file and it's okay if + // this fails so just try once to grab the lock. + unlock, err := libutils.FSTryWriteLock(sshConfigChecker.UserOpenSSHConfigPath) + if err != nil { + return trace.Wrap(err, "getting write lock for %s", sshConfigChecker.UserOpenSSHConfigPath) + } + defer func() { + unlockErr := unlock() + err = trace.NewAggregate(err, trace.Wrap(unlockErr, "unlocking %s", sshConfigChecker.UserOpenSSHConfigPath)) + }() + + currentContents, alreadyIncluded, err := sshConfigChecker.OpenSSHConfigIncludesVNetSSHConfig() + switch { + case trace.IsNotFound(err): + // This is fine, the file will be created with a single include. + case err != nil: + return trace.Wrap(err) + case alreadyIncluded: + return trace.AlreadyExists("%s is already included in %s", + sshConfigChecker.VNetSSHConfigPath, sshConfigChecker.UserOpenSSHConfigPath) + } + + // Add the include at the top of the file for 2 reasons: + // - options set first take precedence over options set later in the file + // - if the include line is added after an existing Host block it will only + // be included if the host block matches + var newContents bytes.Buffer + fmt.Fprintf(&newContents, `# Include Teleport VNet generated configuration +Include "%s" + +`, sshConfigChecker.VNetSSHConfigPath) + newContents.Write(currentContents) + + err = renameio.WriteFile(sshConfigChecker.UserOpenSSHConfigPath, newContents.Bytes(), filePerms) + return trace.Wrap(trace.ConvertSystemError(err), "writing to %s", sshConfigChecker.UserOpenSSHConfigPath) +} diff --git a/lib/vnet/opensshconfig_test.go b/lib/vnet/opensshconfig_test.go index 4e933fc3bc915..9bed1ed5b0410 100644 --- a/lib/vnet/opensshconfig_test.go +++ b/lib/vnet/opensshconfig_test.go @@ -20,10 +20,13 @@ import ( "context" "fmt" "os" + "path/filepath" "testing" "time" + "github.com/gravitational/trace" "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/utils/keypaths" @@ -116,3 +119,85 @@ func TestSSHConfigurator(t *testing.T) { _, err = os.Stat(keypaths.VNetSSHConfigPath(homePath)) require.ErrorIs(t, err, os.ErrNotExist) } + +func TestAutoConfigureOpenSSH(t *testing.T) { + d := t.TempDir() + profilePath := filepath.Join(d, ".tsh") + vnetSSHConfigPath := keypaths.VNetSSHConfigPath(profilePath) + userOpenSSHConfigPath := filepath.Join(d, ".ssh", "config") + expectedInclude := fmt.Sprintf(`# Include Teleport VNet generated configuration +Include "%s" + +`, vnetSSHConfigPath) + for _, tc := range []struct { + desc string + userOpenSSHConfigExists bool + userOpenSSHConfigContents string + expectAlreadyIncludedError bool + expectUserOpenSSHConfigContents string + }{ + { + // When the user OpenSSH config file doesn't exist, it should be + // created with the include. + desc: "no file", + expectUserOpenSSHConfigContents: expectedInclude, + }, + { + // When the user OpenSSH config file already exists but it's empty, + // the include should be added. + desc: "empty file", + userOpenSSHConfigExists: true, + expectUserOpenSSHConfigContents: expectedInclude, + }, + { + // When the user OpenSSH config file already exists with some + // content, the include should be added at the top. + desc: "not empty", + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: "something\nsomethingelse\n", + expectUserOpenSSHConfigContents: expectedInclude + "something\nsomethingelse\n", + }, + { + // When the user OpenSSH config file already includes VNet's config + // file, it should return an AlreadyExists error and the file + // should not be modified. + desc: "already included", + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: expectedInclude, + expectAlreadyIncludedError: true, + expectUserOpenSSHConfigContents: expectedInclude, + }, + { + // When the user OpenSSH config file already includes VNet's config + // file along with existing content, it should return an + // AlreadyExists error and the file should not be modified. + desc: "already included with extra content", + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: "something\n" + expectedInclude + "somethingelse", + expectAlreadyIncludedError: true, + expectUserOpenSSHConfigContents: "something\n" + expectedInclude + "somethingelse", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + if tc.userOpenSSHConfigExists { + // Write the existing user OpenSSH config file if it's supposed + // to exist for this test case. + require.NoError(t, os.WriteFile(userOpenSSHConfigPath, + []byte(tc.userOpenSSHConfigContents), filePerms)) + } + + err := AutoConfigureOpenSSH(t.Context(), profilePath, withUserSSHConfigPathOverride(userOpenSSHConfigPath)) + + if tc.expectAlreadyIncludedError { + assert.ErrorIs(t, err, trace.AlreadyExists("%s is already included in %s", + vnetSSHConfigPath, userOpenSSHConfigPath)) + } else { + assert.NoError(t, err) + } + + contents, err := os.ReadFile(userOpenSSHConfigPath) + require.NoError(t, err) + assert.Equal(t, tc.expectUserOpenSSHConfigContents, string(contents)) + }) + } +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 550811255519b..14ceadbd60e89 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -1331,6 +1331,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { workloadIdentityCmd := newWorkloadIdentityCommands(app) vnetCommand := newVnetCommand(app) + vnetSSHAutoConfigCommand := newVnetSSHAutoConfigCommand(app) vnetAdminSetupCommand := newVnetAdminSetupCommand(app) vnetDaemonCommand := newVnetDaemonCommand(app) vnetServiceCommand := newVnetServiceCommand(app) @@ -1723,6 +1724,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { err = workloadIdentityCmd.issueX509.run(&cf) case vnetCommand.FullCommand(): err = vnetCommand.run(&cf) + case vnetSSHAutoConfigCommand.FullCommand(): + err = vnetSSHAutoConfigCommand.run(&cf) case vnetAdminSetupCommand.FullCommand(): err = vnetAdminSetupCommand.run(&cf) case vnetDaemonCommand.FullCommand(): diff --git a/tool/tsh/common/vnet.go b/tool/tsh/common/vnet.go index 5d01ff4e67ae0..256e9cf3a832e 100644 --- a/tool/tsh/common/vnet.go +++ b/tool/tsh/common/vnet.go @@ -23,6 +23,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/lib/vnet" ) @@ -77,6 +78,22 @@ func (c *vnetCommand) run(cf *CLIConf) error { return trace.Wrap(vnetProcess.Wait()) } +type vnetSSHAutoConfigCommand struct { + *kingpin.CmdClause +} + +func newVnetSSHAutoConfigCommand(app *kingpin.Application) *vnetSSHAutoConfigCommand { + cmd := &vnetSSHAutoConfigCommand{ + CmdClause: app.Command("vnet-ssh-autoconfig", "Automatically include VNet's generated OpenSSH-compatible config file in ~/.ssh/config."), + } + return cmd +} + +func (c *vnetSSHAutoConfigCommand) run(cf *CLIConf) error { + err := vnet.AutoConfigureOpenSSH(cf.Context, profile.FullProfilePath(cf.HomePath)) + return trace.Wrap(err) +} + func newVnetAdminSetupCommand(app *kingpin.Application) vnetCLICommand { return newPlatformVnetAdminSetupCommand(app) } @@ -104,6 +121,7 @@ type vnetCommandNotSupported struct{} func (vnetCommandNotSupported) FullCommand() string { return "" } + func (vnetCommandNotSupported) run(*CLIConf) error { panic("vnetCommandNotSupported.run should never be called, this is a bug") } From 5a1abdb948759bdbe911a80dfe3dff39ee255594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Cie=C5=9Blak?= Date: Wed, 25 Jun 2025 18:10:11 +0200 Subject: [PATCH 19/25] VNet diag notification: Do not show button to open report if there's no workspace selected (#56067) * VNet diag report: Don't show button in notification if there's no workspace * Replace deprecated MutableRefObject with RefObject * Make openReport not depend on value of rootClusterUri Otherwise the effect that uses setInterval re-runs whenever the user switches to another workspace. --- .../teleterm/src/ui/Vnet/vnetContext.test.tsx | 45 ++++++++++++++++--- .../teleterm/src/ui/Vnet/vnetContext.tsx | 31 +++++++++---- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx index 3bb407ef92671..e08aecf3a2b21 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx @@ -20,8 +20,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { ComponentType, createRef, - MutableRefObject, PropsWithChildren, + RefObject, useEffect, useImperativeHandle, } from 'react'; @@ -218,16 +218,16 @@ describe('diag notification', () => { const tests: Array<{ it: string; /** Ref for opening/closing the connections panel. If provided, the panel will be open by default. */ - controlConnectionsRef?: MutableRefObject; + controlConnectionsRef?: RefObject; mockAppContext: (appContext: MockAppContext) => void; verify: ( appContext: MockAppContext, result: { current: VnetContext }, - controlConnectionsRef?: MutableRefObject + controlConnectionsRef?: RefObject ) => Promise; }> = [ { - it: 'is shown when the report cycles from issues found to no issues and back to issues found', + it: 'is shown, closed, then shown again when the report cycles from issues found to no issues and back to issues found', mockAppContext: appContext => { jest .spyOn(appContext.vnet, 'runDiagnostics') @@ -253,7 +253,12 @@ describe('diag notification', () => { ); expect(notificationsService.notifyWarning).toHaveBeenCalledTimes(2); - expect(notificationsService.getNotifications()).toHaveLength(1); + const notifications = notificationsService.getNotifications(); + expect(notifications).toHaveLength(1); + expect(notifications[0].content).toMatchObject({ + description: undefined, + action: expect.objectContaining({ content: 'Open Diag Report' }), + }); }, }, { @@ -407,6 +412,32 @@ describe('diag notification', () => { expect(notificationsService.getNotifications()).toHaveLength(1); }, }, + { + it: 'does not show a button to open the diag report if there is no workspace', + mockAppContext: appContext => { + jest + .spyOn(appContext.vnet, 'runDiagnostics') + .mockResolvedValue( + new MockedUnaryCall({ report: issuesFoundReport }) + ); + appContext.workspacesService.setState(draft => { + draft.rootClusterUri = undefined; + }); + }, + verify: async ({ notificationsService }, result) => { + await waitFor( + () => + expect(result.current.diagnosticsAttempt.status).toEqual('success'), + { interval } + ); + const notifications = notificationsService.getNotifications(); + expect(notifications).toHaveLength(1); + expect(notifications[0].content).toMatchObject({ + description: expect.stringContaining('Log in to a cluster'), + action: undefined, + }); + }, + }, ]; // eslint-disable-next-line jest/expect-expect @@ -445,7 +476,7 @@ describe('diag notification', () => { const Wrapper = ( props: PropsWithChildren<{ appContext: IAppContext; - controlConnectionsRef?: MutableRefObject; + controlConnectionsRef?: RefObject; }> ) => { return ( @@ -480,7 +511,7 @@ function createWrapper( } const OpenConnections = (props: { - controlConnectionsRef: MutableRefObject; + controlConnectionsRef: RefObject; }) => { const { open, close } = useConnectionsContext(); useImperativeHandle(props.controlConnectionsRef, () => ({ open, close })); diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx index 3779cf77be607..7e2afd7750c7e 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -28,6 +28,7 @@ import { useState, } from 'react'; +import { Action } from 'design/Alert'; import { BackgroundItemStatus, GetServiceInfoResponse, @@ -256,13 +257,14 @@ export const VnetContextProvider: FC< [status.value] ); - const rootClusterUri = useStoreSelector( + const isWorkspaceSelected = useStoreSelector( 'workspacesService', - useCallback(state => state.rootClusterUri, []) + useCallback(state => !!state.rootClusterUri, []) ); const openReport = useCallback( (report: Report) => { + const rootClusterUri = workspacesService.getRootClusterUri(); if (!rootClusterUri) { return; } @@ -294,7 +296,7 @@ export const VnetContextProvider: FC< // upon on the next run of runDiagnosticsAndShowNotification. notificationsService.removeNotification(diagNotificationIdRef.current); }, - [rootClusterUri, workspacesService, notificationsService] + [workspacesService, notificationsService] ); const showDiagWarningIndicator: boolean = useMemo( @@ -396,10 +398,10 @@ export const VnetContextProvider: FC< return; } - diagNotificationIdRef.current = notificationsService.notifyWarning({ - isAutoRemovable: false, - title: 'Other software on your device might interfere with VNet.', - action: { + let action: Action; + let description: string; + if (workspacesService.getRootClusterUri()) { + action = { content: 'Open Diag Report', onClick: () => { openReport(report); @@ -409,7 +411,17 @@ export const VnetContextProvider: FC< diagNotificationIdRef.current ); }, - }, + }; + } else { + description = + 'Log in to a cluster to open the diag report from the VNet panel.'; + } + + diagNotificationIdRef.current = notificationsService.notifyWarning({ + isAutoRemovable: false, + title: 'Other software on your device might interfere with VNet.', + description, + action, }); }, [ @@ -419,6 +431,7 @@ export const VnetContextProvider: FC< hasDismissedDiagnosticsAlertRef, resetHasActedOnPreviousNotification, isConnectionsPanelOpenRef, + workspacesService, ] ); @@ -469,7 +482,7 @@ export const VnetContextProvider: FC< dismissDiagnosticsAlert, hasDismissedDiagnosticsAlert, reinstateDiagnosticsAlert, - openReport: rootClusterUri ? openReport : undefined, + openReport: isWorkspaceSelected ? openReport : undefined, showDiagWarningIndicator, hasEverStarted, }} From 37c065524d365311bf437399e28846b1df4291c1 Mon Sep 17 00:00:00 2001 From: Nic Klaassen Date: Thu, 26 Jun 2025 14:50:57 -0700 Subject: [PATCH 20/25] [v18][vnet] feat: automatic SSH client configuration in Connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport #55924 to branch/v18 Co-authored-by: Rafał Cieślak Co-authored-by: Grzegorz Zdunek --- .../lib/teleterm/vnet/v1/vnet_service.pb.go | 128 +++++++++++++++--- .../teleterm/vnet/v1/vnet_service_grpc.pb.go | 42 ++++++ .../vnet/v1/vnet_service_pb.client.ts | 19 +++ .../vnet/v1/vnet_service_pb.grpc-server.ts | 19 +++ .../lib/teleterm/vnet/v1/vnet_service_pb.ts | 84 +++++++++++- lib/teleterm/vnet/service.go | 14 +- .../lib/teleterm/vnet/v1/vnet_service.proto | 13 ++ .../src/services/tshd/fixtures/mocks.ts | 4 +- .../teleterm/src/ui/ModalsHost/ModalsHost.tsx | 12 ++ .../src/ui/Vnet/ConfigureSSHClients.story.tsx | 51 +++++++ .../src/ui/Vnet/ConfigureSSHClients.tsx | 97 +++++++++++++ .../src/ui/Vnet/DocumentVnetDiagReport.tsx | 17 ++- .../src/ui/Vnet/VnetSliderStep.story.tsx | 10 +- .../teleterm/src/ui/Vnet/VnetSliderStep.tsx | 30 +++- .../teleterm/src/ui/Vnet/integration.test.tsx | 6 + .../teleterm/src/ui/Vnet/useVnetLauncher.tsx | 46 ++++++- .../teleterm/src/ui/Vnet/vnetContext.tsx | 62 ++++++++- .../src/ui/services/modals/modalsService.ts | 13 ++ 18 files changed, 619 insertions(+), 48 deletions(-) create mode 100644 web/packages/teleterm/src/ui/Vnet/ConfigureSSHClients.story.tsx create mode 100644 web/packages/teleterm/src/ui/Vnet/ConfigureSSHClients.tsx diff --git a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go index f1103603824cf..e87f534e7c998 100644 --- a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go @@ -296,8 +296,11 @@ type GetServiceInfoResponse struct { // ssh_configured is true if the user's SSH config file includes VNet's // generated SSH config necessary for SSH access. SshConfigured bool `protobuf:"varint,3,opt,name=ssh_configured,json=sshConfigured,proto3" json:"ssh_configured,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // vnet_ssh_config_path is the path of VNet's generated OpenSSH-compatible + // config file. + VnetSshConfigPath string `protobuf:"bytes,4,opt,name=vnet_ssh_config_path,json=vnetSshConfigPath,proto3" json:"vnet_ssh_config_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetServiceInfoResponse) Reset() { @@ -351,6 +354,13 @@ func (x *GetServiceInfoResponse) GetSshConfigured() bool { return false } +func (x *GetServiceInfoResponse) GetVnetSshConfigPath() string { + if x != nil { + return x.VnetSshConfigPath + } + return "" +} + // Request for GetBackgroundItemStatus. type GetBackgroundItemStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -515,6 +525,80 @@ func (x *RunDiagnosticsResponse) GetReport() *v1.Report { return nil } +// Request for AutoConfigureSSH. +type AutoConfigureSSHRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AutoConfigureSSHRequest) Reset() { + *x = AutoConfigureSSHRequest{} + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AutoConfigureSSHRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AutoConfigureSSHRequest) ProtoMessage() {} + +func (x *AutoConfigureSSHRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[10] + 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 AutoConfigureSSHRequest.ProtoReflect.Descriptor instead. +func (*AutoConfigureSSHRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{10} +} + +// Response for AutoConfigureSSH. +type AutoConfigureSSHResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AutoConfigureSSHResponse) Reset() { + *x = AutoConfigureSSHResponse{} + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AutoConfigureSSHResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AutoConfigureSSHResponse) ProtoMessage() {} + +func (x *AutoConfigureSSHResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[11] + 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 AutoConfigureSSHResponse.ProtoReflect.Descriptor instead. +func (*AutoConfigureSSHResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{11} +} + var File_teleport_lib_teleterm_vnet_v1_vnet_service_proto protoreflect.FileDescriptor const file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = "" + @@ -524,30 +608,34 @@ const file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = "" + "\rStartResponse\"\r\n" + "\vStopRequest\"\x0e\n" + "\fStopResponse\"\x17\n" + - "\x15GetServiceInfoRequest\"\x7f\n" + + "\x15GetServiceInfoRequest\"\xb0\x01\n" + "\x16GetServiceInfoResponse\x12\"\n" + "\rapp_dns_zones\x18\x01 \x03(\tR\vappDnsZones\x12\x1a\n" + "\bclusters\x18\x02 \x03(\tR\bclusters\x12%\n" + - "\x0essh_configured\x18\x03 \x01(\bR\rsshConfigured\" \n" + + "\x0essh_configured\x18\x03 \x01(\bR\rsshConfigured\x12/\n" + + "\x14vnet_ssh_config_path\x18\x04 \x01(\tR\x11vnetSshConfigPath\" \n" + "\x1eGetBackgroundItemStatusRequest\"n\n" + "\x1fGetBackgroundItemStatusResponse\x12K\n" + "\x06status\x18\x01 \x01(\x0e23.teleport.lib.teleterm.vnet.v1.BackgroundItemStatusR\x06status\"\x17\n" + "\x15RunDiagnosticsRequest\"S\n" + "\x16RunDiagnosticsResponse\x129\n" + - "\x06report\x18\x01 \x01(\v2!.teleport.lib.vnet.diag.v1.ReportR\x06report*\x8b\x02\n" + + "\x06report\x18\x01 \x01(\v2!.teleport.lib.vnet.diag.v1.ReportR\x06report\"\x19\n" + + "\x17AutoConfigureSSHRequest\"\x1a\n" + + "\x18AutoConfigureSSHResponse*\x8b\x02\n" + "\x14BackgroundItemStatus\x12&\n" + "\"BACKGROUND_ITEM_STATUS_UNSPECIFIED\x10\x00\x12)\n" + "%BACKGROUND_ITEM_STATUS_NOT_REGISTERED\x10\x01\x12\"\n" + "\x1eBACKGROUND_ITEM_STATUS_ENABLED\x10\x02\x12,\n" + "(BACKGROUND_ITEM_STATUS_REQUIRES_APPROVAL\x10\x03\x12$\n" + " BACKGROUND_ITEM_STATUS_NOT_FOUND\x10\x04\x12(\n" + - "$BACKGROUND_ITEM_STATUS_NOT_SUPPORTED\x10\x052\xeb\x04\n" + + "$BACKGROUND_ITEM_STATUS_NOT_SUPPORTED\x10\x052\xf1\x05\n" + "\vVnetService\x12b\n" + "\x05Start\x12+.teleport.lib.teleterm.vnet.v1.StartRequest\x1a,.teleport.lib.teleterm.vnet.v1.StartResponse\x12_\n" + "\x04Stop\x12*.teleport.lib.teleterm.vnet.v1.StopRequest\x1a+.teleport.lib.teleterm.vnet.v1.StopResponse\x12}\n" + "\x0eGetServiceInfo\x124.teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest\x1a5.teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse\x12\x98\x01\n" + "\x17GetBackgroundItemStatus\x12=.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest\x1a>.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse\x12}\n" + - "\x0eRunDiagnostics\x124.teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest\x1a5.teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponseBUZSgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1;vnetv1b\x06proto3" + "\x0eRunDiagnostics\x124.teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest\x1a5.teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse\x12\x83\x01\n" + + "\x10AutoConfigureSSH\x126.teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest\x1a7.teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponseBUZSgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1;vnetv1b\x06proto3" var ( file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescOnce sync.Once @@ -562,7 +650,7 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP() []byte } var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_goTypes = []any{ (BackgroundItemStatus)(0), // 0: teleport.lib.teleterm.vnet.v1.BackgroundItemStatus (*StartRequest)(nil), // 1: teleport.lib.teleterm.vnet.v1.StartRequest @@ -575,23 +663,27 @@ var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_goTypes = []any{ (*GetBackgroundItemStatusResponse)(nil), // 8: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse (*RunDiagnosticsRequest)(nil), // 9: teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest (*RunDiagnosticsResponse)(nil), // 10: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse - (*v1.Report)(nil), // 11: teleport.lib.vnet.diag.v1.Report + (*AutoConfigureSSHRequest)(nil), // 11: teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + (*AutoConfigureSSHResponse)(nil), // 12: teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + (*v1.Report)(nil), // 13: teleport.lib.vnet.diag.v1.Report } var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_depIdxs = []int32{ 0, // 0: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse.status:type_name -> teleport.lib.teleterm.vnet.v1.BackgroundItemStatus - 11, // 1: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse.report:type_name -> teleport.lib.vnet.diag.v1.Report + 13, // 1: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse.report:type_name -> teleport.lib.vnet.diag.v1.Report 1, // 2: teleport.lib.teleterm.vnet.v1.VnetService.Start:input_type -> teleport.lib.teleterm.vnet.v1.StartRequest 3, // 3: teleport.lib.teleterm.vnet.v1.VnetService.Stop:input_type -> teleport.lib.teleterm.vnet.v1.StopRequest 5, // 4: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:input_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest 7, // 5: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:input_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest 9, // 6: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:input_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest - 2, // 7: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse - 4, // 8: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse - 6, // 9: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:output_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse - 8, // 10: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:output_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse - 10, // 11: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:output_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse - 7, // [7:12] is the sub-list for method output_type - 2, // [2:7] is the sub-list for method input_type + 11, // 7: teleport.lib.teleterm.vnet.v1.VnetService.AutoConfigureSSH:input_type -> teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + 2, // 8: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse + 4, // 9: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse + 6, // 10: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:output_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse + 8, // 11: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:output_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse + 10, // 12: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:output_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse + 12, // 13: teleport.lib.teleterm.vnet.v1.VnetService.AutoConfigureSSH:output_type -> teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + 8, // [8:14] is the sub-list for method output_type + 2, // [2:8] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name @@ -608,7 +700,7 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc), len(file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go index dc680f3295f6b..074693d5c6c11 100644 --- a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go @@ -40,6 +40,7 @@ const ( VnetService_GetServiceInfo_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetServiceInfo" VnetService_GetBackgroundItemStatus_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetBackgroundItemStatus" VnetService_RunDiagnostics_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/RunDiagnostics" + VnetService_AutoConfigureSSH_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/AutoConfigureSSH" ) // VnetServiceClient is the client API for VnetService service. @@ -60,6 +61,9 @@ type VnetServiceClient interface { // RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that // is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. RunDiagnostics(ctx context.Context, in *RunDiagnosticsRequest, opts ...grpc.CallOption) (*RunDiagnosticsResponse, error) + // AutoConfigureSSH automatically configures OpenSSH-compatible clients for + // connections to Teleport SSH hosts. + AutoConfigureSSH(ctx context.Context, in *AutoConfigureSSHRequest, opts ...grpc.CallOption) (*AutoConfigureSSHResponse, error) } type vnetServiceClient struct { @@ -120,6 +124,16 @@ func (c *vnetServiceClient) RunDiagnostics(ctx context.Context, in *RunDiagnosti return out, nil } +func (c *vnetServiceClient) AutoConfigureSSH(ctx context.Context, in *AutoConfigureSSHRequest, opts ...grpc.CallOption) (*AutoConfigureSSHResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AutoConfigureSSHResponse) + err := c.cc.Invoke(ctx, VnetService_AutoConfigureSSH_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // VnetServiceServer is the server API for VnetService service. // All implementations must embed UnimplementedVnetServiceServer // for forward compatibility. @@ -138,6 +152,9 @@ type VnetServiceServer interface { // RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that // is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. RunDiagnostics(context.Context, *RunDiagnosticsRequest) (*RunDiagnosticsResponse, error) + // AutoConfigureSSH automatically configures OpenSSH-compatible clients for + // connections to Teleport SSH hosts. + AutoConfigureSSH(context.Context, *AutoConfigureSSHRequest) (*AutoConfigureSSHResponse, error) mustEmbedUnimplementedVnetServiceServer() } @@ -163,6 +180,9 @@ func (UnimplementedVnetServiceServer) GetBackgroundItemStatus(context.Context, * func (UnimplementedVnetServiceServer) RunDiagnostics(context.Context, *RunDiagnosticsRequest) (*RunDiagnosticsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method RunDiagnostics not implemented") } +func (UnimplementedVnetServiceServer) AutoConfigureSSH(context.Context, *AutoConfigureSSHRequest) (*AutoConfigureSSHResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AutoConfigureSSH not implemented") +} func (UnimplementedVnetServiceServer) mustEmbedUnimplementedVnetServiceServer() {} func (UnimplementedVnetServiceServer) testEmbeddedByValue() {} @@ -274,6 +294,24 @@ func _VnetService_RunDiagnostics_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _VnetService_AutoConfigureSSH_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AutoConfigureSSHRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VnetServiceServer).AutoConfigureSSH(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VnetService_AutoConfigureSSH_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VnetServiceServer).AutoConfigureSSH(ctx, req.(*AutoConfigureSSHRequest)) + } + return interceptor(ctx, in, info, handler) +} + // VnetService_ServiceDesc is the grpc.ServiceDesc for VnetService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -301,6 +339,10 @@ var VnetService_ServiceDesc = grpc.ServiceDesc{ MethodName: "RunDiagnostics", Handler: _VnetService_RunDiagnostics_Handler, }, + { + MethodName: "AutoConfigureSSH", + Handler: _VnetService_AutoConfigureSSH_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "teleport/lib/teleterm/vnet/v1/vnet_service.proto", diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts index fdd21d783c4ff..6787923ba9de6 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts @@ -23,6 +23,8 @@ import type { RpcTransport } from "@protobuf-ts/runtime-rpc"; import type { ServiceInfo } from "@protobuf-ts/runtime-rpc"; import { VnetService } from "./vnet_service_pb"; +import type { AutoConfigureSSHResponse } from "./vnet_service_pb"; +import type { AutoConfigureSSHRequest } from "./vnet_service_pb"; import type { RunDiagnosticsResponse } from "./vnet_service_pb"; import type { RunDiagnosticsRequest } from "./vnet_service_pb"; import type { GetBackgroundItemStatusResponse } from "./vnet_service_pb"; @@ -74,6 +76,13 @@ export interface IVnetServiceClient { * @generated from protobuf rpc: RunDiagnostics(teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest) returns (teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse); */ runDiagnostics(input: RunDiagnosticsRequest, options?: RpcOptions): UnaryCall; + /** + * AutoConfigureSSH automatically configures OpenSSH-compatible clients for + * connections to Teleport SSH hosts. + * + * @generated from protobuf rpc: AutoConfigureSSH(teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest) returns (teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse); + */ + autoConfigureSSH(input: AutoConfigureSSHRequest, options?: RpcOptions): UnaryCall; } /** * VnetService provides methods to manage a VNet instance. @@ -133,4 +142,14 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { const method = this.methods[4], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } + /** + * AutoConfigureSSH automatically configures OpenSSH-compatible clients for + * connections to Teleport SSH hosts. + * + * @generated from protobuf rpc: AutoConfigureSSH(teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest) returns (teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse); + */ + autoConfigureSSH(input: AutoConfigureSSHRequest, options?: RpcOptions): UnaryCall { + const method = this.methods[5], opt = this._transport.mergeOptions(options); + return stackIntercept("unary", this._transport, method, opt, input); + } } diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts index 1457c1913f871..15198d3f54bdb 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts @@ -20,6 +20,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // +import { AutoConfigureSSHResponse } from "./vnet_service_pb"; +import { AutoConfigureSSHRequest } from "./vnet_service_pb"; import { RunDiagnosticsResponse } from "./vnet_service_pb"; import { RunDiagnosticsRequest } from "./vnet_service_pb"; import { GetBackgroundItemStatusResponse } from "./vnet_service_pb"; @@ -69,6 +71,13 @@ export interface IVnetService extends grpc.UntypedServiceImplementation { * @generated from protobuf rpc: RunDiagnostics(teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest) returns (teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse); */ runDiagnostics: grpc.handleUnaryCall; + /** + * AutoConfigureSSH automatically configures OpenSSH-compatible clients for + * connections to Teleport SSH hosts. + * + * @generated from protobuf rpc: AutoConfigureSSH(teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest) returns (teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse); + */ + autoConfigureSSH: grpc.handleUnaryCall; } /** * @grpc/grpc-js definition for the protobuf service teleport.lib.teleterm.vnet.v1.VnetService. @@ -131,5 +140,15 @@ export const vnetServiceDefinition: grpc.ServiceDefinition = { requestDeserialize: bytes => RunDiagnosticsRequest.fromBinary(bytes), responseSerialize: value => Buffer.from(RunDiagnosticsResponse.toBinary(value)), requestSerialize: value => Buffer.from(RunDiagnosticsRequest.toBinary(value)) + }, + autoConfigureSSH: { + path: "/teleport.lib.teleterm.vnet.v1.VnetService/AutoConfigureSSH", + originalName: "AutoConfigureSSH", + requestStream: false, + responseStream: false, + responseDeserialize: bytes => AutoConfigureSSHResponse.fromBinary(bytes), + requestDeserialize: bytes => AutoConfigureSSHRequest.fromBinary(bytes), + responseSerialize: value => Buffer.from(AutoConfigureSSHResponse.toBinary(value)), + requestSerialize: value => Buffer.from(AutoConfigureSSHRequest.toBinary(value)) } }; diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts index 772371fc2f714..500e0d2931bf4 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts @@ -92,6 +92,13 @@ export interface GetServiceInfoResponse { * @generated from protobuf field: bool ssh_configured = 3; */ sshConfigured: boolean; + /** + * vnet_ssh_config_path is the path of VNet's generated OpenSSH-compatible + * config file. + * + * @generated from protobuf field: string vnet_ssh_config_path = 4; + */ + vnetSshConfigPath: string; } /** * Request for GetBackgroundItemStatus. @@ -129,6 +136,20 @@ export interface RunDiagnosticsResponse { */ report?: Report; } +/** + * Request for AutoConfigureSSH. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + */ +export interface AutoConfigureSSHRequest { +} +/** + * Response for AutoConfigureSSH. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + */ +export interface AutoConfigureSSHResponse { +} /** * BackgroundItemStatus maps to SMAppServiceStatus of the Service Management framework in macOS. * https://developer.apple.com/documentation/servicemanagement/smappservice/status-swift.enum?language=objc @@ -295,7 +316,8 @@ class GetServiceInfoResponse$Type extends MessageType { super("teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse", [ { no: 1, name: "app_dns_zones", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, { no: 2, name: "clusters", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, - { no: 3, name: "ssh_configured", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } + { no: 3, name: "ssh_configured", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 4, name: "vnet_ssh_config_path", kind: "scalar", T: 9 /*ScalarType.STRING*/ } ]); } create(value?: PartialMessage): GetServiceInfoResponse { @@ -303,6 +325,7 @@ class GetServiceInfoResponse$Type extends MessageType { message.appDnsZones = []; message.clusters = []; message.sshConfigured = false; + message.vnetSshConfigPath = ""; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -321,6 +344,9 @@ class GetServiceInfoResponse$Type extends MessageType { case /* bool ssh_configured */ 3: message.sshConfigured = reader.bool(); break; + case /* string vnet_ssh_config_path */ 4: + message.vnetSshConfigPath = reader.string(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -342,6 +368,9 @@ class GetServiceInfoResponse$Type extends MessageType { /* bool ssh_configured = 3; */ if (message.sshConfigured !== false) writer.tag(3, WireType.Varint).bool(message.sshConfigured); + /* string vnet_ssh_config_path = 4; */ + if (message.vnetSshConfigPath !== "") + writer.tag(4, WireType.LengthDelimited).string(message.vnetSshConfigPath); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -495,6 +524,56 @@ class RunDiagnosticsResponse$Type extends MessageType { * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse */ export const RunDiagnosticsResponse = new RunDiagnosticsResponse$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class AutoConfigureSSHRequest$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest", []); + } + create(value?: PartialMessage): AutoConfigureSSHRequest { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AutoConfigureSSHRequest): AutoConfigureSSHRequest { + return target ?? this.create(); + } + internalBinaryWrite(message: AutoConfigureSSHRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + */ +export const AutoConfigureSSHRequest = new AutoConfigureSSHRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class AutoConfigureSSHResponse$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse", []); + } + create(value?: PartialMessage): AutoConfigureSSHResponse { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AutoConfigureSSHResponse): AutoConfigureSSHResponse { + return target ?? this.create(); + } + internalBinaryWrite(message: AutoConfigureSSHResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + */ +export const AutoConfigureSSHResponse = new AutoConfigureSSHResponse$Type(); /** * @generated ServiceType for protobuf service teleport.lib.teleterm.vnet.v1.VnetService */ @@ -503,5 +582,6 @@ export const VnetService = new ServiceType("teleport.lib.teleterm.vnet.v1.VnetSe { name: "Stop", options: {}, I: StopRequest, O: StopResponse }, { name: "GetServiceInfo", options: {}, I: GetServiceInfoRequest, O: GetServiceInfoResponse }, { name: "GetBackgroundItemStatus", options: {}, I: GetBackgroundItemStatusRequest, O: GetBackgroundItemStatusResponse }, - { name: "RunDiagnostics", options: {}, I: RunDiagnosticsRequest, O: RunDiagnosticsResponse } + { name: "RunDiagnostics", options: {}, I: RunDiagnosticsRequest, O: RunDiagnosticsResponse }, + { name: "AutoConfigureSSH", options: {}, I: AutoConfigureSSHRequest, O: AutoConfigureSSHResponse } ]); diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index e92f981187ff6..748ef53224511 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -248,9 +248,10 @@ func (s *Service) GetServiceInfo(ctx context.Context, _ *api.GetServiceInfoReque } return &api.GetServiceInfoResponse{ - AppDnsZones: unifiedClusterConfig.AppDNSZones(), - Clusters: unifiedClusterConfig.ClusterNames, - SshConfigured: sshConfigured, + AppDnsZones: unifiedClusterConfig.AppDNSZones(), + Clusters: unifiedClusterConfig.ClusterNames, + SshConfigured: sshConfigured, + VnetSshConfigPath: sshConfigChecker.VNetSSHConfigPath, }, nil } @@ -313,6 +314,13 @@ func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, er }, nil } +// AutoConfigureSSH automatically configures OpenSSH-compatible clients for +// connections to Teleport SSH servers through VNet. +func (s *Service) AutoConfigureSSH(ctx context.Context, _ *api.AutoConfigureSSHRequest) (*api.AutoConfigureSSHResponse, error) { + err := vnet.AutoConfigureOpenSSH(ctx, s.cfg.profilePath) + return nil, trace.Wrap(err) +} + func (s *Service) stopLocked() error { if s.status == statusClosed { return trace.CompareFailed("VNet service has been closed") diff --git a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto index d63a140678c62..0336e122b523a 100644 --- a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto +++ b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto @@ -40,6 +40,10 @@ service VnetService { // RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that // is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. rpc RunDiagnostics(RunDiagnosticsRequest) returns (RunDiagnosticsResponse); + + // AutoConfigureSSH automatically configures OpenSSH-compatible clients for + // connections to Teleport SSH hosts. + rpc AutoConfigureSSH(AutoConfigureSSHRequest) returns (AutoConfigureSSHResponse); } // Request for Start. @@ -67,6 +71,9 @@ message GetServiceInfoResponse { // ssh_configured is true if the user's SSH config file includes VNet's // generated SSH config necessary for SSH access. bool ssh_configured = 3; + // vnet_ssh_config_path is the path of VNet's generated OpenSSH-compatible + // config file. + string vnet_ssh_config_path = 4; } // Request for GetBackgroundItemStatus. @@ -97,3 +104,9 @@ message RunDiagnosticsRequest {} message RunDiagnosticsResponse { teleport.lib.vnet.diag.v1.Report report = 1; } + +// Request for AutoConfigureSSH. +message AutoConfigureSSHRequest {} + +// Response for AutoConfigureSSH. +message AutoConfigureSSHResponse {} diff --git a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts index 3520508111585..9f2a80fae0196 100644 --- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -123,9 +123,10 @@ export class MockVnetClient implements VnetClient { appDnsZones: [], clusters: [], sshConfigured: false, + vnetSshConfigPath: + '/Users/user/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config', }); getBackgroundItemStatus = () => new MockedUnaryCall({ status: 0 }); - runDiagnostics() { return new MockedUnaryCall({ report: { @@ -134,4 +135,5 @@ export class MockVnetClient implements VnetClient { }, }); } + autoConfigureSSH = () => new MockedUnaryCall({}); } diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index e5fd1b58b97ef..77cc1037883a1 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -27,6 +27,7 @@ import { Dialog } from 'teleterm/ui/services/modals'; import { ClusterLogout } from '../ClusterLogout'; import { ResourceSearchErrors } from '../Search/ResourceSearchErrors'; import { assertUnreachable } from '../utils'; +import { ConfigureSSHClients } from '../Vnet/ConfigureSSHClients'; import { ChangeAccessRequestKind } from './modals/ChangeAccessRequestKind'; import { AskPin, ChangePin, OverwriteSlot, Touch } from './modals/HardwareKeys'; import { ReAuthenticate } from './modals/ReAuthenticate'; @@ -281,6 +282,17 @@ function renderDialog({ /> ); } + case 'configure-ssh-clients': { + return ( +