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 320e4bbbb5e31..acbaae240a487 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 @@ -192,6 +192,94 @@ func (*StopResponse) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{3} } +// Request for ListDNSZones. +type ListDNSZonesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *ListDNSZonesRequest) Reset() { + *x = ListDNSZonesRequest{} + if protoimpl.UnsafeEnabled { + 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 { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDNSZonesRequest) ProtoMessage() {} + +func (x *ListDNSZonesRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDNSZonesRequest.ProtoReflect.Descriptor instead. +func (*ListDNSZonesRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{4} +} + +// Response for ListDNSZones. +type ListDNSZonesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // 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"` +} + +func (x *ListDNSZonesResponse) Reset() { + *x = ListDNSZonesResponse{} + if protoimpl.UnsafeEnabled { + 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 { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDNSZonesResponse) ProtoMessage() {} + +func (x *ListDNSZonesResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDNSZonesResponse.ProtoReflect.Descriptor instead. +func (*ListDNSZonesResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{5} +} + +func (x *ListDNSZonesResponse) GetDnsZones() []string { + if x != nil { + return x.DnsZones + } + return nil +} + var File_teleport_lib_teleterm_vnet_v1_vnet_service_proto protoreflect.FileDescriptor var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = []byte{ @@ -204,26 +292,38 @@ var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = []byte{ 0x74, 0x22, 0x0f, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x0d, 0x0a, 0x0b, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x0e, 0x0a, 0x0c, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x32, 0xd2, 0x01, 0x0a, 0x0b, 0x56, 0x6e, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x62, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x2b, 0x2e, 0x74, 0x65, 0x6c, - 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x72, 0x6d, 0x2e, 0x76, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, - 0x72, 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, - 0x76, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x04, 0x53, 0x74, 0x6f, 0x70, 0x12, 0x2a, 0x2e, - 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, - 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x74, 0x65, 0x6c, 0x65, + 0x65, 0x22, 0x15, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x4e, 0x53, 0x5a, 0x6f, 0x6e, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x33, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, + 0x44, 0x4e, 0x53, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x6e, 0x73, 0x5f, 0x7a, 0x6f, 0x6e, 0x65, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x08, 0x64, 0x6e, 0x73, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x32, 0xcb, 0x02, + 0x0a, 0x0b, 0x56, 0x6e, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x62, 0x0a, + 0x05, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x2b, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, + 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6c, + 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, 0x6e, 0x65, 0x74, + 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x5f, 0x0a, 0x04, 0x53, 0x74, 0x6f, 0x70, 0x12, 0x2a, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x55, 0x5a, 0x53, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x67, 0x65, 0x6e, 0x2f, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, - 0x74, 0x2f, 0x6c, 0x69, 0x62, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2f, 0x76, - 0x6e, 0x65, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x76, 0x6e, 0x65, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, + 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, 0x6e, + 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x77, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x4e, 0x53, 0x5a, 0x6f, 0x6e, + 0x65, 0x73, 0x12, 0x32, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6c, 0x69, + 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, 0x6e, 0x65, 0x74, 0x2e, + 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x4e, 0x53, 0x5a, 0x6f, 0x6e, 0x65, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, + 0x6e, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x44, 0x4e, 0x53, 0x5a, 0x6f, + 0x6e, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x55, 0x5a, 0x53, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, + 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x74, 0x65, + 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x6c, 0x69, 0x62, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x72, 0x6d, 0x2f, 0x76, 0x6e, 0x65, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x76, 0x6e, 0x65, 0x74, + 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -238,20 +338,24 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP() []byte return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescData } -var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_goTypes = []any{ - (*StartRequest)(nil), // 0: teleport.lib.teleterm.vnet.v1.StartRequest - (*StartResponse)(nil), // 1: teleport.lib.teleterm.vnet.v1.StartResponse - (*StopRequest)(nil), // 2: teleport.lib.teleterm.vnet.v1.StopRequest - (*StopResponse)(nil), // 3: teleport.lib.teleterm.vnet.v1.StopResponse + (*StartRequest)(nil), // 0: teleport.lib.teleterm.vnet.v1.StartRequest + (*StartResponse)(nil), // 1: teleport.lib.teleterm.vnet.v1.StartResponse + (*StopRequest)(nil), // 2: teleport.lib.teleterm.vnet.v1.StopRequest + (*StopResponse)(nil), // 3: teleport.lib.teleterm.vnet.v1.StopResponse + (*ListDNSZonesRequest)(nil), // 4: teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + (*ListDNSZonesResponse)(nil), // 5: teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse } var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_depIdxs = []int32{ 0, // 0: teleport.lib.teleterm.vnet.v1.VnetService.Start:input_type -> teleport.lib.teleterm.vnet.v1.StartRequest 2, // 1: teleport.lib.teleterm.vnet.v1.VnetService.Stop:input_type -> teleport.lib.teleterm.vnet.v1.StopRequest - 1, // 2: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse - 3, // 3: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type + 4, // 2: teleport.lib.teleterm.vnet.v1.VnetService.ListDNSZones:input_type -> teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + 1, // 3: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse + 3, // 4: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse + 5, // 5: teleport.lib.teleterm.vnet.v1.VnetService.ListDNSZones:output_type -> teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name @@ -311,6 +415,30 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_init() { return nil } } + file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[4].Exporter = func(v any, i int) any { + switch v := v.(*ListDNSZonesRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[5].Exporter = func(v any, i int) any { + switch v := v.(*ListDNSZonesResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -318,7 +446,7 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc, NumEnums: 0, - NumMessages: 4, + NumMessages: 6, 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 8bbf28768a63a..5dbbb2788fe92 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 @@ -35,8 +35,9 @@ import ( const _ = grpc.SupportPackageIsVersion8 const ( - VnetService_Start_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Start" - VnetService_Stop_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Stop" + 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" ) // VnetServiceClient is the client API for VnetService service. @@ -49,6 +50,16 @@ 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) } type vnetServiceClient struct { @@ -79,6 +90,16 @@ 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) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListDNSZonesResponse) + err := c.cc.Invoke(ctx, VnetService_ListDNSZones_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 @@ -89,6 +110,16 @@ 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) mustEmbedUnimplementedVnetServiceServer() } @@ -102,6 +133,9 @@ 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) mustEmbedUnimplementedVnetServiceServer() {} // UnsafeVnetServiceServer may be embedded to opt out of forward compatibility for this service. @@ -151,6 +185,24 @@ 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) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VnetServiceServer).ListDNSZones(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VnetService_ListDNSZones_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VnetServiceServer).ListDNSZones(ctx, req.(*ListDNSZonesRequest)) + } + 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) @@ -166,6 +218,10 @@ var VnetService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Stop", Handler: _VnetService_Stop_Handler, }, + { + MethodName: "ListDNSZones", + Handler: _VnetService_ListDNSZones_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 7002e075f65b1..c7e0676724901 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 { ListDNSZonesResponse } from "./vnet_service_pb"; +import type { ListDNSZonesRequest } 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"; @@ -48,6 +50,20 @@ export interface IVnetServiceClient { * @generated from protobuf rpc: Stop(teleport.lib.teleterm.vnet.v1.StopRequest) returns (teleport.lib.teleterm.vnet.v1.StopResponse); */ 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. + * + * 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); + */ + listDNSZones(input: ListDNSZonesRequest, options?: RpcOptions): UnaryCall; } /** * VnetService provides methods to manage a VNet instance. @@ -78,4 +94,21 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { const method = this.methods[1], opt = this._transport.mergeOptions(options); 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). + * + * @generated from protobuf rpc: ListDNSZones(teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest) returns (teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse); + */ + listDNSZones(input: ListDNSZonesRequest, options?: RpcOptions): UnaryCall { + const method = this.methods[2], 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 80d48a460d628..79e407a8f008f 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 { ListDNSZonesResponse } from "./vnet_service_pb"; +import { ListDNSZonesRequest } from "./vnet_service_pb"; import { StopResponse } from "./vnet_service_pb"; import { StopRequest } from "./vnet_service_pb"; import { StartResponse } from "./vnet_service_pb"; @@ -43,6 +45,20 @@ export interface IVnetService extends grpc.UntypedServiceImplementation { * @generated from protobuf rpc: Stop(teleport.lib.teleterm.vnet.v1.StopRequest) returns (teleport.lib.teleterm.vnet.v1.StopResponse); */ 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. + * + * 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); + */ + listDNSZones: grpc.handleUnaryCall; } /** * @grpc/grpc-js definition for the protobuf service teleport.lib.teleterm.vnet.v1.VnetService. @@ -75,5 +91,15 @@ export const vnetServiceDefinition: grpc.ServiceDefinition = { requestDeserialize: bytes => StopRequest.fromBinary(bytes), 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", + 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)) } }; 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 fd91639430a9f..b01b23b0f82e9 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 @@ -21,6 +21,7 @@ // along with this program. If not, see . // import { ServiceType } from "@protobuf-ts/runtime-rpc"; +import { WireType } from "@protobuf-ts/runtime"; import type { BinaryWriteOptions } from "@protobuf-ts/runtime"; import type { IBinaryWriter } from "@protobuf-ts/runtime"; import { UnknownFieldHandler } from "@protobuf-ts/runtime"; @@ -57,6 +58,26 @@ export interface StopRequest { */ export interface StopResponse { } +/** + * Request for ListDNSZones. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + */ +export interface ListDNSZonesRequest { +} +/** + * Response for ListDNSZones. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + */ +export interface ListDNSZonesResponse { + /** + * dns_zones is a deduplicated list of DNS zones. + * + * @generated from protobuf field: repeated string dns_zones = 1; + */ + dnsZones: string[]; +} // @generated message type with reflection information, may provide speed optimized methods class StartRequest$Type extends MessageType { constructor() { @@ -157,10 +178,83 @@ class StopResponse$Type extends MessageType { * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.StopResponse */ export const StopResponse = new StopResponse$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ListDNSZonesRequest$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest", []); + } + create(value?: PartialMessage): ListDNSZonesRequest { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListDNSZonesRequest): ListDNSZonesRequest { + return target ?? this.create(); + } + internalBinaryWrite(message: ListDNSZonesRequest, 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.ListDNSZonesRequest + */ +export const ListDNSZonesRequest = new ListDNSZonesRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ListDNSZonesResponse$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*/ } + ]); + } + create(value?: PartialMessage): ListDNSZonesResponse { + const message = globalThis.Object.create((this.messagePrototype!)); + message.dnsZones = []; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListDNSZonesResponse): ListDNSZonesResponse { + 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()); + 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: 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]); + 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.ListDNSZonesResponse + */ +export const ListDNSZonesResponse = new ListDNSZonesResponse$Type(); /** * @generated ServiceType for protobuf service teleport.lib.teleterm.vnet.v1.VnetService */ 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: "Stop", options: {}, I: StopRequest, O: StopResponse }, + { name: "ListDNSZones", options: {}, I: ListDNSZonesRequest, O: ListDNSZonesResponse } ]); diff --git a/lib/teleterm/apiserver/apiserver.go b/lib/teleterm/apiserver/apiserver.go index 40e387638877e..d54531afca218 100644 --- a/lib/teleterm/apiserver/apiserver.go +++ b/lib/teleterm/apiserver/apiserver.go @@ -56,6 +56,7 @@ func New(cfg Config) (*APIServer, error) { InsecureSkipVerify: cfg.InsecureSkipVerify, ClusterIDCache: cfg.ClusterIDCache, InstallationID: cfg.InstallationID, + Clock: cfg.Clock, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/teleterm/apiserver/config.go b/lib/teleterm/apiserver/config.go index 1f296dce88893..76495b8a181b2 100644 --- a/lib/teleterm/apiserver/config.go +++ b/lib/teleterm/apiserver/config.go @@ -20,6 +20,7 @@ package apiserver import ( "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" "github.com/sirupsen/logrus" "google.golang.org/grpc" @@ -41,6 +42,7 @@ type Config struct { // Log is a component logger Log logrus.FieldLogger TshdServerCreds grpc.ServerOption + Clock clockwork.Clock // ListeningC propagates the address on which the gRPC server listens. Mostly useful in tests, as // the Electron app gets the server port from stdout. ListeningC chan<- utils.NetAddr @@ -76,5 +78,9 @@ func (c *Config) CheckAndSetDefaults() error { c.ClusterIDCache = &clusteridcache.Cache{} } + if c.Clock == nil { + c.Clock = clockwork.NewRealClock() + } + return nil } diff --git a/lib/teleterm/teleterm.go b/lib/teleterm/teleterm.go index d2348a7bb93d3..a5a1e35468e43 100644 --- a/lib/teleterm/teleterm.go +++ b/lib/teleterm/teleterm.go @@ -27,6 +27,7 @@ import ( "syscall" "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -48,9 +49,12 @@ func Serve(ctx context.Context, cfg Config) error { return trace.Wrap(err) } + clock := clockwork.NewRealClock() + storage, err := clusters.NewStorage(clusters.Config{ Dir: cfg.HomeDir, InsecureSkipVerify: cfg.InsecureSkipVerify, + Clock: clock, }) if err != nil { return trace.Wrap(err) @@ -78,6 +82,7 @@ func Serve(ctx context.Context, cfg Config) error { ListeningC: cfg.ListeningC, ClusterIDCache: clusterIDCache, InstallationID: cfg.InstallationID, + Clock: clock, }) if err != nil { return trace.Wrap(err) diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index 440dee1bf78ce..ed8f99b301855 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -25,10 +25,12 @@ import ( "time" "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" "google.golang.org/protobuf/types/known/timestamppb" "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils" 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" @@ -55,11 +57,12 @@ const ( type Service struct { api.UnimplementedVnetServiceServer - cfg Config - mu sync.Mutex - status status - processManager *vnet.ProcessManager - usageReporter usageReporter + cfg Config + mu sync.Mutex + status status + usageReporter usageReporter + processManager *vnet.ProcessManager + clusterConfigCache *vnet.ClusterConfigCache } // New creates an instance of Service. @@ -85,6 +88,7 @@ type Config struct { // InstallationID is a unique ID of this particular Connect installation, used for usage // reporting. InstallationID string + Clock clockwork.Clock } // CheckAndSetDefaults checks and sets the defaults @@ -101,6 +105,10 @@ func (c *Config) CheckAndSetDefaults() error { return trace.BadParameter("missing InstallationID") } + if c.Clock == nil { + c.Clock = clockwork.NewRealClock() + } + return nil } @@ -113,7 +121,7 @@ func (s *Service) Start(ctx context.Context, req *api.StartRequest) (*api.StartR } if s.status == statusRunning { - return &api.StartResponse{}, nil + return nil, trace.AlreadyExists("VNet is already running") } appProvider := &appProvider{ @@ -150,7 +158,11 @@ func (s *Service) Start(ctx context.Context, req *api.StartRequest) (*api.StartR appProvider.usageReporter = usageReporter } - processManager, err := vnet.SetupAndRun(ctx, appProvider) + s.clusterConfigCache = vnet.NewClusterConfigCache(s.cfg.Clock) + processManager, err := vnet.SetupAndRun(ctx, &vnet.SetupAndRunConfig{ + AppProvider: appProvider, + ClusterConfigCache: s.clusterConfigCache, + }) if err != nil { return nil, trace.Wrap(err) } @@ -201,6 +213,81 @@ 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 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 DNS couldn't be fetched +// (due to e.g., a network error or an expired cert). +func (s *Service) ListDNSZones(ctx context.Context, req *api.ListDNSZonesRequest) (*api.ListDNSZonesResponse, 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() + + if s.status != statusRunning { + return nil, trace.CompareFailed("VNet is not running") + } + + defer s.mu.Unlock() + + profileNames, err := s.cfg.DaemonService.ListProfileNames() + if err != nil { + return nil, trace.Wrap(err) + } + + dnsZones := []string{} + + for _, profileName := range profileNames { + rootClusterURI := uri.NewClusterURI(profileName) + cLog := log.With("cluster", rootClusterURI) + + rootClient, err := s.cfg.DaemonService.GetCachedClient(ctx, rootClusterURI) + if err != nil { + cLog.WarnContext(ctx, "Failed to create root cluster client, profile may be expired, skipping DNS zones of this cluster", "error", err) + continue + } + clusterConfig, err := s.clusterConfigCache.GetClusterConfig(ctx, rootClient) + if err != nil { + cLog.WarnContext(ctx, "Failed to load VNet configuration, profile may be expired, skipping DNS zones of this cluster", "error", err) + continue + } + + dnsZones = append(dnsZones, clusterConfig.DNSZones...) + + leafClusters, err := s.cfg.DaemonService.ListLeafClusters(ctx, rootClusterURI.String()) + if err != nil { + cLog.WarnContext(ctx, "Failed to list leaf clusters, profile may be expired, skipping DNS zones from leaf clusters of this cluster", "error", err) + continue + } + + for _, leafCluster := range leafClusters { + cLog := log.With("cluster", leafCluster.URI.String()) + + clusterClient, err := s.cfg.DaemonService.GetCachedClient(ctx, leafCluster.URI) + if err != nil { + cLog.WarnContext(ctx, "Failed to create leaf cluster client, skipping DNS zones for this leaf cluster", "error", err) + continue + } + clusterConfig, err := s.clusterConfigCache.GetClusterConfig(ctx, clusterClient) + if err != nil { + cLog.WarnContext(ctx, "Failed to load VNet configuration, skipping DNS zones for this leaf cluster", "error", err) + continue + } + + dnsZones = append(dnsZones, clusterConfig.DNSZones...) + } + } + + dnsZones = utils.Deduplicate(dnsZones) + + return &api.ListDNSZonesResponse{ + DnsZones: dnsZones, + }, nil +} + func (s *Service) stopLocked() error { if s.status == statusClosed { return trace.CompareFailed("VNet service has been closed") diff --git a/lib/vnet/app_resolver.go b/lib/vnet/app_resolver.go index c6ab2befd2e77..4ab308b6e459e 100644 --- a/lib/vnet/app_resolver.go +++ b/lib/vnet/app_resolver.go @@ -93,7 +93,7 @@ type DialOptions struct { // TCPAppResolver implements [TCPHandlerResolver] for Teleport TCP apps. type TCPAppResolver struct { appProvider AppProvider - clusterConfigCache *clusterConfigCache + clusterConfigCache *ClusterConfigCache customDNSZoneChecker *customDNSZoneValidator slog *slog.Logger clock clockwork.Clock @@ -117,7 +117,7 @@ func NewTCPAppResolver(appProvider AppProvider, opts ...tcpAppResolverOption) (* opt(r) } r.clock = cmp.Or(r.clock, clockwork.NewRealClock()) - r.clusterConfigCache = newClusterConfigCache(appProvider.GetCachedClient, r.clock) + r.clusterConfigCache = cmp.Or(r.clusterConfigCache, NewClusterConfigCache(r.clock)) r.customDNSZoneChecker = newCustomDNSZoneValidator(r.lookupTXT) return r, nil } @@ -138,6 +138,13 @@ func withLookupTXTFunc(lookupTXT lookupTXTFunc) tcpAppResolverOption { } } +// WithClusterConfigCache is a functional option to override the cluster config cache. +func WithClusterConfigCache(clusterConfigCache *ClusterConfigCache) tcpAppResolverOption { + return func(r *TCPAppResolver) { + r.clusterConfigCache = clusterConfigCache + } +} + // ResolveTCPHandler resolves a fully-qualified domain name to a [TCPHandlerSpec] for a Teleport TCP app that should // be used to handle all future TCP connections to [fqdn]. // Avoid using [trace.Wrap] on [ErrNoTCPHandler] to prevent collecting a full stack trace on every unhandled @@ -208,12 +215,12 @@ func (r *TCPAppResolver) clusterClientForAppFQDN(ctx context.Context, profileNam continue } - clusterConfig, err := r.clusterConfigCache.getClusterConfig(ctx, clusterClient) + clusterConfig, err := r.clusterConfigCache.GetClusterConfig(ctx, clusterClient) if err != nil { r.slog.ErrorContext(ctx, "Failed to get VnetConfig, apps in the cluster will not be resolved.", "profile", profileName, "leaf_cluster", leafClusterName, "error", err) continue } - for _, zone := range clusterConfig.dnsZones { + for _, zone := range clusterConfig.DNSZones { if !isSubdomain(fqdn, zone) { // The queried app fqdn is not a subdomain of this zone, skip it. continue @@ -221,13 +228,13 @@ func (r *TCPAppResolver) clusterClientForAppFQDN(ctx context.Context, profileNam // Found a matching cluster. - if zone == clusterConfig.proxyPublicAddr { + if zone == clusterConfig.ProxyPublicAddr { // We don't need to validate a custom DNS zone if this is the proxy public address, this is a // normal app public_addr. return clusterClient, nil } // The queried app fqdn is a subdomain of this custom zone. Check if the zone is valid. - if err := r.customDNSZoneChecker.validate(ctx, clusterConfig.clusterName, zone); err != nil { + if err := r.customDNSZoneChecker.validate(ctx, clusterConfig.ClusterName, zone); err != nil { // Return an error here since the FQDN does match this custom zone, but the zone failed to // validate. return nil, trace.Wrap(err, "validating custom DNS zone %q matching queried FQDN %q", zone, fqdn) @@ -289,13 +296,13 @@ func (r *TCPAppResolver) resolveTCPHandlerForCluster( return nil, trace.Wrap(err) } - clusterConfig, err := r.clusterConfigCache.getClusterConfig(ctx, clusterClient) + clusterConfig, err := r.clusterConfigCache.GetClusterConfig(ctx, clusterClient) if err != nil { return nil, trace.Wrap(err) } return &TCPHandlerSpec{ - IPv4CIDRRange: clusterConfig.ipv4CIDRRange, + IPv4CIDRRange: clusterConfig.IPv4CIDRRange, TCPHandler: appHandler, }, nil } diff --git a/lib/vnet/clusterconfigcache.go b/lib/vnet/clusterconfigcache.go index fcba8bd034fc3..7f01f56516b95 100644 --- a/lib/vnet/clusterconfigcache.go +++ b/lib/vnet/clusterconfigcache.go @@ -29,52 +29,48 @@ import ( "golang.org/x/sync/singleflight" ) -type getClusterClientFunc = func(ctx context.Context, profileName, leafClusterName string) (ClusterClient, error) - -type clusterConfig struct { - // clusterName is the name of the cluster as reported by Ping. - clusterName string - // proxyPublicAddr is the public address of the proxy as reported by Ping, with any ports removed, this is - // just the hostname. This is often but not always identical to the clusterName. For root clusters this +type ClusterConfig struct { + // ClusterName is the name of the cluster as reported by Ping. + ClusterName string + // ProxyPublicAddr is the public address of the proxy as reported by Ping, with any ports removed, this is + // just the hostname. This is often but not always identical to the ClusterName. For root clusters this // will be the same as the profile name (the profile is named after the proxy public addr). - proxyPublicAddr string - // dnsZones is the list of DNS zones that are valid for this cluster, this includes proxyPublicAddr *and* + ProxyPublicAddr string + // 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 - // 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 + DNSZones []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 // be used if a subsequent fetch fails. - expires time.Time + Expires time.Time } -func (e *clusterConfig) stale(clock clockwork.Clock) bool { - return clock.Now().After(e.expires) +func (e *ClusterConfig) stale(clock clockwork.Clock) bool { + return clock.Now().After(e.Expires) } -// clusterConfigCache is a read-through cache for cluster VnetConfigs. Cached entries go stale after 5 +// 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. // // If a read from the cluster fails but there is a stale cache entry present, this prefers to return the stale // cached entry. This is desirable in cases where the profile for a cluster expires during VNet operation, // it's better to use the stale custom DNS zones than to remove all DNS configuration for that cluster. -type clusterConfigCache struct { +type ClusterConfigCache struct { flightGroup singleflight.Group clock clockwork.Clock - getClient getClusterClientFunc - cache map[string]*clusterConfig + cache map[string]*ClusterConfig mu sync.RWMutex } -func newClusterConfigCache(getClient getClusterClientFunc, clock clockwork.Clock) *clusterConfigCache { - return &clusterConfigCache{ - clock: clock, - getClient: getClient, - cache: make(map[string]*clusterConfig), +func NewClusterConfigCache(clock clockwork.Clock) *ClusterConfigCache { + return &ClusterConfigCache{ + clock: clock, + cache: make(map[string]*ClusterConfig), } } -func (c *clusterConfigCache) getClusterConfig(ctx context.Context, clusterClient ClusterClient) (*clusterConfig, error) { +func (c *ClusterConfigCache) GetClusterConfig(ctx context.Context, clusterClient ClusterClient) (*ClusterConfig, error) { k := clusterClient.ClusterName() // Use a singleflight.Group to avoid concurrent requests for the same cluster VnetConfig. @@ -107,10 +103,10 @@ func (c *clusterConfigCache) getClusterConfig(ctx context.Context, clusterClient if err != nil { return nil, trace.Wrap(err) } - return result.(*clusterConfig), nil + return result.(*ClusterConfig), nil } -func (c *clusterConfigCache) getClusterConfigUncached(ctx context.Context, clusterClient ClusterClient) (*clusterConfig, error) { +func (c *ClusterConfigCache) getClusterConfigUncached(ctx context.Context, clusterClient ClusterClient) (*ClusterConfig, error) { pingResp, err := clusterClient.CurrentCluster().Ping(ctx) if err != nil { return nil, trace.Wrap(err) @@ -140,11 +136,11 @@ func (c *clusterConfigCache) getClusterConfigUncached(ctx context.Context, clust ipv4CIDRRange = cmp.Or(vnetConfig.GetSpec().GetIpv4CidrRange(), defaultIPv4CIDRRange) } - return &clusterConfig{ - clusterName: clusterName, - proxyPublicAddr: proxyPublicAddr, - dnsZones: dnsZones, - ipv4CIDRRange: ipv4CIDRRange, - expires: c.clock.Now().Add(5 * time.Minute), + return &ClusterConfig{ + ClusterName: clusterName, + ProxyPublicAddr: proxyPublicAddr, + DNSZones: dnsZones, + IPv4CIDRRange: ipv4CIDRRange, + Expires: c.clock.Now().Add(5 * time.Minute), }, nil } diff --git a/lib/vnet/osconfig.go b/lib/vnet/osconfig.go index 0d77606f23e29..c080327e17cb9 100644 --- a/lib/vnet/osconfig.go +++ b/lib/vnet/osconfig.go @@ -42,7 +42,7 @@ type osConfig struct { type osConfigurator struct { clientStore *client.Store - clusterConfigCache *clusterConfigCache + clusterConfigCache *ClusterConfigCache tunName string tunIPv6 string dnsAddr string @@ -68,7 +68,7 @@ func newOSConfigurator(tunName, ipv6Prefix, dnsAddr string) (*osConfigurator, er homePath: homePath, clientStore: client.NewFSClientStore(homePath), } - configurator.clusterConfigCache = newClusterConfigCache(configurator.getClusterClient, clockwork.NewRealClock()) + configurator.clusterConfigCache = NewClusterConfigCache(clockwork.NewRealClock()) return configurator, nil } @@ -89,7 +89,7 @@ func (c *osConfigurator) updateOSConfiguration(ctx context.Context) error { "profile", profileName, "error", err) continue } - clusterConfig, err := c.clusterConfigCache.getClusterConfig(ctx, rootClient) + clusterConfig, err := c.clusterConfigCache.GetClusterConfig(ctx, rootClient) if err != nil { slog.WarnContext(ctx, "Failed to load VNet configuration, profile may be expired, not configuring VNet for this cluster", @@ -97,13 +97,13 @@ func (c *osConfigurator) updateOSConfiguration(ctx context.Context) error { continue } - dnsZones = append(dnsZones, clusterConfig.dnsZones...) - cidrRanges = append(cidrRanges, clusterConfig.ipv4CIDRRange) + dnsZones = append(dnsZones, clusterConfig.DNSZones...) + cidrRanges = append(cidrRanges, clusterConfig.IPv4CIDRRange) leafClusters, err := getLeafClusters(ctx, rootClient) if err != nil { slog.WarnContext(ctx, - "Failed to list leaf clusters, profile may be expired, not configuring VNet for this cluster", + "Failed to list leaf clusters, profile may be expired, not configuring VNet for leaf clusters of this cluster", "profile", profileName, "error", err) continue } @@ -116,7 +116,7 @@ func (c *osConfigurator) updateOSConfiguration(ctx context.Context) error { continue } - clusterConfig, err := c.clusterConfigCache.getClusterConfig(ctx, clusterClient) + clusterConfig, err := c.clusterConfigCache.GetClusterConfig(ctx, clusterClient) if err != nil { slog.WarnContext(ctx, "Failed to load VNet configuration, not configuring VNet for this cluster", @@ -124,8 +124,8 @@ func (c *osConfigurator) updateOSConfiguration(ctx context.Context) error { continue } - dnsZones = append(dnsZones, clusterConfig.dnsZones...) - cidrRanges = append(cidrRanges, clusterConfig.ipv4CIDRRange) + dnsZones = append(dnsZones, clusterConfig.DNSZones...) + cidrRanges = append(cidrRanges, clusterConfig.IPv4CIDRRange) } } diff --git a/lib/vnet/setup.go b/lib/vnet/setup.go index fe9ee88a78357..64eaca6368498 100644 --- a/lib/vnet/setup.go +++ b/lib/vnet/setup.go @@ -38,7 +38,11 @@ import ( // ctx is used to wait for setup steps that happen before SetupAndRun hands out the control to the // process manager. If ctx gets canceled during SetupAndRun, the process manager gets closed along // with its background tasks. -func SetupAndRun(ctx context.Context, appProvider AppProvider) (*ProcessManager, error) { +func SetupAndRun(ctx context.Context, config *SetupAndRunConfig) (*ProcessManager, error) { + if err := config.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + ipv6Prefix, err := NewIPv6Prefix() if err != nil { return nil, trace.Wrap(err) @@ -100,7 +104,8 @@ func SetupAndRun(ctx context.Context, appProvider AppProvider) (*ProcessManager, } } - appResolver, err := NewTCPAppResolver(appProvider) + appResolver, err := NewTCPAppResolver(config.AppProvider, + WithClusterConfigCache(config.ClusterConfigCache)) if err != nil { return nil, trace.Wrap(err) } @@ -123,6 +128,23 @@ func SetupAndRun(ctx context.Context, appProvider AppProvider) (*ProcessManager, return pm, nil } +// SetupAndRunConfig provides collaborators for the [SetupAndRun] function. +type SetupAndRunConfig struct { + // AppProvider is a required field providing an interface implementation for [AppProvider]. + AppProvider AppProvider + // ClusterConfigCache is an optional field providing [ClusterConfigCache]. If empty, a new cache + // will be created. + ClusterConfigCache *ClusterConfigCache +} + +func (c *SetupAndRunConfig) CheckAndSetDefaults() error { + if c.AppProvider == nil { + return trace.BadParameter("missing AppProvider") + } + + return nil +} + func newProcessManager() (*ProcessManager, context.Context) { ctx, cancel := context.WithCancel(context.Background()) g, ctx := errgroup.WithContext(ctx) diff --git a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto index 7b1cd1b6bc386..70c7ec795c505 100644 --- a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto +++ b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto @@ -26,6 +26,16 @@ service VnetService { rpc Start(StartRequest) returns (StartResponse); // 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); } // Request for Start. @@ -39,3 +49,12 @@ 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; +} diff --git a/tool/tsh/common/vnet_darwin.go b/tool/tsh/common/vnet_darwin.go index 8a396fabdc585..0bb5592f7147c 100644 --- a/tool/tsh/common/vnet_darwin.go +++ b/tool/tsh/common/vnet_darwin.go @@ -44,7 +44,7 @@ func (c *vnetCommand) run(cf *CLIConf) error { return trace.Wrap(err) } - processManager, err := vnet.SetupAndRun(cf.Context, appProvider) + processManager, err := vnet.SetupAndRun(cf.Context, &vnet.SetupAndRunConfig{AppProvider: appProvider}) if err != nil { return trace.Wrap(err) } diff --git a/web/packages/design/src/keyframes.ts b/web/packages/design/src/keyframes.ts index f89f6511c07ee..c49799db9f67f 100644 --- a/web/packages/design/src/keyframes.ts +++ b/web/packages/design/src/keyframes.ts @@ -32,3 +32,17 @@ export const rotate360 = keyframes` from { transform: rotate(0deg); } to { transform: rotate(360deg); } `; + +// The animation should start from 100% opacity so that a transition from non-blinking state to a +// blinking state isn't abrupt. +export const blink = keyframes` + 0% { + opacity: 100%; + } + 50% { + opacity: 0; + } + 100% { + opacity: 100%; + } + `; diff --git a/web/packages/shared/hooks/useAsync.test.ts b/web/packages/shared/hooks/useAsync.test.ts index c0bee77515a11..881268e302d19 100644 --- a/web/packages/shared/hooks/useAsync.test.ts +++ b/web/packages/shared/hooks/useAsync.test.ts @@ -18,7 +18,14 @@ import { renderHook, act, waitFor } from '@testing-library/react'; -import { useAsync, CanceledError } from './useAsync'; +import { wait } from 'shared/utils/wait'; + +import { + useAsync, + CanceledError, + Attempt, + useDelayedRepeatedAttempt, +} from './useAsync'; test('run returns a promise which resolves with the attempt data', async () => { const returnValue = Symbol(); @@ -212,3 +219,161 @@ test('error and statusText are set when the callback returns a rejected promise' // The promise returned from run always succeeds, but any errors are captured as the second arg. await expect(runPromise).resolves.toEqual([null, expectedError]); }); + +describe('useDelayedRepeatedAttempt', () => { + it('does not update attempt status if it resolves before delay', async () => { + let resolve: (symbol: symbol) => void; + const { result } = renderHook(() => { + const [eagerAttempt, run] = useAsync(() => { + // Resolve to a symbol so that successful attempts are not equal to each other. + return new Promise(res => { + resolve = res; + }); + }); + const delayedAttempt = useDelayedRepeatedAttempt(eagerAttempt, 500); + + return { run, delayedAttempt }; + }); + + await act(async () => { + const promise = result.current.run(); + resolve(Symbol()); + await promise; + }); + + expect(result.current.delayedAttempt.status).toEqual('success'); + const oldDelayedAttempt = result.current.delayedAttempt; + + act(() => { + // Start promise but do not await it, instead wait for attempt updates. This ensures that we + // catch any state updates caused by the promise being resolved. + result.current.run(); + resolve(Symbol()); + }); + + // Wait for delayedAttempt to get updated. + let nextDelayedAttempt: Attempt; + await waitFor( + () => { + // result.current always returns the current result. Capture the attempt that's being + // compared within waitFor so that we can check its status outside of the block and be sure + // that it's the same attempt. + nextDelayedAttempt = result.current.delayedAttempt; + expect(nextDelayedAttempt).not.toBe(oldDelayedAttempt); + }, + { + onTimeout: error => { + error.message = + 'delayedAttempt did not get updated within timeout. ' + + `This might mean that the logic for detecting attempt updates is incorrect.\n${error.message}`; + return error; + }, + } + ); + + // As the promise was resolved before the delay, the attempt status should still be success. + expect(nextDelayedAttempt.status).toEqual('success'); + }); + + it('updates attempt status to processing if it does not resolve before delay', async () => { + let resolve: (symbol: symbol) => void; + const { result } = renderHook(() => { + const [eagerAttempt, run] = useAsync(() => { + // Resolve to a symbol so that successful attempts are not equal to each other. + return new Promise(res => { + resolve = res; + }); + }); + const delayedAttempt = useDelayedRepeatedAttempt(eagerAttempt, 100); + + return { run, delayedAttempt }; + }); + + await act(async () => { + const promise = result.current.run(); + resolve(Symbol()); + await promise; + }); + + expect(result.current.delayedAttempt.status).toEqual('success'); + const oldDelayedAttempt = result.current.delayedAttempt; + + let promise: Promise<[symbol, Error]>; + act(() => { + // Start promise but do not resolve it. + promise = result.current.run(); + }); + + // Wait for delayedAttempt to get updated. + let nextDelayedAttempt: Attempt; + await waitFor(() => { + // result.current always returns the current result. Capture the attempt that's being compared + // within waitFor so that we can check its status outside of the block and be sure that it's + // the same attempt. + nextDelayedAttempt = result.current.delayedAttempt; + expect(nextDelayedAttempt).not.toBe(oldDelayedAttempt); + }); + expect(nextDelayedAttempt.status).toEqual('processing'); + + await act(async () => { + // Resolve the promise after the status was updated to processing. + resolve(Symbol()); + await promise; + }); + expect(result.current.delayedAttempt.status).toEqual('success'); + }); + + it('cancels pending update', async () => { + const delayMs = 100; + let resolve: (symbol: symbol) => void; + const { result } = renderHook(() => { + const [eagerAttempt, run] = useAsync(() => { + // Resolve to a symbol so that successful attempts are not equal to each other. + return new Promise(res => { + resolve = res; + }); + }); + const delayedAttempt = useDelayedRepeatedAttempt(eagerAttempt, delayMs); + + return { run, delayedAttempt, eagerAttempt }; + }); + + await act(async () => { + const promise = result.current.run(); + resolve(Symbol()); + await promise; + }); + + expect(result.current.delayedAttempt.status).toEqual('success'); + + let promise: Promise<[symbol, Error]>; + act(() => { + // Start promise but do not resolve it. + promise = result.current.run(); + }); + + // The _eager_ attempt gets updated to a processing state. + // This means that the hook now enqueued a delayed update of delayedAttempt. + expect(result.current.eagerAttempt.status).toEqual('processing'); + expect(result.current.delayedAttempt.status).toEqual('success'); + + await act(async () => { + // Resolve the promise. This transitions eagerAttempt and delayedAttempt to a success state. + resolve(Symbol()); + await promise; + }); + + expect(result.current.eagerAttempt.status).toEqual('success'); + expect(result.current.delayedAttempt.status).toEqual('success'); + + // Wait until the delay. If the pending update was not properly canceled, this should execute + // it. As such, this will count as a state update outside of `act`, which will surface an error. + // + // In case the update does not get canceled, delayedAttempt will not get updated to pending. + // The pending update will call setCurrentAttempt, which will set currentAttempt to processing. + // The hook will reexecute and the effect will set currentAttempt back to success since + // currentAttempt != attempt. This is all because at the time when the pending update gets + // erroneously executed, eagerAttempt is already successful. + await wait(delayMs); + }); +}); diff --git a/web/packages/shared/hooks/useAsync.ts b/web/packages/shared/hooks/useAsync.ts index f546cff8df2eb..9eeb2dca639e1 100644 --- a/web/packages/shared/hooks/useAsync.ts +++ b/web/packages/shared/hooks/useAsync.ts @@ -47,7 +47,6 @@ import { useCallback, useState, useRef, useEffect } from 'react'; * return { fetchUserProfileAttempt, fetchUserProfile }; * } * - * * @example In the view layer you can use it like this: * function UserProfile(props) { * const { fetchUserProfileAttempt, fetchUserProfile } = useUserProfile(props.id); @@ -274,3 +273,41 @@ export function mapAttempt( data: mapFunction(attempt.data), }; } + +/** + * useDelayedRepeatedAttempt makes it so that on repeated calls to `run`, the attempt changes its + * state to 'processing' only after a delay. This can be used to mask repeated calls and + * optimistically show stale results. + * + * @example + * const [eagerFetchUserProfileAttempt, fetchUserProfile] = useAsync(async () => { + * return await fetch(`/users/${userId}`); + * }) + * const fetchUserProfileAttempt = useDelayedRepeatedAttempt(eagerFetchUserProfileAttempt, 600) + */ +export function useDelayedRepeatedAttempt( + attempt: Attempt, + delayMs = 400 +): Attempt { + const [currentAttempt, setCurrentAttempt] = useState(attempt); + + useEffect(() => { + if ( + currentAttempt.status === 'success' && + attempt.status === 'processing' + ) { + const timeout = setTimeout(() => { + setCurrentAttempt(attempt); + }, delayMs); + return () => { + clearTimeout(timeout); + }; + } + + if (currentAttempt !== attempt) { + setCurrentAttempt(attempt); + } + }, [attempt, currentAttempt, delayMs]); + + return currentAttempt; +} diff --git a/web/packages/shared/utils/wait.ts b/web/packages/shared/utils/wait.ts index 5d4c70bc32f78..3f64695227310 100644 --- a/web/packages/shared/utils/wait.ts +++ b/web/packages/shared/utils/wait.ts @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import { useRef, useEffect } from 'react'; /** Resolves after a given duration. */ export function wait(ms: number, abortSignal?: AbortSignal): Promise { @@ -51,3 +52,23 @@ export function waitForever(abortSignal: AbortSignal): Promise { abortSignal.addEventListener('abort', abort, { once: true }); }); } + +/** + * usePromiseRejectedOnUnmount is useful when writing stories for loading states. + */ +export const usePromiseRejectedOnUnmount = () => { + const abortControllerRef = useRef(new AbortController()); + + useEffect(() => { + return () => { + abortControllerRef.current.abort(); + }; + }, []); + + const promiseRef = useRef>(); + if (!promiseRef.current) { + promiseRef.current = waitForever(abortControllerRef.current.signal); + } + + return promiseRef.current; +}; diff --git a/web/packages/teleterm/src/services/tshd/cloneableClient.ts b/web/packages/teleterm/src/services/tshd/cloneableClient.ts index 725d9a9298bd2..117ad7053e897 100644 --- a/web/packages/teleterm/src/services/tshd/cloneableClient.ts +++ b/web/packages/teleterm/src/services/tshd/cloneableClient.ts @@ -409,9 +409,17 @@ export class MockedUnaryCall onrejected?: (reason: any) => TResult2 | PromiseLike ): Promise { if (this.error) { - // Despite this being an error branch, it needs to use Promise.resolve. Otherwise we'd get - // uncaught errors. See https://www.promisejs.org/implementing/#then - return Promise.resolve(onrejected(this.error)); + if (typeof onrejected === 'function') { + try { + // Despite this being an error branch, it needs to use Promise.resolve. Otherwise we'd get + // uncaught errors. See https://www.promisejs.org/implementing/#then + return Promise.resolve(onrejected(this.error)); + } catch (ex) { + return Promise.reject(ex); + } + } else { + return Promise.reject(this.error); + } } return Promise.resolve( diff --git a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts index 42c0d2f9745ca..5855cc7b19042 100644 --- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -118,4 +118,5 @@ export class MockTshClient implements TshdClient { export class MockVnetClient implements VnetClient { start = () => new MockedUnaryCall({}); stop = () => new MockedUnaryCall({}); + listDNSZones = () => new MockedUnaryCall({ dnsZones: [] }); } diff --git a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx index 53056328a2a5b..4e70e5b299427 100644 --- a/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx +++ b/web/packages/teleterm/src/ui/ConnectMyComputer/NavigationMenu.tsx @@ -18,7 +18,7 @@ import React, { forwardRef, useRef, useState } from 'react'; import styled, { css } from 'styled-components'; -import { Box, Button, Indicator, Menu, MenuItem } from 'design'; +import { Box, Button, Indicator, Menu, MenuItem, blink } from 'design'; import { Laptop, Warning } from 'design/Icon'; import { Attempt, AttemptStatus } from 'shared/hooks/useAsync'; @@ -214,19 +214,7 @@ const StyledStatus = styled(Box)<{ status: IndicatorStatus }>` border-radius: 50%; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - @keyframes blink { - 0% { - opacity: 0; - } - 50% { - opacity: 100%; - } - 100% { - opacity: 0; - } - } - - animation: blink 1.4s ease-in-out; + animation: ${blink} 1.4s ease-in-out; animation-iteration-count: ${props => props.status === 'processing' ? 'infinite' : '0'}; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx index 21b2654f8e32f..47fe727ab873f 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx @@ -150,37 +150,6 @@ export function VnetError() { ); } -export function VnetUnexpectedShutdown() { - const appContext = new MockAppContext(); - prepareAppContext(appContext); - - appContext.statePersistenceService.putState({ - ...appContext.statePersistenceService.getState(), - vnet: { autoStart: true }, - }); - appContext.workspacesService.setState(draft => { - draft.isInitialized = true; - }); - appContext.vnet.start = () => { - setTimeout(() => { - appContext.unexpectedVnetShutdownListener({ - error: 'lorem ipsum dolor sit amet', - }); - }, 0); - return new MockedUnaryCall({}); - }; - - return ( - - - - - - - - ); -} - export function WithScroll() { const appContext = new MockAppContext(); prepareAppContext(appContext); diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx index 52422d8e7008e..31384e58f7ecc 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx @@ -75,6 +75,7 @@ export function Connections() { */} . + */ + +import styled from 'styled-components'; +import { Flex, Box, Text } from 'design'; + +import { StaticListItem } from 'teleterm/ui/components/ListItem'; + +import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; + +export default { + title: 'Teleterm/TopBar/ConnectionStatusIndicator', + decorators: [ + Story => { + return ( + + + + ); + }, + ], +}; + +export const Story = () => ( + + + Block +
    + + {' '} + {text[1]} + + + {' '} + {text[2]} + + + {' '} + {text[0]} + + + {' '} + {text[3]} + + + {' '} + {text[4]} + +
+
+ + + Inline + + + {' '} + {text[1]} + + + {text[2]} + + + {text[0]} + + + {text[3]} + + + {text[4]} + + + +
+); + +const text = [ + 'Lorem ipsum', + 'Et ultrices posuere', + 'Dolor sit amet', + 'Ante ipsum primis', + 'Nec porta augue', +]; + +const ListItem = styled(StaticListItem)` + padding: ${props => props.theme.space[1]}px ${props => props.theme.space[2]}px; +`; 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 3467a38489b18..d5fd400a1ebdb 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator.tsx @@ -17,9 +17,9 @@ */ import styled, { css } from 'styled-components'; -import { Box } from 'design'; +import { Box, blink } from 'design'; -type Status = 'on' | 'off' | 'error'; +type Status = 'on' | 'off' | 'error' | 'warning' | 'processing'; export const ConnectionStatusIndicator = (props: { status: Status; @@ -37,6 +37,7 @@ const StyledStatus = styled(Box)` width: 8px; height: 8px; border-radius: 50%; + ${(props: { $status: Status; [key: string]: any }) => { const { $status, theme } = props; @@ -44,6 +45,13 @@ const StyledStatus = styled(Box)` case 'on': { return { backgroundColor: theme.colors.success.main }; } + case 'processing': { + return css` + background-color: ${props => props.theme.colors.success.main}; + animation: ${blink} 1.4s ease-in-out; + animation-iteration-count: infinite; + `; + } case 'off': { return { border: `1px solid ${theme.colors.grey[300]}` }; } @@ -72,6 +80,23 @@ const StyledStatus = styled(Box)` } `; } + case 'warning': { + return css` + color: ${theme.colors.warning.main}; + &:after { + content: '⚠'; + font-size: 12px; + + ${!props.$inline && + ` + position: absolute; + top: -1px; + left: -2px; + line-height: 8px; + `} + } + `; + } default: { $status satisfies never; } diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx new file mode 100644 index 0000000000000..4a9eb7d9ab8aa --- /dev/null +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx @@ -0,0 +1,208 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { useEffect } from 'react'; +import { Box } from 'design'; + +import { usePromiseRejectedOnUnmount } from 'shared/utils/wait'; + +import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; +import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; +import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; + +import { useVnetContext, VnetContextProvider } from './vnetContext'; +import { VnetSliderStep } from './VnetSliderStep'; + +export default { + title: 'Teleterm/Vnet/VnetSliderStep', + decorators: [ + Story => { + return ( + + + + ); + }, + ], +}; + +const dnsZones = ['teleport.example.com', 'company.test']; + +export function Running() { + const appContext = new MockAppContext(); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.vnet.listDNSZones = () => new MockedUnaryCall({ dnsZones }); + + return ( + + + + + + ); +} + +export function UpdatingDnsZones() { + const appContext = new MockAppContext(); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + const promise = usePromiseRejectedOnUnmount(); + appContext.vnet.listDNSZones = () => promise; + + return ( + + + + + + ); +} + +export function UpdatingDnsZonesWithPreviousResults() { + const appContext = new MockAppContext(); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + const promise = usePromiseRejectedOnUnmount(); + let firstCall = true; + appContext.vnet.listDNSZones = () => { + if (firstCall) { + firstCall = false; + return new MockedUnaryCall({ dnsZones }); + } + return promise; + }; + + return ( + + + + + + + ); +} + +const RerequestDNSZones = () => { + const { listDNSZones, listDNSZonesAttempt } = useVnetContext(); + + useEffect(() => { + if (listDNSZonesAttempt.status === 'success') { + listDNSZones(); + } + }, [listDNSZonesAttempt, listDNSZones]); + + return null; +}; + +export function DnsZonesError() { + const appContext = new MockAppContext(); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.vnet.listDNSZones = () => + new MockedUnaryCall(undefined, new Error('something went wrong')); + + return ( + + + + + + ); +} + +export function StartError() { + const appContext = new MockAppContext(); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.vnet.start = () => + new MockedUnaryCall(undefined, new Error('something went wrong')); + + return ( + + + + + + ); +} + +export function UnexpectedShutdown() { + const appContext = new MockAppContext(); + + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true }, + }); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.vnet.start = () => { + setTimeout(() => { + appContext.unexpectedVnetShutdownListener({ + error: 'lorem ipsum dolor sit amet', + }); + }, 0); + return new MockedUnaryCall({}); + }; + + return ( + + + + + + ); +} + +const Component = () => ( + +); + +const noop = () => {}; diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx index 697ad53a42bfa..234fe9c95344c 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx @@ -16,14 +16,13 @@ * along with this program. If not, see . */ -import { PropsWithChildren, useCallback } from 'react'; +import { PropsWithChildren, useEffect, useRef } from 'react'; import { StepComponentProps } from 'design/StepSlider'; -import { Box, Flex, Text } from 'design'; +import { Box, ButtonSecondary, Flex, Text } from 'design'; import { mergeRefs } from 'shared/libs/mergeRefs'; import { useRefAutoFocus } from 'shared/hooks'; -import * as whatwg from 'whatwg-url'; +import { useDelayedRepeatedAttempt } from 'shared/hooks/useAsync'; -import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector'; import { ConnectionStatusIndicator } from 'teleterm/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator'; import { useVnetContext } from './vnetContext'; @@ -39,16 +38,6 @@ export const VnetSliderStep = (props: StepComponentProps) => { const autoFocusRef = useRefAutoFocus({ shouldFocus: visible, }); - const clusters = useStoreSelector( - 'clustersService', - useCallback(state => state.clusters, []) - ); - const rootClusters = [...clusters.values()].filter( - cluster => !cluster.leaf && cluster.connected - ); - const rootProxyHostnames = rootClusters.map( - cluster => new whatwg.URL(`https://${cluster.proxyHost}`).hostname - ); return ( // Padding needs to align with the padding of the previous slider step. @@ -102,21 +91,7 @@ export const VnetSliderStep = (props: StepComponentProps) => { ))} - {status.value === 'running' && - (rootClusters.length === 0 ? ( - - No clusters connected yet, VNet is not proxying any connections. - - ) : ( - <> - {/* TODO(ravicious): Add leaf clusters and custom DNS zones when support for them - lands in VNet. */} - - Proxying - TCP connections to {rootProxyHostnames.join(', ')} - - - ))} + {status.value === 'running' && }
); }; @@ -129,3 +104,78 @@ const ErrorText = (props: PropsWithChildren) => ( {props.children} ); + +/** + * 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. + * + * 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 } = + useVnetContext(); + const listDNSZonesAttempt = useDelayedRepeatedAttempt( + eagerListDNSZonesAttempt + ); + const dnsZonesRefreshRequestedRef = useRef(false); + + useEffect(function refreshListOnOpen() { + if (!dnsZonesRefreshRequestedRef.current) { + dnsZonesRefreshRequestedRef.current = true; + listDNSZones(); + } + }, []); + + if (listDNSZonesAttempt.status === 'error') { + return ( + + + VNet is working, but Teleport Connect could not fetch DNS zones:{' '} + {listDNSZonesAttempt.statusText} + + Retry + + + ); + } + + if ( + listDNSZonesAttempt.status === '' || + (listDNSZonesAttempt.status === 'processing' && !listDNSZonesAttempt.data) + ) { + return ( + + + Updating the list of DNS zones… + + ); + } + + const dnsZones = listDNSZonesAttempt.data; + + return ( + + + {dnsZones.length === 0 ? ( + <>No clusters connected yet, VNet is not proxying any connections. + ) : ( + <>Proxying TCP connections to {dnsZones.join(', ')} + )} + + ); +}; diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx index 4db7891fc3310..3b731ab77b2fd 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -31,6 +31,7 @@ import { useAsync, Attempt } from 'shared/hooks/useAsync'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import { usePersistedState } from 'teleterm/ui/hooks/usePersistedState'; import { useStoreSelector } from 'teleterm/ui/hooks/useStoreSelector'; +import { isTshdRpcError } from 'teleterm/services/tshd'; /** * VnetContext manages the VNet instance. @@ -47,6 +48,8 @@ export type VnetContext = { startAttempt: Attempt; stop: () => Promise<[void, Error]>; stopAttempt: Attempt; + listDNSZones: () => Promise<[string[], Error]>; + listDNSZonesAttempt: Attempt; }; export type VnetStatus = @@ -81,7 +84,13 @@ export const VnetContextProvider: FC = props => { const [startAttempt, start] = useAsync( useCallback(async () => { - await vnet.start({}); + try { + await vnet.start({}); + } catch (error) { + if (!isTshdRpcError(error, 'ALREADY_EXISTS')) { + throw error; + } + } setStatus({ value: 'running' }); setAppState({ autoStart: true }); }, [vnet, setAppState]) @@ -98,6 +107,13 @@ export const VnetContextProvider: FC = props => { }, [vnet, setAppState]) ); + const [listDNSZonesAttempt, listDNSZones] = useAsync( + useCallback( + () => vnet.listDNSZones({}).then(({ response }) => response.dnsZones), + [vnet] + ) + ); + useEffect(() => { const handleAutoStart = async () => { if ( @@ -151,6 +167,8 @@ export const VnetContextProvider: FC = props => { startAttempt, stop, stopAttempt, + listDNSZones, + listDNSZonesAttempt, }} > {props.children}