diff --git a/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service.pb.go b/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service.pb.go index 3b8322ce9b0ae..c3cfd3d345255 100644 --- a/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service.pb.go @@ -37,6 +37,62 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Source of the config. +type ConfigSource int32 + +const ( + ConfigSource_CONFIG_SOURCE_UNSPECIFIED ConfigSource = 0 + // Configuration comes from an environment variable. + ConfigSource_CONFIG_SOURCE_ENV_VAR ConfigSource = 1 + // Configuration comes from SOFTWARE\Policies\Teleport\TeleportConnect. + ConfigSource_CONFIG_SOURCE_POLICY ConfigSource = 2 + // Configuration comes from a hardcoded default. + ConfigSource_CONFIG_SOURCE_DEFAULT ConfigSource = 3 +) + +// Enum value maps for ConfigSource. +var ( + ConfigSource_name = map[int32]string{ + 0: "CONFIG_SOURCE_UNSPECIFIED", + 1: "CONFIG_SOURCE_ENV_VAR", + 2: "CONFIG_SOURCE_POLICY", + 3: "CONFIG_SOURCE_DEFAULT", + } + ConfigSource_value = map[string]int32{ + "CONFIG_SOURCE_UNSPECIFIED": 0, + "CONFIG_SOURCE_ENV_VAR": 1, + "CONFIG_SOURCE_POLICY": 2, + "CONFIG_SOURCE_DEFAULT": 3, + } +) + +func (x ConfigSource) Enum() *ConfigSource { + p := new(ConfigSource) + *p = x + return p +} + +func (x ConfigSource) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ConfigSource) Descriptor() protoreflect.EnumDescriptor { + return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_enumTypes[0].Descriptor() +} + +func (ConfigSource) Type() protoreflect.EnumType { + return &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_enumTypes[0] +} + +func (x ConfigSource) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ConfigSource.Descriptor instead. +func (ConfigSource) EnumDescriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescGZIP(), []int{0} +} + // Request for GetClusterVersions. type GetClusterVersionsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -255,27 +311,27 @@ func (x *UnreachableCluster) GetErrorMessage() string { return "" } -// Request for GetDownloadBaseUrl. -type GetDownloadBaseUrlRequest struct { +// Request for GetConfig. +type GetConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *GetDownloadBaseUrlRequest) Reset() { - *x = GetDownloadBaseUrlRequest{} +func (x *GetConfigRequest) Reset() { + *x = GetConfigRequest{} mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GetDownloadBaseUrlRequest) String() string { +func (x *GetConfigRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetDownloadBaseUrlRequest) ProtoMessage() {} +func (*GetConfigRequest) ProtoMessage() {} -func (x *GetDownloadBaseUrlRequest) ProtoReflect() protoreflect.Message { +func (x *GetConfigRequest) ProtoReflect() protoreflect.Message { mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -287,33 +343,50 @@ func (x *GetDownloadBaseUrlRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GetDownloadBaseUrlRequest.ProtoReflect.Descriptor instead. -func (*GetDownloadBaseUrlRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use GetConfigRequest.ProtoReflect.Descriptor instead. +func (*GetConfigRequest) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescGZIP(), []int{4} } -// Response for GetDownloadBaseUrl. -type GetDownloadBaseUrlResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - BaseUrl string `protobuf:"bytes,1,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` +// Response for GetConfig. +type GetConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A base URL (e.g. cdn.teleport.dev) for downloading packages. + // Sources resolved by platform: + // * macOS/Linux: The `TELEPORT_CDN_BASE_URL` environment variable. + // * Windows: The `CdnBaseUrl` value in the system registry (SOFTWARE\Policies\Teleport\TeleportConnect). + // - HKEY_LOCAL_MACHINE (Machine Policy): Takes precedence over user policies. + // - HKEY_CURRENT_USER (User Policy): Used only for per-user installations if no machine policy is defined. + // - If policy values are missing, falls back to `TELEPORT_CDN_BASE_URL` (deprecated). + // + // Note: OSS builds require this to be set (via env var or registry), otherwise an empty value is returned. + CdnBaseUrl *ConfigValue `protobuf:"bytes,1,opt,name=cdn_base_url,json=cdnBaseUrl,proto3" json:"cdn_base_url,omitempty"` + // The specific client tools version to use. The 'off' value disables automatic updates. + // Sources resolved by platform: + // * macOS/Linux: The `TELEPORT_TOOLS_VERSION` environment variable. + // * Windows: The `ToolsVersion` value in the system registry (SOFTWARE\Policies\Teleport\TeleportConnect). + // - HKEY_LOCAL_MACHINE (Machine Policy): Takes precedence over user policies. + // - HKEY_CURRENT_USER (User Policy): Used only for per-user installations if no machine policy is defined. + // - If policy values are missing, falls back to `TELEPORT_TOOLS_VERSION` (deprecated). + ToolsVersion *ConfigValue `protobuf:"bytes,2,opt,name=tools_version,json=toolsVersion,proto3" json:"tools_version,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *GetDownloadBaseUrlResponse) Reset() { - *x = GetDownloadBaseUrlResponse{} +func (x *GetConfigResponse) Reset() { + *x = GetConfigResponse{} mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GetDownloadBaseUrlResponse) String() string { +func (x *GetConfigResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetDownloadBaseUrlResponse) ProtoMessage() {} +func (*GetConfigResponse) ProtoMessage() {} -func (x *GetDownloadBaseUrlResponse) ProtoReflect() protoreflect.Message { +func (x *GetConfigResponse) ProtoReflect() protoreflect.Message { mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -325,18 +398,162 @@ func (x *GetDownloadBaseUrlResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GetDownloadBaseUrlResponse.ProtoReflect.Descriptor instead. -func (*GetDownloadBaseUrlResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use GetConfigResponse.ProtoReflect.Descriptor instead. +func (*GetConfigResponse) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescGZIP(), []int{5} } -func (x *GetDownloadBaseUrlResponse) GetBaseUrl() string { +func (x *GetConfigResponse) GetCdnBaseUrl() *ConfigValue { + if x != nil { + return x.CdnBaseUrl + } + return nil +} + +func (x *GetConfigResponse) GetToolsVersion() *ConfigValue { + if x != nil { + return x.ToolsVersion + } + return nil +} + +// Contains the config value and its source. +type ConfigValue struct { + state protoimpl.MessageState `protogen:"open.v1"` + Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` + // Source of the config. + Source ConfigSource `protobuf:"varint,2,opt,name=source,proto3,enum=teleport.lib.teleterm.auto_update.v1.ConfigSource" json:"source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigValue) Reset() { + *x = ConfigValue{} + mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigValue) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigValue) ProtoMessage() {} + +func (x *ConfigValue) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[6] + 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 ConfigValue.ProtoReflect.Descriptor instead. +func (*ConfigValue) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescGZIP(), []int{6} +} + +func (x *ConfigValue) GetValue() string { if x != nil { - return x.BaseUrl + return x.Value } return "" } +func (x *ConfigValue) GetSource() ConfigSource { + if x != nil { + return x.Source + } + return ConfigSource_CONFIG_SOURCE_UNSPECIFIED +} + +// Request for GetInstallationMetadata. +type GetInstallationMetadataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInstallationMetadataRequest) Reset() { + *x = GetInstallationMetadataRequest{} + mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInstallationMetadataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInstallationMetadataRequest) ProtoMessage() {} + +func (x *GetInstallationMetadataRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[7] + 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 GetInstallationMetadataRequest.ProtoReflect.Descriptor instead. +func (*GetInstallationMetadataRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescGZIP(), []int{7} +} + +// Response for GetInstallationMetadata. +type GetInstallationMetadataResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Determines whether updates should target a per-machine installation. + IsPerMachineInstall bool `protobuf:"varint,1,opt,name=is_per_machine_install,json=isPerMachineInstall,proto3" json:"is_per_machine_install,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInstallationMetadataResponse) Reset() { + *x = GetInstallationMetadataResponse{} + mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInstallationMetadataResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInstallationMetadataResponse) ProtoMessage() {} + +func (x *GetInstallationMetadataResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_auto_update_v1_auto_update_service_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 GetInstallationMetadataResponse.ProtoReflect.Descriptor instead. +func (*GetInstallationMetadataResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescGZIP(), []int{8} +} + +func (x *GetInstallationMetadataResponse) GetIsPerMachineInstall() bool { + if x != nil { + return x.IsPerMachineInstall + } + return false +} + var File_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto protoreflect.FileDescriptor const file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDesc = "" + @@ -355,13 +572,27 @@ const file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDes "\x12UnreachableCluster\x12\x1f\n" + "\vcluster_uri\x18\x01 \x01(\tR\n" + "clusterUri\x12#\n" + - "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"\x1b\n" + - "\x19GetDownloadBaseUrlRequest\"7\n" + - "\x1aGetDownloadBaseUrlResponse\x12\x19\n" + - "\bbase_url\x18\x01 \x01(\tR\abaseUrl2\xc7\x02\n" + + "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"\x12\n" + + "\x10GetConfigRequest\"\xc0\x01\n" + + "\x11GetConfigResponse\x12S\n" + + "\fcdn_base_url\x18\x01 \x01(\v21.teleport.lib.teleterm.auto_update.v1.ConfigValueR\n" + + "cdnBaseUrl\x12V\n" + + "\rtools_version\x18\x02 \x01(\v21.teleport.lib.teleterm.auto_update.v1.ConfigValueR\ftoolsVersion\"o\n" + + "\vConfigValue\x12\x14\n" + + "\x05value\x18\x01 \x01(\tR\x05value\x12J\n" + + "\x06source\x18\x02 \x01(\x0e22.teleport.lib.teleterm.auto_update.v1.ConfigSourceR\x06source\" \n" + + "\x1eGetInstallationMetadataRequest\"V\n" + + "\x1fGetInstallationMetadataResponse\x123\n" + + "\x16is_per_machine_install\x18\x01 \x01(\bR\x13isPerMachineInstall*}\n" + + "\fConfigSource\x12\x1d\n" + + "\x19CONFIG_SOURCE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15CONFIG_SOURCE_ENV_VAR\x10\x01\x12\x18\n" + + "\x14CONFIG_SOURCE_POLICY\x10\x02\x12\x19\n" + + "\x15CONFIG_SOURCE_DEFAULT\x10\x032\xd4\x03\n" + "\x11AutoUpdateService\x12\x97\x01\n" + - "\x12GetClusterVersions\x12?.teleport.lib.teleterm.auto_update.v1.GetClusterVersionsRequest\x1a@.teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse\x12\x97\x01\n" + - "\x12GetDownloadBaseUrl\x12?.teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest\x1a@.teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponseBcZagithub.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/auto_update/v1;auto_updatev1b\x06proto3" + "\x12GetClusterVersions\x12?.teleport.lib.teleterm.auto_update.v1.GetClusterVersionsRequest\x1a@.teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse\x12|\n" + + "\tGetConfig\x126.teleport.lib.teleterm.auto_update.v1.GetConfigRequest\x1a7.teleport.lib.teleterm.auto_update.v1.GetConfigResponse\x12\xa6\x01\n" + + "\x17GetInstallationMetadata\x12D.teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest\x1aE.teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponseBcZagithub.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/auto_update/v1;auto_updatev1b\x06proto3" var ( file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescOnce sync.Once @@ -375,27 +606,37 @@ func file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDesc return file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDescData } -var file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_goTypes = []any{ - (*GetClusterVersionsRequest)(nil), // 0: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsRequest - (*GetClusterVersionsResponse)(nil), // 1: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse - (*ClusterVersionInfo)(nil), // 2: teleport.lib.teleterm.auto_update.v1.ClusterVersionInfo - (*UnreachableCluster)(nil), // 3: teleport.lib.teleterm.auto_update.v1.UnreachableCluster - (*GetDownloadBaseUrlRequest)(nil), // 4: teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest - (*GetDownloadBaseUrlResponse)(nil), // 5: teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponse + (ConfigSource)(0), // 0: teleport.lib.teleterm.auto_update.v1.ConfigSource + (*GetClusterVersionsRequest)(nil), // 1: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsRequest + (*GetClusterVersionsResponse)(nil), // 2: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse + (*ClusterVersionInfo)(nil), // 3: teleport.lib.teleterm.auto_update.v1.ClusterVersionInfo + (*UnreachableCluster)(nil), // 4: teleport.lib.teleterm.auto_update.v1.UnreachableCluster + (*GetConfigRequest)(nil), // 5: teleport.lib.teleterm.auto_update.v1.GetConfigRequest + (*GetConfigResponse)(nil), // 6: teleport.lib.teleterm.auto_update.v1.GetConfigResponse + (*ConfigValue)(nil), // 7: teleport.lib.teleterm.auto_update.v1.ConfigValue + (*GetInstallationMetadataRequest)(nil), // 8: teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest + (*GetInstallationMetadataResponse)(nil), // 9: teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponse } var file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_depIdxs = []int32{ - 2, // 0: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse.reachable_clusters:type_name -> teleport.lib.teleterm.auto_update.v1.ClusterVersionInfo - 3, // 1: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse.unreachable_clusters:type_name -> teleport.lib.teleterm.auto_update.v1.UnreachableCluster - 0, // 2: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetClusterVersions:input_type -> teleport.lib.teleterm.auto_update.v1.GetClusterVersionsRequest - 4, // 3: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetDownloadBaseUrl:input_type -> teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest - 1, // 4: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetClusterVersions:output_type -> teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse - 5, // 5: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetDownloadBaseUrl:output_type -> teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponse - 4, // [4:6] is the sub-list for method output_type - 2, // [2:4] 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 + 3, // 0: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse.reachable_clusters:type_name -> teleport.lib.teleterm.auto_update.v1.ClusterVersionInfo + 4, // 1: teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse.unreachable_clusters:type_name -> teleport.lib.teleterm.auto_update.v1.UnreachableCluster + 7, // 2: teleport.lib.teleterm.auto_update.v1.GetConfigResponse.cdn_base_url:type_name -> teleport.lib.teleterm.auto_update.v1.ConfigValue + 7, // 3: teleport.lib.teleterm.auto_update.v1.GetConfigResponse.tools_version:type_name -> teleport.lib.teleterm.auto_update.v1.ConfigValue + 0, // 4: teleport.lib.teleterm.auto_update.v1.ConfigValue.source:type_name -> teleport.lib.teleterm.auto_update.v1.ConfigSource + 1, // 5: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetClusterVersions:input_type -> teleport.lib.teleterm.auto_update.v1.GetClusterVersionsRequest + 5, // 6: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetConfig:input_type -> teleport.lib.teleterm.auto_update.v1.GetConfigRequest + 8, // 7: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetInstallationMetadata:input_type -> teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest + 2, // 8: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetClusterVersions:output_type -> teleport.lib.teleterm.auto_update.v1.GetClusterVersionsResponse + 6, // 9: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetConfig:output_type -> teleport.lib.teleterm.auto_update.v1.GetConfigResponse + 9, // 10: teleport.lib.teleterm.auto_update.v1.AutoUpdateService.GetInstallationMetadata:output_type -> teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponse + 8, // [8:11] is the sub-list for method output_type + 5, // [5:8] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_init() } @@ -408,13 +649,14 @@ func file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_init() File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDesc), len(file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_rawDesc)), - NumEnums: 0, - NumMessages: 6, + NumEnums: 1, + NumMessages: 9, NumExtensions: 0, NumServices: 1, }, GoTypes: file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_goTypes, DependencyIndexes: file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_depIdxs, + EnumInfos: file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_enumTypes, MessageInfos: file_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto_msgTypes, }.Build() File_teleport_lib_teleterm_auto_update_v1_auto_update_service_proto = out.File diff --git a/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service_grpc.pb.go b/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service_grpc.pb.go index a178f81d09a3a..afe5cf932f179 100644 --- a/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/auto_update/v1/auto_update_service_grpc.pb.go @@ -35,8 +35,9 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - AutoUpdateService_GetClusterVersions_FullMethodName = "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetClusterVersions" - AutoUpdateService_GetDownloadBaseUrl_FullMethodName = "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetDownloadBaseUrl" + AutoUpdateService_GetClusterVersions_FullMethodName = "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetClusterVersions" + AutoUpdateService_GetConfig_FullMethodName = "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetConfig" + AutoUpdateService_GetInstallationMetadata_FullMethodName = "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetInstallationMetadata" ) // AutoUpdateServiceClient is the client API for AutoUpdateService service. @@ -47,10 +48,15 @@ const ( type AutoUpdateServiceClient interface { // GetClusterVersions returns client tools versions for all clusters. GetClusterVersions(ctx context.Context, in *GetClusterVersionsRequest, opts ...grpc.CallOption) (*GetClusterVersionsResponse, error) - // GetDownloadBaseUrl returns a base URL (e.g. cdn.teleport.dev) for downloading packages. - // Can be overridden with TELEPORT_CDN_BASE_URL env var. - // OSS builds require this env var to be set, otherwise an error is returned. - GetDownloadBaseUrl(ctx context.Context, in *GetDownloadBaseUrlRequest, opts ...grpc.CallOption) (*GetDownloadBaseUrlResponse, error) + // GetConfigRequest retrieves the local auto updates configuration. + // It resolves settings using platform-specific mechanisms: + // - macOS/Linux: Environment variables. + // - Windows: System Registry policies (respecting per-machine vs. per-user installation scopes), + // with a fallback to the deprecated environment variables when policy values are not set. + GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) + // GetInstallationMetadata returns installation metadata of the currently running app instance. + // Implemented only on Windows. + GetInstallationMetadata(ctx context.Context, in *GetInstallationMetadataRequest, opts ...grpc.CallOption) (*GetInstallationMetadataResponse, error) } type autoUpdateServiceClient struct { @@ -71,10 +77,20 @@ func (c *autoUpdateServiceClient) GetClusterVersions(ctx context.Context, in *Ge return out, nil } -func (c *autoUpdateServiceClient) GetDownloadBaseUrl(ctx context.Context, in *GetDownloadBaseUrlRequest, opts ...grpc.CallOption) (*GetDownloadBaseUrlResponse, error) { +func (c *autoUpdateServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(GetDownloadBaseUrlResponse) - err := c.cc.Invoke(ctx, AutoUpdateService_GetDownloadBaseUrl_FullMethodName, in, out, cOpts...) + out := new(GetConfigResponse) + err := c.cc.Invoke(ctx, AutoUpdateService_GetConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *autoUpdateServiceClient) GetInstallationMetadata(ctx context.Context, in *GetInstallationMetadataRequest, opts ...grpc.CallOption) (*GetInstallationMetadataResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInstallationMetadataResponse) + err := c.cc.Invoke(ctx, AutoUpdateService_GetInstallationMetadata_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -89,10 +105,15 @@ func (c *autoUpdateServiceClient) GetDownloadBaseUrl(ctx context.Context, in *Ge type AutoUpdateServiceServer interface { // GetClusterVersions returns client tools versions for all clusters. GetClusterVersions(context.Context, *GetClusterVersionsRequest) (*GetClusterVersionsResponse, error) - // GetDownloadBaseUrl returns a base URL (e.g. cdn.teleport.dev) for downloading packages. - // Can be overridden with TELEPORT_CDN_BASE_URL env var. - // OSS builds require this env var to be set, otherwise an error is returned. - GetDownloadBaseUrl(context.Context, *GetDownloadBaseUrlRequest) (*GetDownloadBaseUrlResponse, error) + // GetConfigRequest retrieves the local auto updates configuration. + // It resolves settings using platform-specific mechanisms: + // - macOS/Linux: Environment variables. + // - Windows: System Registry policies (respecting per-machine vs. per-user installation scopes), + // with a fallback to the deprecated environment variables when policy values are not set. + GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) + // GetInstallationMetadata returns installation metadata of the currently running app instance. + // Implemented only on Windows. + GetInstallationMetadata(context.Context, *GetInstallationMetadataRequest) (*GetInstallationMetadataResponse, error) mustEmbedUnimplementedAutoUpdateServiceServer() } @@ -106,8 +127,11 @@ type UnimplementedAutoUpdateServiceServer struct{} func (UnimplementedAutoUpdateServiceServer) GetClusterVersions(context.Context, *GetClusterVersionsRequest) (*GetClusterVersionsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetClusterVersions not implemented") } -func (UnimplementedAutoUpdateServiceServer) GetDownloadBaseUrl(context.Context, *GetDownloadBaseUrlRequest) (*GetDownloadBaseUrlResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetDownloadBaseUrl not implemented") +func (UnimplementedAutoUpdateServiceServer) GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented") +} +func (UnimplementedAutoUpdateServiceServer) GetInstallationMetadata(context.Context, *GetInstallationMetadataRequest) (*GetInstallationMetadataResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetInstallationMetadata not implemented") } func (UnimplementedAutoUpdateServiceServer) mustEmbedUnimplementedAutoUpdateServiceServer() {} func (UnimplementedAutoUpdateServiceServer) testEmbeddedByValue() {} @@ -148,20 +172,38 @@ func _AutoUpdateService_GetClusterVersions_Handler(srv interface{}, ctx context. return interceptor(ctx, in, info, handler) } -func _AutoUpdateService_GetDownloadBaseUrl_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(GetDownloadBaseUrlRequest) +func _AutoUpdateService_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetConfigRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(AutoUpdateServiceServer).GetDownloadBaseUrl(ctx, in) + return srv.(AutoUpdateServiceServer).GetConfig(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: AutoUpdateService_GetDownloadBaseUrl_FullMethodName, + FullMethod: AutoUpdateService_GetConfig_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(AutoUpdateServiceServer).GetDownloadBaseUrl(ctx, req.(*GetDownloadBaseUrlRequest)) + return srv.(AutoUpdateServiceServer).GetConfig(ctx, req.(*GetConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AutoUpdateService_GetInstallationMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInstallationMetadataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AutoUpdateServiceServer).GetInstallationMetadata(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AutoUpdateService_GetInstallationMetadata_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AutoUpdateServiceServer).GetInstallationMetadata(ctx, req.(*GetInstallationMetadataRequest)) } return interceptor(ctx, in, info, handler) } @@ -178,8 +220,12 @@ var AutoUpdateService_ServiceDesc = grpc.ServiceDesc{ Handler: _AutoUpdateService_GetClusterVersions_Handler, }, { - MethodName: "GetDownloadBaseUrl", - Handler: _AutoUpdateService_GetDownloadBaseUrl_Handler, + MethodName: "GetConfig", + Handler: _AutoUpdateService_GetConfig_Handler, + }, + { + MethodName: "GetInstallationMetadata", + Handler: _AutoUpdateService_GetInstallationMetadata_Handler, }, }, Streams: []grpc.StreamDesc{}, 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 1e8c10790beec..0127924e83381 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 @@ -100,6 +100,60 @@ func (BackgroundItemStatus) EnumDescriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{0} } +// WindowsServiceStatus maps to service-related errors in golang.org/x/sys/windows/zerrors_windows.go. +type WindowsServiceStatus int32 + +const ( + WindowsServiceStatus_WINDOWS_SERVICE_STATUS_UNSPECIFIED WindowsServiceStatus = 0 + WindowsServiceStatus_WINDOWS_SERVICE_STATUS_OK WindowsServiceStatus = 1 + WindowsServiceStatus_WINDOWS_SERVICE_STATUS_DOES_NOT_EXIST WindowsServiceStatus = 2 + // The VNet service cannot start because its version differs from the client. + WindowsServiceStatus_WINDOWS_SERVICE_STATUS_VERSION_MISMATCH WindowsServiceStatus = 3 +) + +// Enum value maps for WindowsServiceStatus. +var ( + WindowsServiceStatus_name = map[int32]string{ + 0: "WINDOWS_SERVICE_STATUS_UNSPECIFIED", + 1: "WINDOWS_SERVICE_STATUS_OK", + 2: "WINDOWS_SERVICE_STATUS_DOES_NOT_EXIST", + 3: "WINDOWS_SERVICE_STATUS_VERSION_MISMATCH", + } + WindowsServiceStatus_value = map[string]int32{ + "WINDOWS_SERVICE_STATUS_UNSPECIFIED": 0, + "WINDOWS_SERVICE_STATUS_OK": 1, + "WINDOWS_SERVICE_STATUS_DOES_NOT_EXIST": 2, + "WINDOWS_SERVICE_STATUS_VERSION_MISMATCH": 3, + } +) + +func (x WindowsServiceStatus) Enum() *WindowsServiceStatus { + p := new(WindowsServiceStatus) + *p = x + return p +} + +func (x WindowsServiceStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WindowsServiceStatus) Descriptor() protoreflect.EnumDescriptor { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_enumTypes[1].Descriptor() +} + +func (WindowsServiceStatus) Type() protoreflect.EnumType { + return &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_enumTypes[1] +} + +func (x WindowsServiceStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WindowsServiceStatus.Descriptor instead. +func (WindowsServiceStatus) EnumDescriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{1} +} + // Request for Start. type StartRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -443,6 +497,111 @@ func (x *GetBackgroundItemStatusResponse) GetStatus() BackgroundItemStatus { return BackgroundItemStatus_BACKGROUND_ITEM_STATUS_UNSPECIFIED } +// Request for CheckInstallTimeRequirementsRequest. +type CheckInstallTimeRequirementsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckInstallTimeRequirementsRequest) Reset() { + *x = CheckInstallTimeRequirementsRequest{} + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckInstallTimeRequirementsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckInstallTimeRequirementsRequest) ProtoMessage() {} + +func (x *CheckInstallTimeRequirementsRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_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 CheckInstallTimeRequirementsRequest.ProtoReflect.Descriptor instead. +func (*CheckInstallTimeRequirementsRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{8} +} + +// Response for CheckInstallTimeRequirementsResponse. +type CheckInstallTimeRequirementsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Status: + // + // *CheckInstallTimeRequirementsResponse_WindowsServiceStatus + Status isCheckInstallTimeRequirementsResponse_Status `protobuf_oneof:"status"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckInstallTimeRequirementsResponse) Reset() { + *x = CheckInstallTimeRequirementsResponse{} + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckInstallTimeRequirementsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckInstallTimeRequirementsResponse) ProtoMessage() {} + +func (x *CheckInstallTimeRequirementsResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[9] + 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 CheckInstallTimeRequirementsResponse.ProtoReflect.Descriptor instead. +func (*CheckInstallTimeRequirementsResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{9} +} + +func (x *CheckInstallTimeRequirementsResponse) GetStatus() isCheckInstallTimeRequirementsResponse_Status { + if x != nil { + return x.Status + } + return nil +} + +func (x *CheckInstallTimeRequirementsResponse) GetWindowsServiceStatus() WindowsServiceStatus { + if x != nil { + if x, ok := x.Status.(*CheckInstallTimeRequirementsResponse_WindowsServiceStatus); ok { + return x.WindowsServiceStatus + } + } + return WindowsServiceStatus_WINDOWS_SERVICE_STATUS_UNSPECIFIED +} + +type isCheckInstallTimeRequirementsResponse_Status interface { + isCheckInstallTimeRequirementsResponse_Status() +} + +type CheckInstallTimeRequirementsResponse_WindowsServiceStatus struct { + WindowsServiceStatus WindowsServiceStatus `protobuf:"varint,1,opt,name=windows_service_status,json=windowsServiceStatus,proto3,enum=teleport.lib.teleterm.vnet.v1.WindowsServiceStatus,oneof"` +} + +func (*CheckInstallTimeRequirementsResponse_WindowsServiceStatus) isCheckInstallTimeRequirementsResponse_Status() { +} + // Request for RunDiagnostics. type RunDiagnosticsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -452,7 +611,7 @@ type RunDiagnosticsRequest struct { func (x *RunDiagnosticsRequest) Reset() { *x = RunDiagnosticsRequest{} - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[8] + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -464,7 +623,7 @@ func (x *RunDiagnosticsRequest) String() string { func (*RunDiagnosticsRequest) ProtoMessage() {} func (x *RunDiagnosticsRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[8] + 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 { @@ -477,7 +636,7 @@ func (x *RunDiagnosticsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RunDiagnosticsRequest.ProtoReflect.Descriptor instead. func (*RunDiagnosticsRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{8} + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{10} } // Response for RunDiagnostics. @@ -490,7 +649,7 @@ type RunDiagnosticsResponse struct { func (x *RunDiagnosticsResponse) Reset() { *x = RunDiagnosticsResponse{} - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[9] + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -502,7 +661,7 @@ func (x *RunDiagnosticsResponse) String() string { func (*RunDiagnosticsResponse) ProtoMessage() {} func (x *RunDiagnosticsResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[9] + 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 { @@ -515,7 +674,7 @@ func (x *RunDiagnosticsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RunDiagnosticsResponse.ProtoReflect.Descriptor instead. func (*RunDiagnosticsResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{9} + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{11} } func (x *RunDiagnosticsResponse) GetReport() *v1.Report { @@ -534,7 +693,7 @@ type AutoConfigureSSHRequest struct { func (x *AutoConfigureSSHRequest) Reset() { *x = AutoConfigureSSHRequest{} - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[10] + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -546,7 +705,7 @@ func (x *AutoConfigureSSHRequest) String() string { func (*AutoConfigureSSHRequest) ProtoMessage() {} func (x *AutoConfigureSSHRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[10] + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -559,7 +718,7 @@ func (x *AutoConfigureSSHRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AutoConfigureSSHRequest.ProtoReflect.Descriptor instead. func (*AutoConfigureSSHRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{10} + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{12} } // Response for AutoConfigureSSH. @@ -571,7 +730,7 @@ type AutoConfigureSSHResponse struct { func (x *AutoConfigureSSHResponse) Reset() { *x = AutoConfigureSSHResponse{} - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[11] + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -583,7 +742,7 @@ func (x *AutoConfigureSSHResponse) String() string { func (*AutoConfigureSSHResponse) ProtoMessage() {} func (x *AutoConfigureSSHResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[11] + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -596,7 +755,7 @@ func (x *AutoConfigureSSHResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AutoConfigureSSHResponse.ProtoReflect.Descriptor instead. func (*AutoConfigureSSHResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{11} + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{13} } var File_teleport_lib_teleterm_vnet_v1_vnet_service_proto protoreflect.FileDescriptor @@ -616,7 +775,11 @@ const file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = "" + "\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" + + "\x06status\x18\x01 \x01(\x0e23.teleport.lib.teleterm.vnet.v1.BackgroundItemStatusR\x06status\"%\n" + + "#CheckInstallTimeRequirementsRequest\"\x9d\x01\n" + + "$CheckInstallTimeRequirementsResponse\x12k\n" + + "\x16windows_service_status\x18\x01 \x01(\x0e23.teleport.lib.teleterm.vnet.v1.WindowsServiceStatusH\x00R\x14windowsServiceStatusB\b\n" + + "\x06status\"\x17\n" + "\x15RunDiagnosticsRequest\"S\n" + "\x16RunDiagnosticsResponse\x129\n" + "\x06report\x18\x01 \x01(\v2!.teleport.lib.vnet.diag.v1.ReportR\x06report\"\x19\n" + @@ -628,12 +791,18 @@ 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\xf1\x05\n" + + "$BACKGROUND_ITEM_STATUS_NOT_SUPPORTED\x10\x05*\xb5\x01\n" + + "\x14WindowsServiceStatus\x12&\n" + + "\"WINDOWS_SERVICE_STATUS_UNSPECIFIED\x10\x00\x12\x1d\n" + + "\x19WINDOWS_SERVICE_STATUS_OK\x10\x01\x12)\n" + + "%WINDOWS_SERVICE_STATUS_DOES_NOT_EXIST\x10\x02\x12+\n" + + "'WINDOWS_SERVICE_STATUS_VERSION_MISMATCH\x10\x032\x9b\a\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" + + "\x17GetBackgroundItemStatus\x12=.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest\x1a>.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse\x12\xa7\x01\n" + + "\x1cCheckInstallTimeRequirements\x12B.teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest\x1aC.teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse\x12}\n" + "\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" @@ -649,44 +818,50 @@ 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_enumTypes = make([]protoimpl.EnumInfo, 1) -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_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 14) 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 - (*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 - (*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 - (*RunDiagnosticsResponse)(nil), // 10: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse - (*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 + (BackgroundItemStatus)(0), // 0: teleport.lib.teleterm.vnet.v1.BackgroundItemStatus + (WindowsServiceStatus)(0), // 1: teleport.lib.teleterm.vnet.v1.WindowsServiceStatus + (*StartRequest)(nil), // 2: teleport.lib.teleterm.vnet.v1.StartRequest + (*StartResponse)(nil), // 3: teleport.lib.teleterm.vnet.v1.StartResponse + (*StopRequest)(nil), // 4: teleport.lib.teleterm.vnet.v1.StopRequest + (*StopResponse)(nil), // 5: teleport.lib.teleterm.vnet.v1.StopResponse + (*GetServiceInfoRequest)(nil), // 6: teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest + (*GetServiceInfoResponse)(nil), // 7: teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse + (*GetBackgroundItemStatusRequest)(nil), // 8: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest + (*GetBackgroundItemStatusResponse)(nil), // 9: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse + (*CheckInstallTimeRequirementsRequest)(nil), // 10: teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest + (*CheckInstallTimeRequirementsResponse)(nil), // 11: teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse + (*RunDiagnosticsRequest)(nil), // 12: teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest + (*RunDiagnosticsResponse)(nil), // 13: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse + (*AutoConfigureSSHRequest)(nil), // 14: teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + (*AutoConfigureSSHResponse)(nil), // 15: teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + (*v1.Report)(nil), // 16: 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 - 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 - 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 + 1, // 1: teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse.windows_service_status:type_name -> teleport.lib.teleterm.vnet.v1.WindowsServiceStatus + 16, // 2: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse.report:type_name -> teleport.lib.vnet.diag.v1.Report + 2, // 3: teleport.lib.teleterm.vnet.v1.VnetService.Start:input_type -> teleport.lib.teleterm.vnet.v1.StartRequest + 4, // 4: teleport.lib.teleterm.vnet.v1.VnetService.Stop:input_type -> teleport.lib.teleterm.vnet.v1.StopRequest + 6, // 5: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:input_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest + 8, // 6: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:input_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest + 10, // 7: teleport.lib.teleterm.vnet.v1.VnetService.CheckInstallTimeRequirements:input_type -> teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest + 12, // 8: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:input_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest + 14, // 9: teleport.lib.teleterm.vnet.v1.VnetService.AutoConfigureSSH:input_type -> teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + 3, // 10: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse + 5, // 11: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse + 7, // 12: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:output_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse + 9, // 13: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:output_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse + 11, // 14: teleport.lib.teleterm.vnet.v1.VnetService.CheckInstallTimeRequirements:output_type -> teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse + 13, // 15: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:output_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse + 15, // 16: teleport.lib.teleterm.vnet.v1.VnetService.AutoConfigureSSH:output_type -> teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + 10, // [10:17] is the sub-list for method output_type + 3, // [3:10] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_init() } @@ -694,13 +869,16 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_init() { if File_teleport_lib_teleterm_vnet_v1_vnet_service_proto != nil { return } + file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[9].OneofWrappers = []any{ + (*CheckInstallTimeRequirementsResponse_WindowsServiceStatus)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ 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: 12, + NumEnums: 2, + NumMessages: 14, 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 074693d5c6c11..a40c0b4ada577 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,12 +35,13 @@ import ( 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_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" + VnetService_Start_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Start" + VnetService_Stop_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Stop" + VnetService_GetServiceInfo_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetServiceInfo" + VnetService_GetBackgroundItemStatus_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetBackgroundItemStatus" + VnetService_CheckInstallTimeRequirements_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/CheckInstallTimeRequirements" + 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. @@ -58,6 +59,9 @@ type VnetServiceClient interface { // 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) + // CheckInstallTimeRequirements validates install-time prerequisites (for example, VNet service presence) that can + // only be changed by reinstalling the app. + CheckInstallTimeRequirements(ctx context.Context, in *CheckInstallTimeRequirementsRequest, opts ...grpc.CallOption) (*CheckInstallTimeRequirementsResponse, error) // 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) @@ -114,6 +118,16 @@ func (c *vnetServiceClient) GetBackgroundItemStatus(ctx context.Context, in *Get return out, nil } +func (c *vnetServiceClient) CheckInstallTimeRequirements(ctx context.Context, in *CheckInstallTimeRequirementsRequest, opts ...grpc.CallOption) (*CheckInstallTimeRequirementsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CheckInstallTimeRequirementsResponse) + err := c.cc.Invoke(ctx, VnetService_CheckInstallTimeRequirements_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *vnetServiceClient) RunDiagnostics(ctx context.Context, in *RunDiagnosticsRequest, opts ...grpc.CallOption) (*RunDiagnosticsResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RunDiagnosticsResponse) @@ -149,6 +163,9 @@ type VnetServiceServer interface { // 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) + // CheckInstallTimeRequirements validates install-time prerequisites (for example, VNet service presence) that can + // only be changed by reinstalling the app. + CheckInstallTimeRequirements(context.Context, *CheckInstallTimeRequirementsRequest) (*CheckInstallTimeRequirementsResponse, error) // 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) @@ -177,6 +194,9 @@ func (UnimplementedVnetServiceServer) GetServiceInfo(context.Context, *GetServic func (UnimplementedVnetServiceServer) GetBackgroundItemStatus(context.Context, *GetBackgroundItemStatusRequest) (*GetBackgroundItemStatusResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetBackgroundItemStatus not implemented") } +func (UnimplementedVnetServiceServer) CheckInstallTimeRequirements(context.Context, *CheckInstallTimeRequirementsRequest) (*CheckInstallTimeRequirementsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckInstallTimeRequirements not implemented") +} func (UnimplementedVnetServiceServer) RunDiagnostics(context.Context, *RunDiagnosticsRequest) (*RunDiagnosticsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method RunDiagnostics not implemented") } @@ -276,6 +296,24 @@ func _VnetService_GetBackgroundItemStatus_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } +func _VnetService_CheckInstallTimeRequirements_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CheckInstallTimeRequirementsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VnetServiceServer).CheckInstallTimeRequirements(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VnetService_CheckInstallTimeRequirements_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VnetServiceServer).CheckInstallTimeRequirements(ctx, req.(*CheckInstallTimeRequirementsRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _VnetService_RunDiagnostics_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(RunDiagnosticsRequest) if err := dec(in); err != nil { @@ -335,6 +373,10 @@ var VnetService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetBackgroundItemStatus", Handler: _VnetService_GetBackgroundItemStatus_Handler, }, + { + MethodName: "CheckInstallTimeRequirements", + Handler: _VnetService_CheckInstallTimeRequirements_Handler, + }, { MethodName: "RunDiagnostics", Handler: _VnetService_RunDiagnostics_Handler, diff --git a/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.client.ts b/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.client.ts index 667d930a94edf..a101c9f2915a2 100644 --- a/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.client.ts +++ b/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.client.ts @@ -23,8 +23,10 @@ import type { RpcTransport } from "@protobuf-ts/runtime-rpc"; import type { ServiceInfo } from "@protobuf-ts/runtime-rpc"; import { AutoUpdateService } from "./auto_update_service_pb"; -import type { GetDownloadBaseUrlResponse } from "./auto_update_service_pb"; -import type { GetDownloadBaseUrlRequest } from "./auto_update_service_pb"; +import type { GetInstallationMetadataResponse } from "./auto_update_service_pb"; +import type { GetInstallationMetadataRequest } from "./auto_update_service_pb"; +import type { GetConfigResponse } from "./auto_update_service_pb"; +import type { GetConfigRequest } from "./auto_update_service_pb"; import { stackIntercept } from "@protobuf-ts/runtime-rpc"; import type { GetClusterVersionsResponse } from "./auto_update_service_pb"; import type { GetClusterVersionsRequest } from "./auto_update_service_pb"; @@ -43,13 +45,22 @@ export interface IAutoUpdateServiceClient { */ getClusterVersions(input: GetClusterVersionsRequest, options?: RpcOptions): UnaryCall; /** - * GetDownloadBaseUrl returns a base URL (e.g. cdn.teleport.dev) for downloading packages. - * Can be overridden with TELEPORT_CDN_BASE_URL env var. - * OSS builds require this env var to be set, otherwise an error is returned. + * GetConfigRequest retrieves the local auto updates configuration. + * It resolves settings using platform-specific mechanisms: + * * macOS/Linux: Environment variables. + * * Windows: System Registry policies (respecting per-machine vs. per-user installation scopes), + * with a fallback to the deprecated environment variables when policy values are not set. * - * @generated from protobuf rpc: GetDownloadBaseUrl(teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest) returns (teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponse); + * @generated from protobuf rpc: GetConfig(teleport.lib.teleterm.auto_update.v1.GetConfigRequest) returns (teleport.lib.teleterm.auto_update.v1.GetConfigResponse); */ - getDownloadBaseUrl(input: GetDownloadBaseUrlRequest, options?: RpcOptions): UnaryCall; + getConfig(input: GetConfigRequest, options?: RpcOptions): UnaryCall; + /** + * GetInstallationMetadata returns installation metadata of the currently running app instance. + * Implemented only on Windows. + * + * @generated from protobuf rpc: GetInstallationMetadata(teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest) returns (teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponse); + */ + getInstallationMetadata(input: GetInstallationMetadataRequest, options?: RpcOptions): UnaryCall; } /** * AutoUpdateService provides access to information about client tools updates. @@ -72,14 +83,26 @@ export class AutoUpdateServiceClient implements IAutoUpdateServiceClient, Servic return stackIntercept("unary", this._transport, method, opt, input); } /** - * GetDownloadBaseUrl returns a base URL (e.g. cdn.teleport.dev) for downloading packages. - * Can be overridden with TELEPORT_CDN_BASE_URL env var. - * OSS builds require this env var to be set, otherwise an error is returned. + * GetConfigRequest retrieves the local auto updates configuration. + * It resolves settings using platform-specific mechanisms: + * * macOS/Linux: Environment variables. + * * Windows: System Registry policies (respecting per-machine vs. per-user installation scopes), + * with a fallback to the deprecated environment variables when policy values are not set. * - * @generated from protobuf rpc: GetDownloadBaseUrl(teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest) returns (teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponse); + * @generated from protobuf rpc: GetConfig(teleport.lib.teleterm.auto_update.v1.GetConfigRequest) returns (teleport.lib.teleterm.auto_update.v1.GetConfigResponse); */ - getDownloadBaseUrl(input: GetDownloadBaseUrlRequest, options?: RpcOptions): UnaryCall { + getConfig(input: GetConfigRequest, options?: RpcOptions): UnaryCall { const method = this.methods[1], opt = this._transport.mergeOptions(options); - return stackIntercept("unary", this._transport, method, opt, input); + return stackIntercept("unary", this._transport, method, opt, input); + } + /** + * GetInstallationMetadata returns installation metadata of the currently running app instance. + * Implemented only on Windows. + * + * @generated from protobuf rpc: GetInstallationMetadata(teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest) returns (teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponse); + */ + getInstallationMetadata(input: GetInstallationMetadataRequest, 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/auto_update/v1/auto_update_service_pb.grpc-server.ts b/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.grpc-server.ts index f48a7368e2cd2..e765a4471b819 100644 --- a/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.grpc-server.ts +++ b/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.grpc-server.ts @@ -20,8 +20,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // -import { GetDownloadBaseUrlResponse } from "./auto_update_service_pb"; -import { GetDownloadBaseUrlRequest } from "./auto_update_service_pb"; +import { GetInstallationMetadataResponse } from "./auto_update_service_pb"; +import { GetInstallationMetadataRequest } from "./auto_update_service_pb"; +import { GetConfigResponse } from "./auto_update_service_pb"; +import { GetConfigRequest } from "./auto_update_service_pb"; import { GetClusterVersionsResponse } from "./auto_update_service_pb"; import { GetClusterVersionsRequest } from "./auto_update_service_pb"; import type * as grpc from "@grpc/grpc-js"; @@ -38,13 +40,22 @@ export interface IAutoUpdateService extends grpc.UntypedServiceImplementation { */ getClusterVersions: grpc.handleUnaryCall; /** - * GetDownloadBaseUrl returns a base URL (e.g. cdn.teleport.dev) for downloading packages. - * Can be overridden with TELEPORT_CDN_BASE_URL env var. - * OSS builds require this env var to be set, otherwise an error is returned. + * GetConfigRequest retrieves the local auto updates configuration. + * It resolves settings using platform-specific mechanisms: + * * macOS/Linux: Environment variables. + * * Windows: System Registry policies (respecting per-machine vs. per-user installation scopes), + * with a fallback to the deprecated environment variables when policy values are not set. * - * @generated from protobuf rpc: GetDownloadBaseUrl(teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest) returns (teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponse); + * @generated from protobuf rpc: GetConfig(teleport.lib.teleterm.auto_update.v1.GetConfigRequest) returns (teleport.lib.teleterm.auto_update.v1.GetConfigResponse); */ - getDownloadBaseUrl: grpc.handleUnaryCall; + getConfig: grpc.handleUnaryCall; + /** + * GetInstallationMetadata returns installation metadata of the currently running app instance. + * Implemented only on Windows. + * + * @generated from protobuf rpc: GetInstallationMetadata(teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest) returns (teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponse); + */ + getInstallationMetadata: grpc.handleUnaryCall; } /** * @grpc/grpc-js definition for the protobuf service teleport.lib.teleterm.auto_update.v1.AutoUpdateService. @@ -68,14 +79,24 @@ export const autoUpdateServiceDefinition: grpc.ServiceDefinition Buffer.from(GetClusterVersionsResponse.toBinary(value)), requestSerialize: value => Buffer.from(GetClusterVersionsRequest.toBinary(value)) }, - getDownloadBaseUrl: { - path: "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetDownloadBaseUrl", - originalName: "GetDownloadBaseUrl", + getConfig: { + path: "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetConfig", + originalName: "GetConfig", + requestStream: false, + responseStream: false, + responseDeserialize: bytes => GetConfigResponse.fromBinary(bytes), + requestDeserialize: bytes => GetConfigRequest.fromBinary(bytes), + responseSerialize: value => Buffer.from(GetConfigResponse.toBinary(value)), + requestSerialize: value => Buffer.from(GetConfigRequest.toBinary(value)) + }, + getInstallationMetadata: { + path: "/teleport.lib.teleterm.auto_update.v1.AutoUpdateService/GetInstallationMetadata", + originalName: "GetInstallationMetadata", requestStream: false, responseStream: false, - responseDeserialize: bytes => GetDownloadBaseUrlResponse.fromBinary(bytes), - requestDeserialize: bytes => GetDownloadBaseUrlRequest.fromBinary(bytes), - responseSerialize: value => Buffer.from(GetDownloadBaseUrlResponse.toBinary(value)), - requestSerialize: value => Buffer.from(GetDownloadBaseUrlRequest.toBinary(value)) + responseDeserialize: bytes => GetInstallationMetadataResponse.fromBinary(bytes), + requestDeserialize: bytes => GetInstallationMetadataRequest.fromBinary(bytes), + responseSerialize: value => Buffer.from(GetInstallationMetadataResponse.toBinary(value)), + requestSerialize: value => Buffer.from(GetInstallationMetadataRequest.toBinary(value)) } }; diff --git a/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.ts b/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.ts index 0e4814b83caca..19f16cd42c72e 100644 --- a/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.ts +++ b/gen/proto/ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb.ts @@ -103,22 +103,110 @@ export interface UnreachableCluster { errorMessage: string; } /** - * Request for GetDownloadBaseUrl. + * Request for GetConfig. * - * @generated from protobuf message teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest + * @generated from protobuf message teleport.lib.teleterm.auto_update.v1.GetConfigRequest */ -export interface GetDownloadBaseUrlRequest { +export interface GetConfigRequest { } /** - * Response for GetDownloadBaseUrl. + * Response for GetConfig. * - * @generated from protobuf message teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponse + * @generated from protobuf message teleport.lib.teleterm.auto_update.v1.GetConfigResponse */ -export interface GetDownloadBaseUrlResponse { +export interface GetConfigResponse { /** - * @generated from protobuf field: string base_url = 1; + * A base URL (e.g. cdn.teleport.dev) for downloading packages. + * Sources resolved by platform: + * * macOS/Linux: The `TELEPORT_CDN_BASE_URL` environment variable. + * * Windows: The `CdnBaseUrl` value in the system registry (SOFTWARE\Policies\Teleport\TeleportConnect). + * - HKEY_LOCAL_MACHINE (Machine Policy): Takes precedence over user policies. + * - HKEY_CURRENT_USER (User Policy): Used only for per-user installations if no machine policy is defined. + * - If policy values are missing, falls back to `TELEPORT_CDN_BASE_URL` (deprecated). + * + * Note: OSS builds require this to be set (via env var or registry), otherwise an empty value is returned. + * + * @generated from protobuf field: teleport.lib.teleterm.auto_update.v1.ConfigValue cdn_base_url = 1; + */ + cdnBaseUrl?: ConfigValue; + /** + * The specific client tools version to use. The 'off' value disables automatic updates. + * Sources resolved by platform: + * * macOS/Linux: The `TELEPORT_TOOLS_VERSION` environment variable. + * * Windows: The `ToolsVersion` value in the system registry (SOFTWARE\Policies\Teleport\TeleportConnect). + * - HKEY_LOCAL_MACHINE (Machine Policy): Takes precedence over user policies. + * - HKEY_CURRENT_USER (User Policy): Used only for per-user installations if no machine policy is defined. + * - If policy values are missing, falls back to `TELEPORT_TOOLS_VERSION` (deprecated). + * + * @generated from protobuf field: teleport.lib.teleterm.auto_update.v1.ConfigValue tools_version = 2; + */ + toolsVersion?: ConfigValue; +} +/** + * Contains the config value and its source. + * + * @generated from protobuf message teleport.lib.teleterm.auto_update.v1.ConfigValue + */ +export interface ConfigValue { + /** + * @generated from protobuf field: string value = 1; + */ + value: string; + /** + * Source of the config. + * + * @generated from protobuf field: teleport.lib.teleterm.auto_update.v1.ConfigSource source = 2; + */ + source: ConfigSource; +} +/** + * Request for GetInstallationMetadata. + * + * @generated from protobuf message teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest + */ +export interface GetInstallationMetadataRequest { +} +/** + * Response for GetInstallationMetadata. + * + * @generated from protobuf message teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponse + */ +export interface GetInstallationMetadataResponse { + /** + * Determines whether updates should target a per-machine installation. + * + * @generated from protobuf field: bool is_per_machine_install = 1; + */ + isPerMachineInstall: boolean; +} +/** + * Source of the config. + * + * @generated from protobuf enum teleport.lib.teleterm.auto_update.v1.ConfigSource + */ +export enum ConfigSource { + /** + * @generated from protobuf enum value: CONFIG_SOURCE_UNSPECIFIED = 0; */ - baseUrl: string; + UNSPECIFIED = 0, + /** + * Configuration comes from an environment variable. + * + * @generated from protobuf enum value: CONFIG_SOURCE_ENV_VAR = 1; + */ + ENV_VAR = 1, + /** + * Configuration comes from SOFTWARE\Policies\Teleport\TeleportConnect. + * + * @generated from protobuf enum value: CONFIG_SOURCE_POLICY = 2; + */ + POLICY = 2, + /** + * Configuration comes from a hardcoded default. + * + * @generated from protobuf enum value: CONFIG_SOURCE_DEFAULT = 3; + */ + DEFAULT = 3 } // @generated message type with reflection information, may provide speed optimized methods class GetClusterVersionsRequest$Type extends MessageType { @@ -327,20 +415,153 @@ class UnreachableCluster$Type extends MessageType { */ export const UnreachableCluster = new UnreachableCluster$Type(); // @generated message type with reflection information, may provide speed optimized methods -class GetDownloadBaseUrlRequest$Type extends MessageType { +class GetConfigRequest$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.auto_update.v1.GetConfigRequest", []); + } + create(value?: PartialMessage): GetConfigRequest { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetConfigRequest): GetConfigRequest { + return target ?? this.create(); + } + internalBinaryWrite(message: GetConfigRequest, 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.auto_update.v1.GetConfigRequest + */ +export const GetConfigRequest = new GetConfigRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GetConfigResponse$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.auto_update.v1.GetConfigResponse", [ + { no: 1, name: "cdn_base_url", kind: "message", T: () => ConfigValue }, + { no: 2, name: "tools_version", kind: "message", T: () => ConfigValue } + ]); + } + create(value?: PartialMessage): GetConfigResponse { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetConfigResponse): GetConfigResponse { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* teleport.lib.teleterm.auto_update.v1.ConfigValue cdn_base_url */ 1: + message.cdnBaseUrl = ConfigValue.internalBinaryRead(reader, reader.uint32(), options, message.cdnBaseUrl); + break; + case /* teleport.lib.teleterm.auto_update.v1.ConfigValue tools_version */ 2: + message.toolsVersion = ConfigValue.internalBinaryRead(reader, reader.uint32(), options, message.toolsVersion); + 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: GetConfigResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* teleport.lib.teleterm.auto_update.v1.ConfigValue cdn_base_url = 1; */ + if (message.cdnBaseUrl) + ConfigValue.internalBinaryWrite(message.cdnBaseUrl, writer.tag(1, WireType.LengthDelimited).fork(), options).join(); + /* teleport.lib.teleterm.auto_update.v1.ConfigValue tools_version = 2; */ + if (message.toolsVersion) + ConfigValue.internalBinaryWrite(message.toolsVersion, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + 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.auto_update.v1.GetConfigResponse + */ +export const GetConfigResponse = new GetConfigResponse$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class ConfigValue$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.auto_update.v1.ConfigValue", [ + { no: 1, name: "value", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "source", kind: "enum", T: () => ["teleport.lib.teleterm.auto_update.v1.ConfigSource", ConfigSource, "CONFIG_SOURCE_"] } + ]); + } + create(value?: PartialMessage): ConfigValue { + const message = globalThis.Object.create((this.messagePrototype!)); + message.value = ""; + message.source = 0; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ConfigValue): ConfigValue { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string value */ 1: + message.value = reader.string(); + break; + case /* teleport.lib.teleterm.auto_update.v1.ConfigSource source */ 2: + message.source = reader.int32(); + 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: ConfigValue, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string value = 1; */ + if (message.value !== "") + writer.tag(1, WireType.LengthDelimited).string(message.value); + /* teleport.lib.teleterm.auto_update.v1.ConfigSource source = 2; */ + if (message.source !== 0) + writer.tag(2, WireType.Varint).int32(message.source); + 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.auto_update.v1.ConfigValue + */ +export const ConfigValue = new ConfigValue$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class GetInstallationMetadataRequest$Type extends MessageType { constructor() { - super("teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlRequest", []); + super("teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataRequest", []); } - create(value?: PartialMessage): GetDownloadBaseUrlRequest { + create(value?: PartialMessage): GetInstallationMetadataRequest { 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?: GetDownloadBaseUrlRequest): GetDownloadBaseUrlRequest { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetInstallationMetadataRequest): GetInstallationMetadataRequest { return target ?? this.create(); } - internalBinaryWrite(message: GetDownloadBaseUrlRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + internalBinaryWrite(message: GetInstallationMetadataRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -348,30 +569,30 @@ class GetDownloadBaseUrlRequest$Type extends MessageType { +class GetInstallationMetadataResponse$Type extends MessageType { constructor() { - super("teleport.lib.teleterm.auto_update.v1.GetDownloadBaseUrlResponse", [ - { no: 1, name: "base_url", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + super("teleport.lib.teleterm.auto_update.v1.GetInstallationMetadataResponse", [ + { no: 1, name: "is_per_machine_install", kind: "scalar", T: 8 /*ScalarType.BOOL*/ } ]); } - create(value?: PartialMessage): GetDownloadBaseUrlResponse { + create(value?: PartialMessage): GetInstallationMetadataResponse { const message = globalThis.Object.create((this.messagePrototype!)); - message.baseUrl = ""; + message.isPerMachineInstall = false; if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetDownloadBaseUrlResponse): GetDownloadBaseUrlResponse { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetInstallationMetadataResponse): GetInstallationMetadataResponse { let message = target ?? this.create(), end = reader.pos + length; while (reader.pos < end) { let [fieldNo, wireType] = reader.tag(); switch (fieldNo) { - case /* string base_url */ 1: - message.baseUrl = reader.string(); + case /* bool is_per_machine_install */ 1: + message.isPerMachineInstall = reader.bool(); break; default: let u = options.readUnknownField; @@ -384,10 +605,10 @@ class GetDownloadBaseUrlResponse$Type extends MessageType; + /** + * CheckInstallTimeRequirements validates install-time prerequisites (for example, VNet service presence) that can + * only be changed by reinstalling the app. + * + * @generated from protobuf rpc: CheckInstallTimeRequirements(teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest) returns (teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse); + */ + checkInstallTimeRequirements(input: CheckInstallTimeRequirementsRequest, options?: RpcOptions): UnaryCall; /** * 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. @@ -132,6 +141,16 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { const method = this.methods[3], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } + /** + * CheckInstallTimeRequirements validates install-time prerequisites (for example, VNet service presence) that can + * only be changed by reinstalling the app. + * + * @generated from protobuf rpc: CheckInstallTimeRequirements(teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest) returns (teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse); + */ + checkInstallTimeRequirements(input: CheckInstallTimeRequirementsRequest, options?: RpcOptions): UnaryCall { + const method = this.methods[4], opt = this._transport.mergeOptions(options); + return stackIntercept("unary", this._transport, method, opt, input); + } /** * 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. @@ -139,7 +158,7 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { * @generated from protobuf rpc: RunDiagnostics(teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest) returns (teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse); */ runDiagnostics(input: RunDiagnosticsRequest, options?: RpcOptions): UnaryCall { - const method = this.methods[4], opt = this._transport.mergeOptions(options); + const method = this.methods[5], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } /** @@ -149,7 +168,7 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { * @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); + const method = this.methods[6], 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 ea46ece0fcd38..4d0760e6268b6 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,6 +24,8 @@ 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 { CheckInstallTimeRequirementsResponse } from "./vnet_service_pb"; +import { CheckInstallTimeRequirementsRequest } from "./vnet_service_pb"; import { GetBackgroundItemStatusResponse } from "./vnet_service_pb"; import { GetBackgroundItemStatusRequest } from "./vnet_service_pb"; import { GetServiceInfoResponse } from "./vnet_service_pb"; @@ -64,6 +66,13 @@ export interface IVnetService extends grpc.UntypedServiceImplementation { * @generated from protobuf rpc: GetBackgroundItemStatus(teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest) returns (teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse); */ getBackgroundItemStatus: grpc.handleUnaryCall; + /** + * CheckInstallTimeRequirements validates install-time prerequisites (for example, VNet service presence) that can + * only be changed by reinstalling the app. + * + * @generated from protobuf rpc: CheckInstallTimeRequirements(teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest) returns (teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse); + */ + checkInstallTimeRequirements: grpc.handleUnaryCall; /** * 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. @@ -131,6 +140,16 @@ export const vnetServiceDefinition: grpc.ServiceDefinition = { responseSerialize: value => Buffer.from(GetBackgroundItemStatusResponse.toBinary(value)), requestSerialize: value => Buffer.from(GetBackgroundItemStatusRequest.toBinary(value)) }, + checkInstallTimeRequirements: { + path: "/teleport.lib.teleterm.vnet.v1.VnetService/CheckInstallTimeRequirements", + originalName: "CheckInstallTimeRequirements", + requestStream: false, + responseStream: false, + responseDeserialize: bytes => CheckInstallTimeRequirementsResponse.fromBinary(bytes), + requestDeserialize: bytes => CheckInstallTimeRequirementsRequest.fromBinary(bytes), + responseSerialize: value => Buffer.from(CheckInstallTimeRequirementsResponse.toBinary(value)), + requestSerialize: value => Buffer.from(CheckInstallTimeRequirementsRequest.toBinary(value)) + }, runDiagnostics: { path: "/teleport.lib.teleterm.vnet.v1.VnetService/RunDiagnostics", originalName: "RunDiagnostics", 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 9a58c635851c0..ff76ac94363c6 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 @@ -118,6 +118,32 @@ export interface GetBackgroundItemStatusResponse { */ status: BackgroundItemStatus; } +/** + * Request for CheckInstallTimeRequirementsRequest. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest + */ +export interface CheckInstallTimeRequirementsRequest { +} +/** + * Response for CheckInstallTimeRequirementsResponse. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse + */ +export interface CheckInstallTimeRequirementsResponse { + /** + * @generated from protobuf oneof: status + */ + status: { + oneofKind: "windowsServiceStatus"; + /** + * @generated from protobuf field: teleport.lib.teleterm.vnet.v1.WindowsServiceStatus windows_service_status = 1; + */ + windowsServiceStatus: WindowsServiceStatus; + } | { + oneofKind: undefined; + }; +} /** * Request for RunDiagnostics. * @@ -185,6 +211,31 @@ export enum BackgroundItemStatus { */ NOT_SUPPORTED = 5 } +/** + * WindowsServiceStatus maps to service-related errors in golang.org/x/sys/windows/zerrors_windows.go. + * + * @generated from protobuf enum teleport.lib.teleterm.vnet.v1.WindowsServiceStatus + */ +export enum WindowsServiceStatus { + /** + * @generated from protobuf enum value: WINDOWS_SERVICE_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: WINDOWS_SERVICE_STATUS_OK = 1; + */ + OK = 1, + /** + * @generated from protobuf enum value: WINDOWS_SERVICE_STATUS_DOES_NOT_EXIST = 2; + */ + DOES_NOT_EXIST = 2, + /** + * The VNet service cannot start because its version differs from the client. + * + * @generated from protobuf enum value: WINDOWS_SERVICE_STATUS_VERSION_MISMATCH = 3; + */ + VERSION_MISMATCH = 3 +} // @generated message type with reflection information, may provide speed optimized methods class StartRequest$Type extends MessageType { constructor() { @@ -454,6 +505,81 @@ class GetBackgroundItemStatusResponse$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsRequest", []); + } + create(value?: PartialMessage): CheckInstallTimeRequirementsRequest { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: CheckInstallTimeRequirementsRequest): CheckInstallTimeRequirementsRequest { + return target ?? this.create(); + } + internalBinaryWrite(message: CheckInstallTimeRequirementsRequest, 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.CheckInstallTimeRequirementsRequest + */ +export const CheckInstallTimeRequirementsRequest = new CheckInstallTimeRequirementsRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class CheckInstallTimeRequirementsResponse$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.vnet.v1.CheckInstallTimeRequirementsResponse", [ + { no: 1, name: "windows_service_status", kind: "enum", oneof: "status", T: () => ["teleport.lib.teleterm.vnet.v1.WindowsServiceStatus", WindowsServiceStatus, "WINDOWS_SERVICE_STATUS_"] } + ]); + } + create(value?: PartialMessage): CheckInstallTimeRequirementsResponse { + const message = globalThis.Object.create((this.messagePrototype!)); + message.status = { oneofKind: undefined }; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: CheckInstallTimeRequirementsResponse): CheckInstallTimeRequirementsResponse { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* teleport.lib.teleterm.vnet.v1.WindowsServiceStatus windows_service_status */ 1: + message.status = { + oneofKind: "windowsServiceStatus", + windowsServiceStatus: reader.int32() + }; + 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: CheckInstallTimeRequirementsResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* teleport.lib.teleterm.vnet.v1.WindowsServiceStatus windows_service_status = 1; */ + if (message.status.oneofKind === "windowsServiceStatus") + writer.tag(1, WireType.Varint).int32(message.status.windowsServiceStatus); + 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.CheckInstallTimeRequirementsResponse + */ +export const CheckInstallTimeRequirementsResponse = new CheckInstallTimeRequirementsResponse$Type(); +// @generated message type with reflection information, may provide speed optimized methods class RunDiagnosticsRequest$Type extends MessageType { constructor() { super("teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest", []); @@ -582,6 +708,7 @@ 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: "CheckInstallTimeRequirements", options: {}, I: CheckInstallTimeRequirementsRequest, O: CheckInstallTimeRequirementsResponse }, { name: "RunDiagnostics", options: {}, I: RunDiagnosticsRequest, O: RunDiagnosticsResponse }, { name: "AutoConfigureSSH", options: {}, I: AutoConfigureSSHRequest, O: AutoConfigureSSHResponse } ]); diff --git a/integration/autoupdate/tools/connect_privileged_updater_windows_test.go b/integration/autoupdate/tools/connect_privileged_updater_windows_test.go new file mode 100644 index 0000000000000..f5d7f8f4566ae --- /dev/null +++ b/integration/autoupdate/tools/connect_privileged_updater_windows_test.go @@ -0,0 +1,475 @@ +/* + * Teleport + * Copyright (C) 2026 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package tools_test + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/Microsoft/go-winio" + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/utils/retryutils" + "github.com/gravitational/teleport/lib/teleterm/autoupdate/privilegedupdater" +) + +func TestPrivilegedUpdateServiceSuccess(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + err := runPrivilegedUpdaterFlow(t, up) + require.NoError(t, err) +} + +func TestPrivilegedUpdateServiceRejectsDowngrade(t *testing.T) { + up := update{ + // The version is a downgrade compared to the current api.Version. + version: "0.0.1", + binary: []byte("payload"), + } + err := runPrivilegedUpdaterFlow(t, up) + require.ErrorIs(t, err, trace.BadParameter("update version 0.0.1 is not newer than current version %s", teleport.SemVer())) +} + +func TestPrivilegedUpdateServiceRejectsChecksumMismatch(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + + otherHash := sha256.Sum256([]byte("different-payload")) + err := runPrivilegedUpdaterFlow(t, up, withChecksumServerResponseWriter(func(w http.ResponseWriter) { + _, err := w.Write([]byte(hex.EncodeToString(otherHash[:]))) + require.NoError(t, err) + })) + require.ErrorIs(t, err, trace.BadParameter("hash of the update does not match downloaded checksum")) +} + +func TestPrivilegedUpdateServiceRejectsInvalidVersionFormat(t *testing.T) { + up := update{ + version: "not-a-semver", + binary: []byte("payload"), + } + err := runPrivilegedUpdaterFlow(t, up) + require.Error(t, err) + require.Contains(t, err.Error(), `invalid update version "not-a-semver"`) +} + +func TestPrivilegedUpdateServiceRejectsChecksumRequestFailure(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + + err := runPrivilegedUpdaterFlow(t, up, withChecksumServerResponseWriter(func(w http.ResponseWriter) { + http.Error(w, "failure", http.StatusInternalServerError) + })) + + require.Error(t, err) + require.Contains(t, err.Error(), "downloading update checksum") +} + +func TestPrivilegedUpdateServicePolicyOffRejectsUpdate(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + err := runPrivilegedUpdaterFlow(t, up, withServiceTestPolicyToolsVersion("off")) + require.Error(t, err) + require.ErrorIs(t, err, trace.AccessDenied(`ToolsVersion in HKLM\SOFTWARE\Policies\Teleport\TeleportConnect is "off", automatic updates are disabled by system policy`)) +} + +func TestPrivilegedUpdateServicePolicyVersionMismatch(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + err := runPrivilegedUpdaterFlow(t, up, withServiceTestPolicyToolsVersion("999.0.1")) + require.ErrorIs(t, err, trace.BadParameter("update version 999.0.0 does not match policy version 999.0.1")) +} + +func TestPrivilegedUpdateServiceRejectsMalformedMetadata(t *testing.T) { + cfg := getDefaultConfig(t) + + serviceErr := make(chan error, 1) + go func() { + serviceErr <- privilegedupdater.RunServiceTest(t.Context(), cfg) + }() + + conn := dialUpdaterPipe(t, 5*time.Second) + defer conn.Close() + + // Send malformed JSON metadata. + malformedMetadata := []byte("{") + require.NoError(t, binary.Write(conn, binary.LittleEndian, uint32(len(malformedMetadata)))) + n, err := conn.Write(malformedMetadata) + require.NoError(t, err) + require.Len(t, malformedMetadata, n) + require.NoError(t, conn.Close()) + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + select { + case err := <-serviceErr: + require.Error(t, err) + require.Contains(t, err.Error(), "failed to unmarshal update metadata") + case <-ctx.Done(): + t.Fatal("timed out") + } +} + +func TestPrivilegedUpdateServiceRejectsUpdateBaseDirFile(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + + baseDir := filepath.Join(t.TempDir(), "not-a-dir") + require.NoError(t, os.WriteFile(baseDir, []byte("x"), 0o600)) + + err := runPrivilegedUpdaterFlow(t, up, withServiceTestUpdateBaseDir(baseDir)) + require.ErrorIs(t, err, trace.BadParameter("security violation: %s exists but is not a directory", baseDir)) +} + +func TestPrivilegedUpdateServiceRejectsUpdateBaseDirReparsePoint(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + + targetDir := t.TempDir() + baseDir := filepath.Join(t.TempDir(), "junction-base") + createJunction(t, baseDir, targetDir) + + err := runPrivilegedUpdaterFlow(t, up, withServiceTestUpdateBaseDir(baseDir)) + require.ErrorIs(t, err, trace.BadParameter("security violation: %s is a reparse point", baseDir)) +} + +func TestPrivilegedUpdateServiceSafelyCleanupOldUpdates(t *testing.T) { + updateBaseDir := t.TempDir() + outsideDir := t.TempDir() + outsideFile := filepath.Join(outsideDir, "must-stay.txt") + require.NoError(t, os.WriteFile(outsideFile, []byte("outside"), 0o600)) + + staleDir := filepath.Join(updateBaseDir, "stale-update") + require.NoError(t, os.MkdirAll(staleDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(staleDir, "update.exe"), []byte("stale"), 0o600)) + + junctionPath := filepath.Join(updateBaseDir, "outside-junction") + createJunction(t, junctionPath, outsideDir) + + updateBinary := []byte("payload") + up := update{ + version: "999.0.0", + binary: updateBinary, + } + err := runPrivilegedUpdaterFlow(t, up, withServiceTestUpdateBaseDir(updateBaseDir)) + require.NoError(t, err) + + _, err = os.Stat(staleDir) + require.ErrorIs(t, err, os.ErrNotExist, "stale update directory should be removed") + + _, err = os.Lstat(junctionPath) + require.ErrorIs(t, err, os.ErrNotExist, "junction entry should be removed") + + _, err = os.Stat(outsideFile) + require.NoError(t, err, "cleanup must not remove files outside base dir via junction traversal") +} + +func TestPrivilegedUpdateServiceCorrectsUpdateBaseDirACL(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + + defaultConfig := getDefaultConfig(t) + baseDir := filepath.Join(t.TempDir(), "new-dir") + require.NoError(t, os.MkdirAll(baseDir, 0o777)) + // Everyone has Full Control over this object, + // and the permission is inherited by all subfolders and files. + // This access will be corrected by the service. + setDirectoryDACL(t, baseDir, "D:(A;OICI;GA;;;WD)") + + err := runPrivilegedUpdaterFlow(t, up, withServiceTestUpdateBaseDir(baseDir)) + require.NoError(t, err) + + assertDirectorySecurityDescriptor(t, baseDir, defaultConfig.UpdateDirSecurityDescriptor) +} + +func TestPrivilegedUpdateServiceAllowOnlyOneClientConnection(t *testing.T) { + serviceErr := make(chan error, 1) + cfg := getDefaultConfig(t) + + go func() { + serviceErr <- privilegedupdater.RunServiceTest(t.Context(), cfg) + }() + + // First client connects and keeps the pipe open. This blocks the service in readUpdate. + firstConn := dialUpdaterPipe(t, 2*time.Second) + + // Second client should fail because waitForSingleClient closes the listener after first accept. + clientCtx2, cancel2 := context.WithTimeout(t.Context(), 2*time.Second) + t.Cleanup(cancel2) + secondConn, err := winio.DialPipeAccess(clientCtx2, privilegedupdater.PipePath, privilegedupdater.SafePipeReadWriteAccess) + if secondConn != nil { + _ = secondConn.Close() + } + require.Error(t, err, "second client unexpectedly connected") + + // Let the service exit cleanly from the blocked read path. + require.NoError(t, firstConn.Close()) + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + select { + case err := <-serviceErr: + require.Error(t, err) + case <-ctx.Done(): + t.Fatal("timed out") + } +} + +func TestPrivilegedUpdateServiceRejectsUnsignedUpdate(t *testing.T) { + up := update{ + version: "999.0.0", + binary: []byte("payload"), + } + + // Keep signature verification enabled for this test to ensure unsigned + // updates are rejected. + err := runPrivilegedUpdaterFlow(t, up, withServiceTestVerifySignature(nil)) + require.Error(t, err) + require.ErrorContains(t, err, "verifying update signature") +} + +type serviceConfig struct { + privilegedupdater.ServiceTestConfig + checksumServerResponseWriter func(http.ResponseWriter) +} + +type privilegedServiceMainConfigOption func(*serviceConfig) + +func withServiceTestUpdateBaseDir(path string) privilegedServiceMainConfigOption { + return func(cfg *serviceConfig) { + cfg.UpdateBaseDir = path + } +} + +func withChecksumServerResponseWriter(checksumResponseWriter func(w http.ResponseWriter)) privilegedServiceMainConfigOption { + return func(cfg *serviceConfig) { + cfg.checksumServerResponseWriter = checksumResponseWriter + } +} + +func withServiceTestPolicyToolsVersion(version string) privilegedServiceMainConfigOption { + return func(cfg *serviceConfig) { + cfg.PolicyToolsVersion = version + } +} + +func withServiceTestVerifySignature(fn func(updatePath string) error) privilegedServiceMainConfigOption { + return func(cfg *serviceConfig) { + cfg.VerifySignature = fn + } +} + +type update struct { + version string + binary []byte +} + +// runPrivilegedUpdaterFlow runs the service implementation and sends the update via the named pipe. +func runPrivilegedUpdaterFlow(t *testing.T, update update, opts ...privilegedServiceMainConfigOption) error { + t.Helper() + + defaultCfg := getDefaultConfig(t) + cfg := &serviceConfig{ + ServiceTestConfig: *defaultCfg, + } + for _, opt := range opts { + opt(cfg) + } + + checksumPath := "/Teleport Connect Setup-" + update.version + ".exe.sha256" + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != checksumPath { + http.NotFound(w, r) + return + } + if cfg.checksumServerResponseWriter != nil { + cfg.checksumServerResponseWriter(w) + } else { + hash := sha256.Sum256(update.binary) + // By default, return a checksum for the passed file. + _, _ = w.Write([]byte(hex.EncodeToString(hash[:]))) + } + })) + t.Cleanup(server.Close) + + payloadPath := filepath.Join(t.TempDir(), "client-update.exe") + require.NoError(t, os.WriteFile(payloadPath, update.binary, 0o600)) + + serviceErr := make(chan error, 1) + installUpdateFromClientErr := make(chan error, 1) + go func() { + err := privilegedupdater.RunServiceTest(t.Context(), &privilegedupdater.ServiceTestConfig{ + UpdateDirSecurityDescriptor: cfg.UpdateDirSecurityDescriptor, + UpdateBaseDir: cfg.UpdateBaseDir, + PolicyToolsVersion: cfg.PolicyToolsVersion, + PolicyCDNBaseURL: server.URL, + HTTPClient: server.Client(), + PipeAuthenticatedUsersAccess: cfg.PipeAuthenticatedUsersAccess, + VerifySignature: cfg.VerifySignature, + }) + // We are attempting to run a non-exe file. + // It will fail, so we check if we ran the correct file. + // The pattern should match: \\update.exe. + // In the production code, base-update-dir is %ProgramData%\TeleportConnectUpdater. + if err != nil && strings.Contains(err.Error(), "running installer") { + pattern := fmt.Sprintf( + `.*starting installer path=%s\\[0-9a-fA-F-]{36}\\update\.exe`, + regexp.QuoteMeta(cfg.UpdateBaseDir), + ) + require.Regexp(t, pattern, err.Error()) + require.Contains(t, err.Error(), "args=\"--updated /S /allusers\"") + serviceErr <- nil + return + } + serviceErr <- err + }() + go func() { + installUpdateFromClientErr <- privilegedupdater.InstallUpdateFromClient(t.Context(), payloadPath, false, update.version) + }() + + for i := 0; i < 2; i++ { + select { + case err := <-serviceErr: + return err + case err := <-installUpdateFromClientErr: + if err != nil { + return err + } + case <-t.Context().Done(): + t.Fatal("timed out") + return nil + } + } + return nil +} + +func dialUpdaterPipe(t *testing.T, timeout time.Duration) net.Conn { + t.Helper() + + var conn net.Conn + err := retryutils.RetryStaticFor(timeout, 25*time.Millisecond, func() error { + c, err := winio.DialPipeAccess(t.Context(), privilegedupdater.PipePath, privilegedupdater.SafePipeReadWriteAccess) + if err != nil { + return err + } + conn = c + return nil + }) + require.NoError(t, err, "failed to connect to updater pipe before timeout") + return conn +} + +// getDefaultConfig returns a base dir and a security descriptor. +func getDefaultConfig(t *testing.T) *privilegedupdater.ServiceTestConfig { + t.Helper() + + token := windows.GetCurrentProcessToken() + tokenUser, err := token.GetTokenUser() + require.NoError(t, err) + require.NotNil(t, tokenUser.User.Sid) + + ownerSID := tokenUser.User.Sid.String() + + // We can't use the production security descriptor as it requires the process to run with elevated privileges. + // Here we create a descriptor that restrict a bit the regular rights for authenticated users. + descriptor := "O:" + ownerSID + + "D:P" + + "(A;;FA;;;SY)" + + "(A;;FA;;;BA)" + + "(A;OICI;0x1301bf;;;AU)" // 0x1301bf - modify rights for AU (authenticated users) for dir and sub dirs (OICI) + + return &privilegedupdater.ServiceTestConfig{ + UpdateDirSecurityDescriptor: descriptor, + UpdateBaseDir: t.TempDir(), + // Allow Authenticated Users to create the pipe in tests. + PipeAuthenticatedUsersAccess: windows.GENERIC_READ | windows.GENERIC_WRITE, + // Integration test updates are unsigned. + VerifySignature: func(string) error { return nil }, + } +} + +func createJunction(t *testing.T, linkPath, targetPath string) { + t.Helper() + + cmd := exec.Command("cmd", "/c", "mklink", "/J", linkPath, targetPath) + _, err := cmd.CombinedOutput() + require.NoError(t, err) +} + +func assertDirectorySecurityDescriptor(t *testing.T, path string, expectedDescriptor string) { + t.Helper() + + actualSD, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION) + require.NoError(t, err) + + expectedSD, err := windows.SecurityDescriptorFromString(expectedDescriptor) + require.NoError(t, err) + + // Comparing ACLs is non-trivial. + // + // In SDDL, "D:" starts the DACL section. + // "D:P" means the DACL is protected (no inheritance). + // After ACL changes, Windows may apply "D:PAI", where "AI" indicates + // auto-inherited ACEs. The descriptors are functionally equivalent + // for our purposes, so normalize before comparison. + expectedSDString := strings.Replace(expectedSD.String(), "D:P", "D:PAI", 1) + require.Equal(t, expectedSDString, actualSD.String(), "directory DACL does not match expected descriptor") +} + +func setDirectoryDACL(t *testing.T, path string, descriptor string) { + t.Helper() + + sd, err := windows.SecurityDescriptorFromString(descriptor) + require.NoError(t, err) + dacl, _, err := sd.DACL() + require.NoError(t, err) + + err = windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION, nil, nil, dacl, nil) + require.NoError(t, err) +} diff --git a/lib/teleterm/autoupdate/common/config.go b/lib/teleterm/autoupdate/common/config.go new file mode 100644 index 0000000000000..11c74968ae2cf --- /dev/null +++ b/lib/teleterm/autoupdate/common/config.go @@ -0,0 +1,35 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import ( + "github.com/gravitational/teleport/lib/autoupdate" + "github.com/gravitational/teleport/lib/modules" +) + +// TeleportToolsVersionOff indicates that managed updates are disabled ("off"). +const TeleportToolsVersionOff = "off" + +// GetDefaultBaseURL returns the default base URL used to download artifacts. +func GetDefaultBaseURL() string { + m := modules.GetModules() + // Uses the same logic as the teleport/lib/autoupdate/tools package. + if m.BuildType() != modules.BuildOSS { + return autoupdate.DefaultBaseURL + } + return "" +} diff --git a/lib/teleterm/autoupdate/common/registry_windows.go b/lib/teleterm/autoupdate/common/registry_windows.go new file mode 100644 index 0000000000000..ab853fa59c9c3 --- /dev/null +++ b/lib/teleterm/autoupdate/common/registry_windows.go @@ -0,0 +1,86 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import ( + "errors" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows/registry" +) + +const ( + // TeleportConnectPoliciesKeyPath is the Windows registry path for Teleport Connect policy settings. + TeleportConnectPoliciesKeyPath = `SOFTWARE\Policies\Teleport\TeleportConnect` + // RegistryValueToolsVersion is the policy value name that pins the managed tools version. + RegistryValueToolsVersion = "ToolsVersion" + // RegistryValueCDNBaseURL is the policy value name that configures the managed update CDN base URL. + RegistryValueCDNBaseURL = "CdnBaseUrl" +) + +// PolicyValues defines the managed update policy configuration. +type PolicyValues struct { + // CDNBaseURL is the base URL used to download artifacts. + CDNBaseURL string + // Version specifies the enforced application version. + Version string +} + +// ReadRegistryPolicyValues reads system policy values (tools version and CDN base URL) for Teleport Connect. +func ReadRegistryPolicyValues(key registry.Key) (*PolicyValues, error) { + version, err := ReadRegistryValue(key, TeleportConnectPoliciesKeyPath, RegistryValueToolsVersion) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + + url, err := ReadRegistryValue(key, TeleportConnectPoliciesKeyPath, RegistryValueCDNBaseURL) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + + return &PolicyValues{ + CDNBaseURL: url, + Version: version, + }, nil +} + +// ReadRegistryValue reads a registry value. +func ReadRegistryValue(hive registry.Key, pathName string, valueName string) (path string, err error) { + key, err := registry.OpenKey(hive, pathName, registry.READ) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", trace.NotFound("registry key %s not found", pathName) + } + return "", trace.Wrap(err, "opening registry key %s", pathName) + } + + defer func() { + if closeErr := key.Close(); closeErr != nil && err == nil { + err = trace.Wrap(closeErr, "closing registry key %s", pathName) + } + }() + + path, _, err = key.GetStringValue(valueName) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", trace.NotFound("registry value %s not found in %s", valueName, pathName) + } + return "", trace.Wrap(err, "reading registry value %s from %s", valueName, pathName) + } + + return path, nil +} diff --git a/lib/teleterm/autoupdate/privilegedupdater/authenticode_windows.go b/lib/teleterm/autoupdate/privilegedupdater/authenticode_windows.go new file mode 100644 index 0000000000000..0f3b178181e63 --- /dev/null +++ b/lib/teleterm/autoupdate/privilegedupdater/authenticode_windows.go @@ -0,0 +1,212 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package privilegedupdater + +//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go authenticode_windows.go + +//sys CryptMsgGetParam(msg windows.Handle, paramType uint32, index uint32, data *byte, dataLen *uint32) (err error) [failretval==0] = crypt32.CryptMsgGetParam +//sys CryptMsgClose(msg windows.Handle) (err error) [failretval==0] = crypt32.CryptMsgClose + +import ( + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "slices" + "unsafe" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows" +) + +const ( + // CMSG_SIGNER_CERT_INFO_PARAM retrieves a CERT_INFO structure directly. + // As in https://learn.microsoft.com/en-us/windows/win32/api/wincrypt/nf-wincrypt-certgetsubjectcertificatefromstore#examples. + cmsgSignerCertInfoParam = 7 +) + +// verifySignature checks if the update is signed by Teleport. +func verifySignature(updatePath string) error { + updatePathPtr, err := windows.UTF16PtrFromString(updatePath) + if err != nil { + return trace.Wrap(err) + } + if err = verifyTrust(updatePathPtr); err != nil { + return trace.Wrap(err, "verifying update signature") + } + updateCert, err := getCert(updatePathPtr) + if err != nil { + return trace.Wrap(err, "getting update certificate") + } + + if !hasTeleportSubject(updateCert) { + return trace.BadParameter("signature verification failed: update subject does not match Teleport subject (teleport: %s, update: %s)", logCert(teleportCert), logCert(updateCert)) + } + + return nil +} + +func verifyTrust(path *uint16) error { + fileInfo := windows.WinTrustFileInfo{ + Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})), + FilePath: path, + } + data := &windows.WinTrustData{ + Size: uint32(unsafe.Sizeof(windows.WinTrustData{})), + UIChoice: windows.WTD_UI_NONE, + RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN, + UnionChoice: windows.WTD_CHOICE_FILE, + StateAction: windows.WTD_STATEACTION_VERIFY, + FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&fileInfo), + } + + // verify + err := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + + // close + data.StateAction = windows.WTD_STATEACTION_CLOSE + closeErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data) + + return trace.NewAggregate(err, closeErr) +} + +// getCert extracts the certificate context from a signed file. +// This function does not validate the chain of trust. Use verifyTrust for that. +func getCert(path *uint16) (*x509.Certificate, error) { + var ( + msgHandle, storeHandle windows.Handle + encoding uint32 + ) + + err := windows.CryptQueryObject( + windows.CERT_QUERY_OBJECT_FILE, + unsafe.Pointer(path), + windows.CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + windows.CERT_QUERY_FORMAT_FLAG_BINARY, + 0, + &encoding, + nil, nil, + &storeHandle, + &msgHandle, + nil, + ) + if err != nil { + return nil, trace.Wrap(err, "could not open crypt object") + } + defer func() { + windows.CertCloseStore(storeHandle, 0) + CryptMsgClose(msgHandle) + }() + + certInfoBlob, err := getCertInfoBlob(msgHandle) + if err != nil { + return nil, err + } + + certInfo := (*windows.CertInfo)(unsafe.Pointer(&certInfoBlob[0])) + certCtx, err := windows.CertFindCertificateInStore( + storeHandle, + encoding, + 0, + windows.CERT_FIND_SUBJECT_CERT, + unsafe.Pointer(certInfo), + nil, + ) + if err != nil { + return nil, trace.Wrap(err, "signer certificate not found in store") + } + defer windows.CertFreeCertificateContext(certCtx) + + cert, err := extractX509Certificate(certCtx) + return cert, trace.Wrap(err) +} + +// hasTeleportSubject checks whether updateCert has the expected Teleport publisher subject by comparing a subset of X.509 Subject fields. +// The cert validity must be checked first with verifyTrust. +// +// Security notes: +// - This does NOT provide cryptographic authenticity guarantees. An attacker could theoretically obtain a certificate +// with identical subject fields. However, obtaining such a certificate from a CA trusted by Windows is non-trivial +// (the cert must first pass the WinVerifyTrust check). +// - Teleport Managed Updates explicitly do not verify asset authenticity (see https://github.com/gravitational/teleport/blob/0bc64ebc163728ece7f9e7b874e6eb9b95736a01/rfd/0184-agent-auto-updates.md?plain=1#L1909-L1918), +// so this check acts as a best-effort, additional defense layer. +// - We intentionally avoid pinning a specific certificate in the updater so that certificate renewals or rotations +// do not accidentally break updates. +// - This logic should become unnecessary once proper authenticity verification is implemented (e.g., using The Update Framework) +// along with the supporting infrastructure. +// +// Similar approaches: +// - electron-updater compares DN attributes: +// https://github.com/electron-userland/electron-builder/blob/02e59ba8a3b02e1b3ab20035ff43f48ea20880b7/packages/electron-updater/src/windowsExecutableCodeSignatureVerifier.ts#L69-L85 +// - Tailscale verifies only the CN: +// https://github.com/tailscale/tailscale/blob/3ec5be3f510f74738179c1023468343a62a7e00f/clientupdate/clientupdate_windows.go#L70-L74 +func hasTeleportSubject(updateCert *x509.Certificate) bool { + if updateCert == nil { + return false + } + + s1, s2 := teleportCert.Subject, updateCert.Subject + return s1.CommonName == s2.CommonName && + slices.Equal(s1.Organization, s2.Organization) && + slices.Equal(s1.Locality, s2.Locality) && + slices.Equal(s1.Province, s2.Province) && + slices.Equal(s1.Country, s2.Country) +} + +var teleportCert = &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "Gravitational, Inc.", + Organization: []string{"Gravitational, Inc."}, + Locality: []string{"Oakland"}, + Province: []string{"California"}, + Country: []string{"US"}, + }, +} + +func logCert(cert *x509.Certificate) string { + if cert == nil { + return "" + } + + s := cert.Subject + return fmt.Sprintf("CN=%q, O=%v, L=%v, ST=%v, C=%v", s.CommonName, s.Organization, s.Locality, s.Province, s.Country) +} + +func getCertInfoBlob(handle windows.Handle) ([]byte, error) { + var size uint32 + if err := CryptMsgGetParam(handle, cmsgSignerCertInfoParam, 0, nil, &size); err != nil { + return nil, trace.Wrap(err, "failed to get CMSG_SIGNER_CERT_INFO_PARAM param size") + } + + buf := make([]byte, size) + if err := CryptMsgGetParam(handle, cmsgSignerCertInfoParam, 0, &buf[0], &size); err != nil { + return nil, trace.Wrap(err, "failed to get CMSG_SIGNER_CERT_INFO_PARAM param data") + } + return buf, nil +} + +func extractX509Certificate(ctx *windows.CertContext) (*x509.Certificate, error) { + if ctx == nil || ctx.EncodedCert == nil || ctx.Length == 0 { + return nil, trace.BadParameter("invalid certificate context") + } + + der := unsafe.Slice(ctx.EncodedCert, int(ctx.Length)) + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, trace.Wrap(err, "failed to parse x509 certificate") + } + return cert, nil +} diff --git a/lib/teleterm/autoupdate/privilegedupdater/client_windows.go b/lib/teleterm/autoupdate/privilegedupdater/client_windows.go new file mode 100644 index 0000000000000..af0ed93ca7798 --- /dev/null +++ b/lib/teleterm/autoupdate/privilegedupdater/client_windows.go @@ -0,0 +1,160 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package privilegedupdater + +import ( + "context" + "errors" + "net" + "os" + "syscall" + "time" + + "github.com/Microsoft/go-winio" + "github.com/gravitational/trace" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" + + "github.com/gravitational/teleport/api/utils/retryutils" +) + +const ( + serviceStartTimeout = 5 * time.Second + serviceStartRetryStep = 500 * time.Millisecond + serviceStartRetryMax = 500 * time.Millisecond + pipeDialTimeout = 3 * time.Second + pipeDialRetryStep = 100 * time.Millisecond + pipeDialRetryMax = 300 * time.Millisecond +) + +// RunServiceAndInstallUpdateFromClient is called by the client. +// It starts the update service, sends update metadata, and transfers the binary for validation and installation. +func RunServiceAndInstallUpdateFromClient(ctx context.Context, path string, forceRun bool, version string) error { + if err := ensureServiceRunning(ctx); err != nil { + // NsisDualModeUpdater relies on this exact phrase for fallback behavior. + return trace.Wrap(err, "failed to ensure service is running") + } + err := InstallUpdateFromClient(ctx, path, forceRun, version) + return trace.Wrap(err) +} + +// InstallUpdateFromClient sends update metadata, and transfers the binary for validation and installation. +func InstallUpdateFromClient(ctx context.Context, path string, forceRun bool, version string) error { + conn, err := dialPipeWithRetry(ctx, PipePath) + if err != nil { + return trace.Wrap(err) + } + defer conn.Close() + + // The update must be read by the client running as a standard user. + // Passing the path directly to the SYSTEM service could cause it to read + // files the user is not permitted to access. + file, err := os.Open(path) + if err != nil { + return trace.Wrap(err) + } + defer file.Close() + + meta := updateMetadata{ForceRun: forceRun, Version: version} + return trace.Wrap(writeUpdate(conn, meta, file)) +} + +func dialPipeWithRetry(ctx context.Context, path string) (net.Conn, error) { + ctx, cancel := context.WithTimeout(ctx, pipeDialTimeout) + defer cancel() + linearRetry, err := retryutils.NewLinear(retryutils.LinearConfig{ + Step: pipeDialRetryStep, + Max: pipeDialRetryMax, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + isRetryError := func(err error) bool { + return errors.Is(err, windows.ERROR_FILE_NOT_FOUND) + } + + var conn net.Conn + err = linearRetry.For(ctx, func() error { + conn, err = winio.DialPipeAccess(ctx, path, uint32(SafePipeReadWriteAccess)) + if err != nil && !isRetryError(err) { + return retryutils.PermanentRetryError(trace.Wrap(err)) + } + return trace.Wrap(err) + }) + if err != nil { + return nil, trace.Wrap(err) + } + return conn, nil +} + +func ensureServiceRunning(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, serviceStartTimeout) + defer cancel() + // Avoid [mgr.Connect] because it requests elevated permissions. + scManager, err := windows.OpenSCManager(nil /*machine*/, nil /*database*/, windows.SC_MANAGER_CONNECT) + if err != nil { + return trace.Wrap(err, "opening Windows service manager") + } + defer windows.CloseServiceHandle(scManager) + serviceNamePtr, err := syscall.UTF16PtrFromString(serviceName) + if err != nil { + return trace.Wrap(err, "converting service name to UTF16") + } + serviceHandle, err := windows.OpenService(scManager, serviceNamePtr, serviceAccessFlags) + if err != nil { + return trace.Wrap(err, "opening Windows service %v", serviceName) + } + service := &mgr.Service{ + Name: serviceName, + Handle: serviceHandle, + } + defer service.Close() + + status, err := service.Query() + if err != nil { + return trace.Wrap(err, "querying service status") + } + if status.State == svc.Running { + return nil + } + + if err = service.Start(); err != nil { + return trace.Wrap(err, "starting Windows service %s", serviceName) + } + + linearRetry, err := retryutils.NewLinear(retryutils.LinearConfig{ + Step: serviceStartRetryStep, + Max: serviceStartRetryMax, + }) + if err != nil { + return trace.Wrap(err) + } + + err = linearRetry.For(ctx, func() error { + status, err = service.Query() + if err != nil { + return retryutils.PermanentRetryError(trace.Wrap(err)) + } + if status.State != svc.Running { + return trace.Errorf("service not running yet") + } + return nil + }) + return trace.Wrap(err) +} diff --git a/lib/teleterm/autoupdate/privilegedupdater/protocol_windows.go b/lib/teleterm/autoupdate/privilegedupdater/protocol_windows.go new file mode 100644 index 0000000000000..324e0124e18a9 --- /dev/null +++ b/lib/teleterm/autoupdate/privilegedupdater/protocol_windows.go @@ -0,0 +1,107 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package privilegedupdater + +import ( + "encoding/binary" + "encoding/json" + "io" + "os" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/utils" +) + +const ( + PipePath = `\\.\pipe\TeleportConnectUpdaterPipe` + maxUpdateMetadataSize = 1 * 1024 * 1024 // 1 MiB + maxUpdatePayloadSize = 1 * 1024 * 1024 * 1024 // 1 GiB +) + +type updateMetadata struct { + // ForceRun determines whether to run the app after installing the update. + ForceRun bool `json:"force_run"` + // Version is update version. + Version string `json:"version"` +} + +// writeUpdate writes an update stream in the following order: +// 1. An uint32 specifying the length of the updateMetadata header. +// 2. The updateMetadata header of the specified length. +// 3. The update binary, read until EOF. +func writeUpdate(conn io.Writer, meta updateMetadata, file io.Reader) error { + if meta.Version == "" { + return trace.BadParameter("update version is required") + } + + metaBytes, err := json.Marshal(meta) + if err != nil { + return trace.Wrap(err) + } + if len(metaBytes) > maxUpdateMetadataSize { + return trace.BadParameter("update metadata payload too large") + } + + if err = binary.Write(conn, binary.LittleEndian, uint32(len(metaBytes))); err != nil { + return trace.Wrap(err) + } + if _, err = conn.Write(metaBytes); err != nil { + return trace.Wrap(err) + } + + _, err = io.Copy(conn, file) + return trace.Wrap(err) +} + +// readUpdate reads an update stream in the following order: +// 1. An uint32 specifying the length of the updateMetadata header. +// 2. The updateMetadata header of the specified length. +// 3. The update binary, read until EOF. +// +// It writes the installer to destinationPath and returns the parsed metadata. +func readUpdate(conn io.Reader, destinationPath string) (*updateMetadata, error) { + var jsonLen uint32 + if err := binary.Read(conn, binary.LittleEndian, &jsonLen); err != nil { + return nil, trace.Wrap(err) + } + if jsonLen > maxUpdateMetadataSize { + return nil, trace.BadParameter("update metadata payload too large") + } + + buf := make([]byte, jsonLen) + _, err := io.ReadFull(conn, buf) + if err != nil { + return nil, trace.Wrap(err) + } + meta := &updateMetadata{} + if err = json.Unmarshal(buf, meta); err != nil { + return nil, trace.Wrap(err, "failed to unmarshal update metadata") + } + if meta.Version == "" { + return nil, trace.BadParameter("update version is required") + } + + outFile, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) + if err != nil { + return nil, trace.Wrap(err) + } + + payloadReader := utils.LimitReader(conn, maxUpdatePayloadSize) + _, err = io.Copy(outFile, payloadReader) + return meta, trace.NewAggregate(err, outFile.Close()) +} diff --git a/lib/teleterm/autoupdate/privilegedupdater/service_windows.go b/lib/teleterm/autoupdate/privilegedupdater/service_windows.go new file mode 100644 index 0000000000000..87dcd7071c2fb --- /dev/null +++ b/lib/teleterm/autoupdate/privilegedupdater/service_windows.go @@ -0,0 +1,548 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package privilegedupdater + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + "unsafe" + + "github.com/Microsoft/go-winio" + "github.com/coreos/go-semver/semver" + "github.com/google/uuid" + "github.com/gravitational/trace" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/lib/teleterm/autoupdate/common" + logutils "github.com/gravitational/teleport/lib/utils/log" + "github.com/gravitational/teleport/lib/windowsservice" +) + +// ServiceCommand is the tsh subcommand that the Windows service manager invokes when starting the +// updater service. +var ServiceCommand = []string{"connect-updater", ServiceSubCommand} + +const ( + // ServiceSubCommand is the tsh subcommand under "connect-updater" that runs the updater service. + ServiceSubCommand = "service" + serviceName = "TeleportConnectUpdater" + serviceDescription = "Installs Teleport Connect updates without requiring administrator privileges." + eventSource = "connect-updater" + serviceAccessFlags = windows.SERVICE_START | windows.SERVICE_QUERY_STATUS + serviceRunTimeout = 30 * time.Second + + // SafePipeReadWriteAccess defines access for Authenticated Users (AU). + //According to https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights + // and https://stackoverflow.com/questions/29947524/c-let-user-process-write-to-local-system-named-pipe-custom-security-descrip + // the pipe should not set GENERIC_WRITE for standard users as it would allow them to create the pipe. + SafePipeReadWriteAccess = windows.GENERIC_READ | windows.FILE_WRITE_DATA + + updateDirSecurityDescriptor = "O:SY" + // Owner SYSTEM + "D:P" + // 'P' blocks permissions inheritance from the parent directory + "(A;OICI;GA;;;SY)" + // Allow System Full Access + "(A;OICI;GA;;;BA)" // Allow Built-in Administrators Full Access +) + +// makePipeServerSecurityDescriptor allows SYSTEM/Admins Full Control and grants Authenticated Users the passed access mask. +func makePipeServerSecurityDescriptor(authenticatedUsersAccess uint32) string { + return "D:" + // DACL + "(A;;GA;;;SY)" + // Allow (A);; Generic All (GA);;; SYSTEM (SY) + "(A;;GA;;;BA)" + // Allow (A);; Generic All (GA);;; Built-in Admins (BA) + fmt.Sprintf("(A;;%#x;;;AU)", authenticatedUsersAccess) // Allow (A);; authenticatedUsersAccess ;;; Authenticated Users (AU) +} + +var log = logutils.NewPackageLogger(teleport.ComponentKey, "autoupdate") + +// ServiceTestConfig allows overriding certain updater config properties. +// For test use only. +type ServiceTestConfig struct { + // UpdateDirSecurityDescriptor overrides updateDirSecurityDescriptor. + UpdateDirSecurityDescriptor string + // UpdateBaseDir overrides the default %ProgramData%\TeleportConnectUpdater update path. + UpdateBaseDir string + // PolicyToolsVersion overrides ToolsVersion in HKLM\SOFTWARE\Policies\Teleport\TeleportConnect. + PolicyToolsVersion string + // PolicyCDNBaseURL overrides CdnBaseUrl in HKLM\SOFTWARE\Policies\Teleport\TeleportConnect. + PolicyCDNBaseURL string + // HTTPClient overrides the client used for checksum download. + HTTPClient *http.Client + // PipeAuthenticatedUsersAccess overrides Authenticated Users access mask in + // the named pipe DACL. If zero, SafePipeReadWriteAccess is used. + PipeAuthenticatedUsersAccess uint32 + // VerifySignature overrides signature verification. + // If nil, verifySignature is used. + VerifySignature func(updatePath string) error +} + +// InstallService installs the Teleport Connect privileged update service. +// This service enables installing updates without prompting the user for administrator permissions. +func InstallService(ctx context.Context) (err error) { + return trace.Wrap(windowsservice.Install(ctx, &windowsservice.InstallConfig{ + Name: serviceName, + Command: ServiceCommand, + Description: serviceDescription, + EventSourceName: eventSource, + AccessPermissions: serviceAccessFlags, + })) +} + +// UninstallService uninstalls Teleport Connect privileged update service. +func UninstallService(ctx context.Context) (err error) { + return trace.Wrap(windowsservice.Uninstall(ctx, &windowsservice.UninstallConfig{ + Name: serviceName, + EventSourceName: eventSource, + })) +} + +// RunService implements Teleport Connect privileged update service. +// This service enables installing updates without prompting the user for administrator permissions. +func RunService() error { + h := &handler{ + testCfg: &ServiceTestConfig{}, + } + + closeLogger, err := windowsservice.InitSlogEventLogger(eventSource) + if err != nil { + return trace.Wrap(err) + } + + err = windowsservice.Run(&windowsservice.RunConfig{ + Name: serviceName, + Handler: h, + Logger: log, + }) + return trace.NewAggregate(err, closeLogger()) +} + +// RunServiceTest implements Teleport Connect privileged update service. +// It runs the service implementation directly. +// For test use only. +func RunServiceTest(ctx context.Context, cfg *ServiceTestConfig) error { + h := &handler{ + testCfg: cfg, + } + return trace.Wrap(h.Execute(ctx, nil)) +} + +type handler struct { + testCfg *ServiceTestConfig +} + +func (h *handler) Execute(ctx context.Context, _ []string) (err error) { + ctx, cancel := context.WithTimeout(ctx, serviceRunTimeout) + defer cancel() + + updaterConfig, err := h.getUpdaterConfig() + if err != nil { + return trace.Wrap(err, "getting updater config") + } + + updateMeta, updatePath, err := h.readUpdateMeta(ctx) + if err != nil { + return trace.Wrap(err, "reading update metadata") + } + + if updaterConfig.Version != "" && updateMeta.Version != updaterConfig.Version { + return trace.BadParameter("update version %s does not match policy version %s", updateMeta.Version, updaterConfig.Version) + } + + if err = ensureIsUpgrade(updateMeta.Version); err != nil { + return trace.Wrap(err, "checking if update is upgrade") + } + + verifySignatureFn := verifySignature + if h.testCfg.VerifySignature != nil { + verifySignatureFn = h.testCfg.VerifySignature + } + if err = verifySignatureFn(updatePath); err != nil { + return trace.Wrap(err, "verifying update signature") + } + + hash, err := h.downloadChecksum(ctx, updaterConfig.CDNBaseURL, updateMeta.Version) + if err != nil { + return trace.Wrap(err, "downloading update checksum") + } + + if err = verifyUpdateChecksum(updatePath, hash); err != nil { + return trace.Wrap(err, "verifying update checksum") + } + + return trace.Wrap(runInstaller(updatePath, updateMeta.ForceRun), "running installer") +} + +// getUpdaterConfig reads the per-machine config. +func (h *handler) getUpdaterConfig() (*common.PolicyValues, error) { + policyValues, err := common.ReadRegistryPolicyValues(registry.LOCAL_MACHINE) + if err != nil { + return nil, trace.Wrap(err) + } + + versionFromPolicy := policyValues.Version + if h.testCfg.PolicyToolsVersion != "" { + versionFromPolicy = h.testCfg.PolicyToolsVersion + } + if versionFromPolicy == common.TeleportToolsVersionOff { + return nil, trace.AccessDenied("%s in HKLM\\%s is %q, automatic updates are disabled by system policy", common.RegistryValueToolsVersion, common.TeleportConnectPoliciesKeyPath, common.TeleportToolsVersionOff) + } + + cdnBaseURL := policyValues.CDNBaseURL + if h.testCfg.PolicyCDNBaseURL != "" { + cdnBaseURL = h.testCfg.PolicyCDNBaseURL + } + if cdnBaseURL == "" { + cdnBaseURL = common.GetDefaultBaseURL() + } + if cdnBaseURL == "" { + return nil, trace.AccessDenied("client tools updates are disabled as they are licensed under AGPL. To use Community Edition builds or custom binaries, set %s in HKLM\\%s", common.RegistryValueCDNBaseURL, common.TeleportConnectPoliciesKeyPath) + } + + return &common.PolicyValues{ + CDNBaseURL: cdnBaseURL, + Version: versionFromPolicy, + }, nil +} + +type acceptResult struct { + conn net.Conn + err error +} + +func (h *handler) readUpdateMeta(ctx context.Context) (_ *updateMetadata, _ string, err error) { + pipeAuthenticatedUsersAccess := uint32(SafePipeReadWriteAccess) + if h.testCfg.PipeAuthenticatedUsersAccess != 0 { + pipeAuthenticatedUsersAccess = h.testCfg.PipeAuthenticatedUsersAccess + } + + conn, err := waitForSingleClient(ctx, pipeAuthenticatedUsersAccess) + if err != nil { + return nil, "", trace.Wrap(err, "waiting for client") + } + closeConnOnce := sync.OnceValue(conn.Close) + // Always defer conn.Close and return the error. + defer func() { + err = trace.NewAggregate(err, trace.Wrap(closeConnOnce(), "closing conn")) + }() + // Close conn early to unblock reads if ctx is canceled. + defer context.AfterFunc(ctx, func() { _ = closeConnOnce() })() + + dir, err := h.getSecureUpdateDir() + if err != nil { + return nil, "", trace.Wrap(err) + } + + updatePath := filepath.Join(dir, "update.exe") + updateMeta, err := readUpdate(conn, updatePath) + if err != nil { + return nil, "", trace.Wrap(err) + } + return updateMeta, updatePath, nil +} + +// waitForSingleClient waits for the first client and then closes the listener. +func waitForSingleClient(ctx context.Context, authenticatedUsersAccess uint32) (net.Conn, error) { + l, err := winio.ListenPipe(PipePath, &winio.PipeConfig{ + SecurityDescriptor: makePipeServerSecurityDescriptor(authenticatedUsersAccess), + }) + if err != nil { + return nil, trace.Wrap(err) + } + + resCh := make(chan acceptResult, 1) + + go func() { + conn, acceptErr := l.Accept() + resCh <- acceptResult{conn: conn, err: acceptErr} + }() + + select { + case <-ctx.Done(): + err = l.Close() + // Drain the goroutine — l.Close() unblocks Accept(). + res := <-resCh + if res.conn != nil { + _ = res.conn.Close() + } + return nil, trace.NewAggregate(ctx.Err(), err) + case res := <-resCh: + if res.err != nil { + return nil, trace.Wrap(res.err) + } + if err = l.Close(); err != nil { + return nil, trace.NewAggregate(err, res.conn.Close()) + } + return res.conn, nil + } +} + +// getSecureUpdateDir secures %ProgramData%\TeleportConnectUpdater directory and then returns +// a unique %ProgramData%\TeleportConnectUpdater\ path. +func (h *handler) getSecureUpdateDir() (string, error) { + updateRoot := h.testCfg.UpdateBaseDir + if updateRoot == "" { + programData, err := windows.KnownFolderPath(windows.FOLDERID_ProgramData, 0) + if err != nil { + return "", trace.Wrap(err, "reading ProgramData path") + } + updateRoot = filepath.Join(programData, "TeleportConnectUpdater") + } + + descriptor := updateDirSecurityDescriptor + if h.testCfg.UpdateDirSecurityDescriptor != "" { + descriptor = h.testCfg.UpdateDirSecurityDescriptor + } + sd, err := windows.SecurityDescriptorFromString(descriptor) + if err != nil { + return "", trace.Wrap(err, "creating security descriptor") + } + + sa := &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + SecurityDescriptor: sd, + InheritHandle: 0, + } + + if err = ensureDirIsSecure(updateRoot, sa); err != nil { + return "", trace.Wrap(err, "securing TeleportConnectUpdater directory") + } + + err = cleanupOldUpdates(updateRoot) + if err != nil { + return "", trace.Wrap(err, "cleaning up old updates") + } + + // Create a per-update random directory. This prevents DLL planting attacks, as the update is executed from its own directory. + newGUID := uuid.New().String() + updateDir := filepath.Join(updateRoot, newGUID) + updateDirPtr, err := windows.UTF16PtrFromString(updateDir) + if err != nil { + return "", trace.Wrap(err) + } + + if err = windows.CreateDirectory(updateDirPtr, sa); err != nil { + return "", trace.Wrap(err, "failed to create update dir") + } + + return updateDir, nil +} + +// ensureDirIsSecure guarantees that the directory exists and is locked down to SYSTEM/Admins only. +func ensureDirIsSecure(dir string, sa *windows.SecurityAttributes) error { + namePtr, err := windows.UTF16PtrFromString(dir) + if err != nil { + return trace.Wrap(err) + } + + // Try to create the directory with the secure ACLs immediately. + err = windows.CreateDirectory(namePtr, sa) + // If the directory exists, continue with verification and reapply the ACLs. + if err != nil && !errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return trace.Wrap(err, "creating directory") + } + + // If the directory exists, open a handle with DACL modification rights + // We use FILE_FLAG_OPEN_REPARSE_POINT to ensure we open the directory itself, + // not a target it might point to (it could be a junction). + dirHandle, err := windows.CreateFile( + namePtr, + windows.READ_CONTROL|windows.WRITE_DAC|windows.WRITE_OWNER, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, + 0, + ) + if err != nil { + return trace.Wrap(err, "failed to open handle to existing directory") + } + defer windows.CloseHandle(dirHandle) + + // Verify it is a real directory (not a symlink/junction) + // This prevents redirection attacks where we might unexpectedly secure a system folder. + var info windows.ByHandleFileInformation + if err = windows.GetFileInformationByHandle(dirHandle, &info); err != nil { + return trace.Wrap(err, "getting file information") + } + + if info.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return trace.BadParameter("security violation: %s is a reparse point", dir) + } + + if info.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + return trace.BadParameter("security violation: %s exists but is not a directory", dir) + } + + owner, _, err := sa.SecurityDescriptor.Owner() + if err != nil { + return trace.Wrap(err, "reading owner from security descriptor") + } + dacl, _, err := sa.SecurityDescriptor.DACL() + if err != nil { + return trace.Wrap(err, "reading DACL from security descriptor") + } + if dacl == nil { + return trace.BadParameter("security violation: DACL must not be empty") + } + + // Reapply directory ACLs. + err = windows.SetSecurityInfo( + dirHandle, + windows.SE_FILE_OBJECT, + // PROTECTED_DACL_SECURITY_INFORMATION stops the directory from inheriting + // "User Write" permissions from the parent (%ProgramData%). + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + owner, + nil, + dacl, + nil, + ) + + return trace.Wrap(err, "resetting directory security") +} + +// cleanupOldUpdates removes stale update directories and files from the cache. +// Failures to remove individual entries are logged and ignored so cleanup can continue. +// +// This is fine, as updates are always stored in freshly generated, random subdirectories. +// This saves us from accidentally executing attacker-controlled files (e.g., planted DLLs), +// +// Important: +// This function runs with SYSTEM privileges and relies on the Go standard library’s +// os.RemoveAll implementation on Windows. It detects reparse points (symlinks and +// junctions) and removes the link itself without ever recursing into the target, +// mitigating junction/symlink crossing attacks. +func cleanupOldUpdates(baseDir string) error { + entries, err := os.ReadDir(baseDir) + if err != nil { + return trace.Wrap(err) + } + for _, entry := range entries { + fullPath := filepath.Join(baseDir, entry.Name()) + + err = os.RemoveAll(fullPath) + if err != nil { + log.Error("Failed to remove old update file", "path", fullPath, "error", err) + } + } + return nil +} + +func ensureIsUpgrade(updateVersion string) error { + updateSemver, err := semver.NewVersion(updateVersion) + if err != nil { + return trace.Wrap(err, "invalid update version %q", updateVersion) + } + current := teleport.SemVer() + if current == nil { + return trace.BadParameter("current version is not available") + } + if updateSemver.Compare(*current) <= 0 { + return trace.BadParameter("update version %s is not newer than current version %s", updateSemver, current) + } + return nil +} + +func (h *handler) downloadChecksum(ctx context.Context, baseUrl string, version string) ([]byte, error) { + parsedBaseURL, err := url.Parse(baseUrl) + if err != nil { + return nil, trace.Wrap(err, "parsing base URL") + } + // Keep updater policy aligned with Service.GetConfig RPC validation and reject non-TLS CDNs even if this path is called outside the UI flow. + if parsedBaseURL.Scheme != "https" { + return nil, trace.BadParameter("CDN base URL must be https") + } + filename := fmt.Sprintf("Teleport Connect Setup-%s.exe.sha256", version) + downloadURL := parsedBaseURL.JoinPath(filename) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL.String(), nil) + if err != nil { + return nil, trace.Wrap(err) + } + client := http.DefaultClient + if h.testCfg.HTTPClient != nil { + client = h.testCfg.HTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, trace.Wrap(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, trace.BadParameter("update hash request failed with status %s", resp.Status) + } + + var buf bytes.Buffer + _, err = io.CopyN(&buf, resp.Body, sha256.Size*2) // SHA bytes to hex + if err != nil { + return nil, trace.Wrap(err) + } + hexBytes, err := hex.DecodeString(buf.String()) + if err != nil { + return nil, trace.Wrap(err) + } + return hexBytes, nil +} + +func verifyUpdateChecksum(updatePath string, expectedHash []byte) error { + file, err := os.Open(updatePath) + if err != nil { + return trace.Wrap(err) + } + defer file.Close() + + hasher := sha256.New() + if _, err = io.Copy(hasher, file); err != nil { + return trace.Wrap(err) + } + actual := hasher.Sum(nil) + if !bytes.Equal(actual, expectedHash) { + return trace.BadParameter("hash of the update does not match downloaded checksum") + } + return nil +} + +func runInstaller(updatePath string, forceRun bool) error { + args := []string{"--updated", "/S", "/allusers"} + if forceRun { + args = append(args, "--force-run") + } + cmd := exec.Command(updatePath, args...) + + log.Info("Running command", "command", cmd.String()) + + err := cmd.Start() + if err != nil { + return trace.Wrap(err, "starting installer path=%s args=%q", updatePath, strings.Join(args, " ")) + } + + // Release the handle so the parent process can exit and the installer will continue. + return trace.Wrap(cmd.Process.Release()) +} diff --git a/lib/teleterm/autoupdate/privilegedupdater/zsyscall_windows.go b/lib/teleterm/autoupdate/privilegedupdater/zsyscall_windows.go new file mode 100644 index 0000000000000..979cd421841bb --- /dev/null +++ b/lib/teleterm/autoupdate/privilegedupdater/zsyscall_windows.go @@ -0,0 +1,61 @@ +// Code generated by 'go generate'; DO NOT EDIT. + +package privilegedupdater + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var _ unsafe.Pointer + +// Do the interface allocations only once for common +// Errno values. +const ( + errnoERROR_IO_PENDING = 997 +) + +var ( + errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING) + errERROR_EINVAL error = syscall.EINVAL +) + +// errnoErr returns common boxed Errno values, to prevent +// allocations at runtime. +func errnoErr(e syscall.Errno) error { + switch e { + case 0: + return errERROR_EINVAL + case errnoERROR_IO_PENDING: + return errERROR_IO_PENDING + } + // TODO: add more here, after collecting data on the common + // error values see on Windows. (perhaps when running + // all.bat?) + return e +} + +var ( + modcrypt32 = windows.NewLazySystemDLL("crypt32.dll") + + procCryptMsgClose = modcrypt32.NewProc("CryptMsgClose") + procCryptMsgGetParam = modcrypt32.NewProc("CryptMsgGetParam") +) + +func CryptMsgClose(msg windows.Handle) (err error) { + r1, _, e1 := syscall.SyscallN(procCryptMsgClose.Addr(), uintptr(msg)) + if r1 == 0 { + err = errnoErr(e1) + } + return +} + +func CryptMsgGetParam(msg windows.Handle, paramType uint32, index uint32, data *byte, dataLen *uint32) (err error) { + r1, _, e1 := syscall.SyscallN(procCryptMsgGetParam.Addr(), uintptr(msg), uintptr(paramType), uintptr(index), uintptr(unsafe.Pointer(data)), uintptr(unsafe.Pointer(dataLen))) + if r1 == 0 { + err = errnoErr(e1) + } + return +} diff --git a/lib/teleterm/autoupdate/service.go b/lib/teleterm/autoupdate/service.go index f124d8904618d..9177419c5e8d8 100644 --- a/lib/teleterm/autoupdate/service.go +++ b/lib/teleterm/autoupdate/service.go @@ -18,21 +18,31 @@ package autoupdate import ( "context" + "net/url" "os" + "strings" "sync" "time" + "github.com/coreos/go-semver/semver" "github.com/gravitational/trace" "golang.org/x/sync/errgroup" "github.com/gravitational/teleport/api/client/webclient" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/auto_update/v1" "github.com/gravitational/teleport/lib/autoupdate" - "github.com/gravitational/teleport/lib/modules" + "github.com/gravitational/teleport/lib/teleterm/autoupdate/common" "github.com/gravitational/teleport/lib/teleterm/clusters" ) -const pingTimeout = 5 * time.Second +const ( + pingTimeout = 5 * time.Second + + // When tsh runs as a daemon, auto-updates must be disabled. Connect enforces this by + // launching tsh with TELEPORT_TOOLS_VERSION=off, and forwards the real value via + // FORWARDED_TELEPORT_TOOLS_VERSION. + forwardedTeleportToolsEnvVar = "FORWARDED_TELEPORT_TOOLS_VERSION" +) // Service implements gRPC service for autoupdate. type Service struct { @@ -131,30 +141,74 @@ func (s *Service) pingCluster(ctx context.Context, cluster *clusters.Cluster) (* return find, trace.Wrap(err) } -// GetDownloadBaseUrl returns base URL for downloading Teleport packages. -func (s *Service) GetDownloadBaseUrl(_ context.Context, _ *api.GetDownloadBaseUrlRequest) (*api.GetDownloadBaseUrlResponse, error) { - baseURL, err := resolveBaseURL() +// GetConfig retrieves the local auto updates configuration. +func (s *Service) GetConfig(_ context.Context, _ *api.GetConfigRequest) (*api.GetConfigResponse, error) { + config, err := platformGetConfig() if err != nil { return nil, trace.Wrap(err) } - return &api.GetDownloadBaseUrlResponse{ - BaseUrl: baseURL, - }, trace.Wrap(err) -} + toolsVersionValue := strings.TrimSpace(config.GetToolsVersion().Value) + toolsVersionSource := config.GetToolsVersion().Source + switch toolsVersionValue { + case "": + toolsVersionSource = api.ConfigSource_CONFIG_SOURCE_UNSPECIFIED + case common.TeleportToolsVersionOff: + break + default: + if _, err = semver.NewVersion(toolsVersionValue); err != nil { + return nil, trace.BadParameter("invalid version %v for tools version", toolsVersionValue) + } + } -// resolveBaseURL generates the base URL using the same logic as the teleport/lib/autoupdate/tools package. -func resolveBaseURL() (string, error) { - envBaseURL := os.Getenv(autoupdate.BaseURLEnvVar) - if envBaseURL != "" { - // TODO(gzdunek): Validate if it's correct URL. - return envBaseURL, nil + cdnBaseUrlValue := strings.TrimSpace(config.GetCdnBaseUrl().Value) + cdnBaseUrlSource := config.GetCdnBaseUrl().Source + if cdnBaseUrlValue == "" { + cdnBaseUrlSource = api.ConfigSource_CONFIG_SOURCE_UNSPECIFIED + } else { + if err = validateURL(cdnBaseUrlValue); err != nil { + return nil, trace.Wrap(err) + } + } + + defaultBaseUrlValue := common.GetDefaultBaseURL() + if cdnBaseUrlValue == "" && defaultBaseUrlValue != "" { + cdnBaseUrlValue = defaultBaseUrlValue + cdnBaseUrlSource = api.ConfigSource_CONFIG_SOURCE_DEFAULT } - m := modules.GetModules() - if m.BuildType() == modules.BuildOSS { - return "", trace.BadParameter("Client tools updates are disabled as they are licensed under AGPL. To use Community Edition builds or custom binaries, set the 'TELEPORT_CDN_BASE_URL' environment variable.") + return &api.GetConfigResponse{ + ToolsVersion: &api.ConfigValue{Value: toolsVersionValue, Source: toolsVersionSource}, + CdnBaseUrl: &api.ConfigValue{Value: cdnBaseUrlValue, Source: cdnBaseUrlSource}, + }, nil +} + +func validateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return trace.BadParameter("invalid CDN base URL: %v", err) + } + if u.Scheme != "https" { + return trace.BadParameter("CDN base URL must be https") + } + if u.Host == "" { + return trace.BadParameter("CDN base URL must include host") } + return nil +} - return autoupdate.DefaultBaseURL, nil +func readConfigFromEnvVars() (*api.GetConfigResponse, error) { + envBaseURL := os.Getenv(autoupdate.BaseURLEnvVar) + envTeleportToolsVersion := os.Getenv(forwardedTeleportToolsEnvVar) + + return &api.GetConfigResponse{ + CdnBaseUrl: &api.ConfigValue{ + Value: envBaseURL, + Source: api.ConfigSource_CONFIG_SOURCE_ENV_VAR, + }, + ToolsVersion: &api.ConfigValue{ + Value: envTeleportToolsVersion, + Source: api.ConfigSource_CONFIG_SOURCE_ENV_VAR, + }, + }, nil } diff --git a/lib/teleterm/autoupdate/service_other.go b/lib/teleterm/autoupdate/service_other.go new file mode 100644 index 0000000000000..97a5d336d99fc --- /dev/null +++ b/lib/teleterm/autoupdate/service_other.go @@ -0,0 +1,31 @@ +//go:build !windows + +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package autoupdate + +import ( + "github.com/gravitational/trace" + + api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/auto_update/v1" +) + +// platformGetConfig retrieves the local auto updates configuration. +func platformGetConfig() (*api.GetConfigResponse, error) { + config, err := readConfigFromEnvVars() + return config, trace.Wrap(err) +} diff --git a/lib/teleterm/autoupdate/service_windows.go b/lib/teleterm/autoupdate/service_windows.go new file mode 100644 index 0000000000000..49d59b157120c --- /dev/null +++ b/lib/teleterm/autoupdate/service_windows.go @@ -0,0 +1,127 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package autoupdate + +import ( + "context" + "os" + "path/filepath" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows/registry" + + api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/auto_update/v1" + "github.com/gravitational/teleport/lib/teleterm/autoupdate/common" +) + +const ( + // Defined in electron-builder-config.js + teleportConnectGUID = "22539266-67e8-54a3-83b9-dfdca7b33ee1" + teleportConnectKeyPath = `SOFTWARE\` + teleportConnectGUID + registryValueInstallLocation = "InstallLocation" +) + +// GetInstallationMetadata returns installation metadata of the currently running app instance. +func (s *Service) GetInstallationMetadata(_ context.Context, _ *api.GetInstallationMetadataRequest) (*api.GetInstallationMetadataResponse, error) { + perMachine, err := isPerMachineInstall() + if err != nil { + return nil, trace.Wrap(err) + } + + return &api.GetInstallationMetadataResponse{IsPerMachineInstall: perMachine}, nil +} + +// platformGetConfig retrieves the local auto updates configuration. +func platformGetConfig() (*api.GetConfigResponse, error) { + perMachine, err := isPerMachineInstall() + if err != nil { + return nil, trace.Wrap(err) + } + + machineValues, err := common.ReadRegistryPolicyValues(registry.LOCAL_MACHINE) + if err != nil { + return nil, trace.Wrap(err) + } + + config := &api.GetConfigResponse{ + CdnBaseUrl: &api.ConfigValue{ + Value: machineValues.CDNBaseURL, + Source: api.ConfigSource_CONFIG_SOURCE_POLICY, + }, + ToolsVersion: &api.ConfigValue{ + Value: machineValues.Version, + Source: api.ConfigSource_CONFIG_SOURCE_POLICY, + }, + } + + // If per-machine config is fully set, there's no need to check other sources. + perMachineConfigFullySet := machineValues.CDNBaseURL != "" && machineValues.Version != "" + if perMachineConfigFullySet { + return config, nil + } + + if !perMachine { + userValues, err := common.ReadRegistryPolicyValues(registry.CURRENT_USER) + if err != nil { + return nil, trace.Wrap(err) + } + + if machineValues.CDNBaseURL == "" { + config.CdnBaseUrl.Value = userValues.CDNBaseURL + } + + if machineValues.Version == "" { + config.ToolsVersion.Value = userValues.Version + } + } + + // Read deprecated env vars. If they are set and the app is installed per-machine, updates must use + // the standard UAC installer (no privileged updater). + envVarConfig, err := readConfigFromEnvVars() + if err != nil { + return nil, trace.Wrap(err) + } + if config.CdnBaseUrl.Value == "" { + config.CdnBaseUrl = envVarConfig.GetCdnBaseUrl() + } + + if config.ToolsVersion.Value == "" { + config.ToolsVersion = envVarConfig.GetToolsVersion() + } + + return config, nil +} + +func isPerMachineInstall() (bool, error) { + perMachineLocation, err := common.ReadRegistryValue(registry.LOCAL_MACHINE, teleportConnectKeyPath, registryValueInstallLocation) + if err != nil { + if trace.IsNotFound(err) { + return false, nil + } + return false, trace.Wrap(err) + } + + exePath, err := os.Executable() + if err != nil { + return false, trace.Wrap(err) + } + + // tsh is placed in /resources/bin/tsh.exe. + exePathInPerMachineLocation := filepath.Join(perMachineLocation, "resources", "bin", "tsh.exe") + + return exePath == exePathInPerMachineLocation, nil +} diff --git a/lib/teleterm/vnet/service_windows.go b/lib/teleterm/vnet/service_windows.go index d41fe2ee00f7d..b7c2a97142823 100644 --- a/lib/teleterm/vnet/service_windows.go +++ b/lib/teleterm/vnet/service_windows.go @@ -18,9 +18,13 @@ package vnet import ( "context" + "errors" "github.com/gravitational/trace" + "golang.org/x/sys/windows" + api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1" + "github.com/gravitational/teleport/lib/vnet" "github.com/gravitational/teleport/lib/vnet/diag" ) @@ -46,3 +50,33 @@ func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, err sshDiag, }, nil } + +// CheckInstallTimeRequirements verifies the existence of the VNet system service, which is installed only in per-machine setups. +func (s *Service) CheckInstallTimeRequirements(_ context.Context, _ *api.CheckInstallTimeRequirementsRequest) (*api.CheckInstallTimeRequirementsResponse, error) { + err := vnet.VerifyServiceInstalledAndMatchesClient() + if err == nil { + return &api.CheckInstallTimeRequirementsResponse{ + Status: &api.CheckInstallTimeRequirementsResponse_WindowsServiceStatus{ + WindowsServiceStatus: api.WindowsServiceStatus_WINDOWS_SERVICE_STATUS_OK, + }, + }, nil + } + + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return &api.CheckInstallTimeRequirementsResponse{ + Status: &api.CheckInstallTimeRequirementsResponse_WindowsServiceStatus{ + WindowsServiceStatus: api.WindowsServiceStatus_WINDOWS_SERVICE_STATUS_DOES_NOT_EXIST, + }, + }, nil + + } + if trace.IsCompareFailed(err) { + return &api.CheckInstallTimeRequirementsResponse{ + Status: &api.CheckInstallTimeRequirementsResponse_WindowsServiceStatus{ + WindowsServiceStatus: api.WindowsServiceStatus_WINDOWS_SERVICE_STATUS_VERSION_MISMATCH, + }, + }, nil + + } + return nil, trace.Wrap(err) +} diff --git a/lib/vnet/admin_process_windows.go b/lib/vnet/admin_process_windows.go index 09afbf8619d48..c50ac22f0b441 100644 --- a/lib/vnet/admin_process_windows.go +++ b/lib/vnet/admin_process_windows.go @@ -210,6 +210,8 @@ func compareFiles(p1, p2 string) error { return trace.Wrap(err) } if !bytes.Equal(h1, h2) { + // Used by lib/teleterm/vnet.(*Service).CheckInstallTimeRequirements: + // trace.CompareFailed => WINDOWS_SERVICE_STATUS_VERSION_MISMATCH. return trace.CompareFailed("files %s and %s are not equal", p1, p2) } return nil diff --git a/lib/vnet/install_service_windows.go b/lib/vnet/install_service_windows.go index fac0d068ddef8..35f80fd457ea8 100644 --- a/lib/vnet/install_service_windows.go +++ b/lib/vnet/install_service_windows.go @@ -18,170 +18,41 @@ package vnet import ( "context" - "errors" - "fmt" "os" "path/filepath" - "strings" "github.com/gravitational/trace" "golang.org/x/sys/windows" - "golang.org/x/sys/windows/svc/eventlog" - "golang.org/x/sys/windows/svc/mgr" - "github.com/gravitational/teleport" - eventlogutils "github.com/gravitational/teleport/lib/utils/log/eventlog" + "github.com/gravitational/teleport/lib/windowsservice" ) -// InstallService installs the VNet windows service. -// -// Windows services are installed by the service manager, which takes a path to -// the service executable. So that regular users are not able to overwrite the -// executable at that path, we use a path under %PROGRAMFILES%, which is not -// writable by regular users by default. -func InstallService(ctx context.Context) (err error) { +const eventSource = "vnet" + +// InstallService installs the VNet Windows service. +func InstallService(ctx context.Context) error { tshPath, err := os.Executable() if err != nil { return trace.Wrap(err, "getting current exe path") } - if err := assertTshInProgramFiles(tshPath); err != nil { - return trace.Wrap(err, "checking if tsh.exe is installed under %%PROGRAMFILES%%") - } if err := assertWintunInstalled(tshPath); err != nil { return trace.Wrap(err, "checking if wintun.dll is installed next to %s", tshPath) } - - svcMgr, err := mgr.Connect() - if err != nil { - return trace.Wrap(err, "connecting to Windows service manager") - } - svc, err := svcMgr.OpenService(serviceName) - if err != nil { - if !errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { - return trace.Wrap(err, "unexpected error checking if Windows service %s exists", serviceName) - } - // The service has not been created yet and must be installed. - svc, err = svcMgr.CreateService( - serviceName, - tshPath, - mgr.Config{ - StartType: mgr.StartManual, - }, - ServiceCommand, - ) - if err != nil { - return trace.Wrap(err, "creating VNet Windows service") - } - } - if err := svc.Close(); err != nil { - return trace.Wrap(err, "closing VNet Windows service") - } - if err := grantServiceRights(); err != nil { - return trace.Wrap(err, "granting authenticated users permission to control the VNet Windows service") - } - if err := installEventSource(); err != nil { - trace.Wrap(err, "creating event source for logging") - } - if err := logInstallationEvent("VNet service installed"); err != nil { - trace.Wrap(err, "logging installation event") - } - return nil + return trace.Wrap(windowsservice.Install(ctx, &windowsservice.InstallConfig{ + Name: serviceName, + Description: serviceDescription, + Command: []string{ServiceCommand}, + EventSourceName: eventSource, + AccessPermissions: windows.SERVICE_QUERY_STATUS | windows.SERVICE_START | windows.SERVICE_STOP | windows.SERVICE_QUERY_CONFIG, + })) } -// UninstallService uninstalls the VNet windows service. -func UninstallService(ctx context.Context) (err error) { - svcMgr, err := mgr.Connect() - if err != nil { - return trace.Wrap(err, "connecting to Windows service manager") - } - svc, err := svcMgr.OpenService(serviceName) - if err != nil { - return trace.Wrap(err, "opening Windows service %s", serviceName) - } - if err := svc.Delete(); err != nil { - return trace.Wrap(err, "deleting Windows service %s", serviceName) - } - if err := svc.Close(); err != nil { - return trace.Wrap(err, "closing VNet Windows service") - } - - if err := logInstallationEvent("VNet service uninstalled"); err != nil { - trace.Wrap(err, "logging installation event") - } - if err := eventlogutils.Remove(eventlogutils.LogName, eventSource); err != nil { - return trace.Wrap(err, "removing event source for logging") - } - - return nil -} - -func grantServiceRights() error { - // Get the current security info for the service, requesting only the DACL - // (discretionary access control list). - si, err := windows.GetNamedSecurityInfo(serviceName, windows.SE_SERVICE, windows.DACL_SECURITY_INFORMATION) - if err != nil { - return trace.Wrap(err, "getting current service security information") - } - // Get the DACL from the security info. - dacl, _ /*defaulted*/, err := si.DACL() - if err != nil { - return trace.Wrap(err, "getting current service DACL") - } - // This is the universal well-known SID for "Authenticated Users". - authenticatedUsersSID, err := windows.StringToSid("S-1-5-11") - if err != nil { - return trace.Wrap(err, "parsing authenticated users SID") - } - // Build an explicit access entry allowing authenticated users to start, - // stop, and query the service. - ea := []windows.EXPLICIT_ACCESS{{ - AccessPermissions: windows.SERVICE_QUERY_STATUS | windows.SERVICE_START | windows.SERVICE_STOP, - AccessMode: windows.GRANT_ACCESS, - Trustee: windows.TRUSTEE{ - TrusteeForm: windows.TRUSTEE_IS_SID, - TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, - TrusteeValue: windows.TrusteeValueFromSID(authenticatedUsersSID), - }, - }} - // Merge the new explicit access entry with the existing DACL. - dacl, err = windows.ACLFromEntries(ea, dacl) - if err != nil { - return trace.Wrap(err, "merging service DACL entries") - } - // Set the DACL on the service security info. - if err := windows.SetNamedSecurityInfo( - serviceName, - windows.SE_SERVICE, - windows.DACL_SECURITY_INFORMATION, - nil, // owner - nil, // group - dacl, // dacl - nil, // sacl - ); err != nil { - return trace.Wrap(err, "setting service DACL") - } - return nil -} - -// assertTshInProgramFiles asserts that tsh is a regular file installed under -// the program files directory (usually C:\Program Files\). -func assertTshInProgramFiles(tshPath string) error { - if err := assertRegularFile(tshPath); err != nil { - return trace.Wrap(err) - } - programFiles := os.Getenv("PROGRAMFILES") - if programFiles == "" { - return trace.Errorf("PROGRAMFILES env var is not set") - } - // Windows file paths are case-insensitive. - cleanedProgramFiles := strings.ToLower(filepath.Clean(programFiles)) + string(filepath.Separator) - cleanedTshPath := strings.ToLower(filepath.Clean(tshPath)) - if !strings.HasPrefix(cleanedTshPath, cleanedProgramFiles) { - return trace.BadParameter( - "tsh.exe is currently installed at %s, it must be installed under %s in order to install the VNet Windows service", - tshPath, programFiles) - } - return nil +// UninstallService uninstalls the Windows VNet service. +func UninstallService(ctx context.Context) error { + return trace.Wrap(windowsservice.Uninstall(ctx, &windowsservice.UninstallConfig{ + Name: serviceName, + EventSourceName: eventSource, + })) } // asertWintunInstalled returns an error if wintun.dll is not a regular file @@ -203,34 +74,3 @@ func assertRegularFile(path string) error { } return nil } - -const eventSource = "vnet" - -func installEventSource() error { - exe, err := os.Executable() - if err != nil { - return trace.Wrap(err) - } - // Assume that the message file is shipped next to tsh.exe. - msgFilePath := filepath.Join(filepath.Dir(exe), "msgfile.dll") - - // This should create a registry entry under - // SYSTEM\CurrentControlSet\Services\EventLog\Teleport\vnet with an absolute path to msgfile.dll. - // If the user moves Teleport Connect to some other directory, logs will still be captured, but - // they might display a message about missing event ID until the user reinstalls the app. - err = eventlogutils.Install(eventlogutils.LogName, eventSource, msgFilePath, false /* useExpandKey */) - return trace.Wrap(err) -} - -func logInstallationEvent(eventMessage string) error { - log, err := eventlog.Open(eventSource) - if err != nil { - return trace.Wrap(err, "opening logger") - } - - if err := log.Info(eventlogutils.EventID, fmt.Sprintf("%s version:%s", eventMessage, teleport.Version)); err != nil { - return trace.Wrap(err, "writing log message") - } - - return trace.Wrap(log.Close(), "closing logger") -} diff --git a/lib/vnet/service_client_windows.go b/lib/vnet/service_client_windows.go new file mode 100644 index 0000000000000..1aebd96add000 --- /dev/null +++ b/lib/vnet/service_client_windows.go @@ -0,0 +1,73 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package vnet + +import ( + "os" + "syscall" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc/mgr" +) + +// VerifyServiceInstalledAndMatchesClient returns nil if the service is installed and matches the client version. +// Called by the client. +func VerifyServiceInstalledAndMatchesClient() error { + // Avoid [mgr.Connect] because it requests elevated permissions. + scManager, err := windows.OpenSCManager(nil /*machine*/, nil /*database*/, windows.SC_MANAGER_CONNECT) + if err != nil { + return trace.Wrap(err, "opening Windows service manager") + } + defer windows.CloseServiceHandle(scManager) + serviceNamePtr, err := syscall.UTF16PtrFromString(serviceName) + if err != nil { + return trace.Wrap(err, "converting service name to UTF16") + } + serviceHandle, err := windows.OpenService(scManager, serviceNamePtr, windows.SERVICE_QUERY_CONFIG) + if err != nil { + return trace.Wrap(err, "opening Windows service %v", serviceName) + } + service := &mgr.Service{ + Name: serviceName, + Handle: serviceHandle, + } + defer service.Close() + + config, err := service.Config() + if err != nil { + return trace.Wrap(err, "getting service config") + } + exe, err := os.Executable() + if err != nil { + return trace.Wrap(err, "getting executable path") + } + serviceArgs, err := windows.DecomposeCommandLine(config.BinaryPathName) + if err != nil { + return trace.Wrap(err, "parsing Windows service binary command line") + } + if len(serviceArgs) == 0 { + return trace.BadParameter("Windows service has empty binary command line") + } + + // Require exact binary match between the client and service. + // Hash comparison is enough here because same binary means same version. + if err = compareFiles(exe, serviceArgs[0]); err != nil { + return trace.Wrap(err, "comparing tsh.exe executable with service executable") + } + return nil +} diff --git a/lib/vnet/service_windows.go b/lib/vnet/service_windows.go index d672cdfd841da..9823ef6279ab7 100644 --- a/lib/vnet/service_windows.go +++ b/lib/vnet/service_windows.go @@ -17,12 +17,8 @@ package vnet import ( - "cmp" "context" - "errors" "log/slog" - "os" - "strconv" "syscall" "time" @@ -33,7 +29,7 @@ import ( "golang.org/x/sys/windows/svc/mgr" "github.com/gravitational/teleport" - logutils "github.com/gravitational/teleport/lib/utils/log" + "github.com/gravitational/teleport/lib/windowsservice" ) const ( @@ -126,83 +122,23 @@ func startService(ctx context.Context, cfg *windowsAdminProcessConfig) (*mgr.Ser // ServiceMain runs the Windows VNet admin service. func ServiceMain() error { - closeFn, err := setupServiceLogger() + closeLogger, err := windowsservice.InitSlogEventLogger(eventSource) if err != nil { - return trace.Wrap(err, "setting up logger for service") - } - - if err := svc.Run(serviceName, &windowsService{}); err != nil { - closeFn() - return trace.Wrap(err, "running Windows service") + return trace.Wrap(err) } - - return trace.Wrap(closeFn(), "closing logger") -} - -// windowsService implements [svc.Handler]. -type windowsService struct{} - -// Execute implements [svc.Handler.Execute], the GoDoc is copied below. -// -// Execute will be called by the package code at the start of the service, and -// the service will exit once Execute completes. Inside Execute you must read -// service change requests from [requests] and act accordingly. You must keep -// service control manager up to date about state of your service by writing -// into [status] as required. args contains service name followed by argument -// strings passed to the service. -// You can provide service exit code in exitCode return parameter, with 0 being -// "no error". You can also indicate if exit code, if any, is service specific -// or not by using svcSpecificEC parameter. -func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { logger := slog.With(teleport.ComponentKey, teleport.Component("vnet", "windows-service")) - const cmdsAccepted = svc.AcceptStop // Interrogate is always accepted and there is no const for it. - status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - errCh := make(chan error) - go func() { errCh <- s.run(ctx, args) }() - var terminateTimedOut <-chan time.Time -loop: - for { - select { - case request := <-requests: - switch request.Cmd { - case svc.Interrogate: - state := svc.Running - if ctx.Err() != nil { - state = svc.StopPending - } - status <- svc.Status{State: state, Accepts: cmdsAccepted} - case svc.Stop: - logger.InfoContext(ctx, "Received stop command, shutting down service") - // Cancel the context passed to s.run to terminate the - // networking stack. - cancel() - terminateTimedOut = cmp.Or(terminateTimedOut, time.After(terminateTimeout)) - status <- svc.Status{State: svc.StopPending} - } - case <-terminateTimedOut: - logger.ErrorContext(ctx, "Networking stack failed to terminate within timeout, exiting process", - slog.Duration("timeout", terminateTimeout)) - exitCode = 1 - break loop - case err := <-errCh: - if err == nil || errors.Is(err, context.Canceled) { - logger.InfoContext(ctx, "Service terminated") - } else { - logger.ErrorContext(ctx, "Service terminated", "error", err) - exitCode = 1 - } - break loop - } - } - status <- svc.Status{State: svc.Stopped, Win32ExitCode: exitCode} - return false, exitCode + err = windowsservice.Run(&windowsservice.RunConfig{ + Name: serviceName, + Handler: &handler{}, + Logger: logger, + }) + return trace.NewAggregate(err, closeLogger()) } -func (s *windowsService) run(ctx context.Context, args []string) error { +type handler struct{} + +func (w *handler) Execute(ctx context.Context, args []string) error { var cfg windowsAdminProcessConfig app := kingpin.New(serviceName, "Teleport VNet Windows Service") serviceCmd := app.Command("vnet-service", "Start the VNet service.") @@ -221,23 +157,3 @@ func (s *windowsService) run(ctx context.Context, args []string) error { } return nil } - -func setupServiceLogger() (func() error, error) { - level := slog.LevelInfo - if envVar := os.Getenv(teleport.VerboseLogsEnvVar); envVar != "" { - isDebug, err := strconv.ParseBool(envVar) - if err != nil { - return nil, trace.Wrap(err, "parsing %s", teleport.VerboseLogsEnvVar) - } - if isDebug { - level = slog.LevelDebug - } - } - - handler, close, err := logutils.NewSlogEventLogHandler("vnet", level) - if err != nil { - return nil, trace.Wrap(err, "initializing log handler") - } - slog.SetDefault(slog.New(handler)) - return close, nil -} diff --git a/lib/windowsservice/install_windows.go b/lib/windowsservice/install_windows.go new file mode 100644 index 0000000000000..21dbd91dedc6c --- /dev/null +++ b/lib/windowsservice/install_windows.go @@ -0,0 +1,267 @@ +// 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 windowsservice + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" + + "github.com/gravitational/teleport" + eventlogutils "github.com/gravitational/teleport/lib/utils/log/eventlog" +) + +// InstallConfig defines parameters for installing a Windows service +// that is implemented by tsh.exe. +type InstallConfig struct { + // Name is the service name. + Name string + // Description is the service description. + Description string + // Command is the tsh subcommand (and its arguments) that the service manager + // invokes on start. Each element becomes a separate command-line argument. + Command []string + // EventSourceName is the name of an event source that will log service events. + EventSourceName string + // AccessPermissions defines which service control actions are granted to + // authenticated users (e.g., start/stop/query). + AccessPermissions windows.ACCESS_MASK +} + +// Install installs a Windows service implemented by tsh.exe. +// +// Windows services are installed by the service manager, which takes a path to +// the service executable. So that regular users are not able to overwrite the +// executable at that path, we use a path under %PROGRAMFILES%, which is not +// writable by regular users by default. +func Install(ctx context.Context, cfg *InstallConfig) (err error) { + if cfg.Name == "" { + return trace.BadParameter("service name is required") + } + if len(cfg.Command) == 0 { + return trace.BadParameter("command is required") + } + if cfg.EventSourceName == "" { + return trace.BadParameter("event source name is required") + } + if cfg.AccessPermissions == 0 { + return trace.BadParameter("access permissions is required") + } + + tshPath, err := os.Executable() + if err != nil { + return trace.Wrap(err, "getting current exe path") + } + if err := assertTshInProgramFiles(tshPath); err != nil { + return trace.Wrap(err, "checking if tsh.exe is installed under %%PROGRAMFILES%%") + } + + svcMgr, err := mgr.Connect() + if err != nil { + return trace.Wrap(err, "connecting to Windows service manager") + } + svc, err := svcMgr.OpenService(cfg.Name) + if err != nil { + if !errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) { + return trace.Wrap(err, "unexpected error checking if Windows service %s exists", cfg.Name) + } + // The service has not been created yet and must be installed. + svc, err = svcMgr.CreateService( + cfg.Name, + tshPath, + mgr.Config{ + StartType: mgr.StartManual, + Description: cfg.Description, + }, + cfg.Command..., + ) + if err != nil { + return trace.Wrap(err, "creating VNet Windows service") + } + } + if err := svc.Close(); err != nil { + return trace.Wrap(err, "closing VNet Windows service") + } + if err := grantServiceRights(cfg.Name, cfg.AccessPermissions); err != nil { + return trace.Wrap(err, "granting authenticated users permission to control the VNet Windows service") + } + if err := installEventSource(cfg.EventSourceName); err != nil { + return trace.Wrap(err, "creating event source for logging") + } + if err := logInstallationEvent(cfg.EventSourceName, fmt.Sprintf("%s service installed", cfg.Name)); err != nil { + return trace.Wrap(err, "logging installation event") + } + return nil +} + +// UninstallConfig defines parameters for removing a Windows service. +type UninstallConfig struct { + // Name is the service name. + Name string + // EventSourceName is the event source to remove from the Windows Event Log. + EventSourceName string +} + +// Uninstall uninstalls the Windows service. +func Uninstall(ctx context.Context, cfg *UninstallConfig) (err error) { + if cfg.Name == "" { + return trace.BadParameter("service name is required") + } + if cfg.EventSourceName == "" { + return trace.BadParameter("event source name is required") + } + svcMgr, err := mgr.Connect() + if err != nil { + return trace.Wrap(err, "connecting to Windows service manager") + } + svc, err := svcMgr.OpenService(cfg.Name) + if err != nil { + return trace.Wrap(err, "opening Windows service %s", cfg.Name) + } + if err := svc.Delete(); err != nil { + return trace.Wrap(err, "deleting Windows service %s", cfg.Name) + } + if err := svc.Close(); err != nil { + return trace.Wrap(err, "closing VNet Windows service") + } + + if err := logInstallationEvent(cfg.EventSourceName, fmt.Sprintf("%s service uninstalled", cfg.Name)); err != nil { + return trace.Wrap(err, "logging installation event") + } + if err := eventlogutils.Remove(eventlogutils.LogName, cfg.EventSourceName); err != nil { + return trace.Wrap(err, "removing event source for logging") + } + + return nil +} + +func grantServiceRights(name string, accessPermissions windows.ACCESS_MASK) error { + // Get the current security info for the service, requesting only the DACL + // (discretionary access control list). + si, err := windows.GetNamedSecurityInfo(name, windows.SE_SERVICE, windows.DACL_SECURITY_INFORMATION) + if err != nil { + return trace.Wrap(err, "getting current service security information") + } + // Get the DACL from the security info. + dacl, _ /*defaulted*/, err := si.DACL() + if err != nil { + return trace.Wrap(err, "getting current service DACL") + } + // This is the universal well-known SID for "Authenticated Users". + authenticatedUsersSID, err := windows.StringToSid("S-1-5-11") + if err != nil { + return trace.Wrap(err, "parsing authenticated users SID") + } + // Build an explicit access entry for authenticated users. + ea := []windows.EXPLICIT_ACCESS{{ + AccessPermissions: accessPermissions, + AccessMode: windows.GRANT_ACCESS, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(authenticatedUsersSID), + }, + }} + // Merge the new explicit access entry with the existing DACL. + dacl, err = windows.ACLFromEntries(ea, dacl) + if err != nil { + return trace.Wrap(err, "merging service DACL entries") + } + // Set the DACL on the service security info. + if err := windows.SetNamedSecurityInfo( + name, + windows.SE_SERVICE, + windows.DACL_SECURITY_INFORMATION, + nil, // owner + nil, // group + dacl, // dacl + nil, // sacl + ); err != nil { + return trace.Wrap(err, "setting service DACL") + } + return nil +} + +// assertTshInProgramFiles asserts that tsh is a regular file installed under +// the program files directory (usually C:\Program Files\). +func assertTshInProgramFiles(tshPath string) error { + if err := assertRegularFile(tshPath); err != nil { + return trace.Wrap(err) + } + programFiles, err := windows.KnownFolderPath(windows.FOLDERID_ProgramFiles, 0) + if err != nil { + return trace.Wrap(err, "failed to read Program Files path") + } + // Windows file paths are case-insensitive. + cleanedProgramFiles := strings.ToLower(filepath.Clean(programFiles)) + string(filepath.Separator) + cleanedTshPath := strings.ToLower(filepath.Clean(tshPath)) + if !strings.HasPrefix(cleanedTshPath, cleanedProgramFiles) { + return trace.BadParameter( + "tsh.exe is currently installed at %s, it must be installed under %s in order to install the VNet Windows service", + tshPath, programFiles) + } + return nil +} + +func assertRegularFile(path string) error { + switch info, err := os.Lstat(path); { + case os.IsNotExist(err): + return trace.Wrap(err, "%s not found", path) + case err != nil: + return trace.Wrap(err, "unexpected error checking %s", path) + case !info.Mode().IsRegular(): + return trace.BadParameter("%s is not a regular file", path) + } + return nil +} + +func installEventSource(name string) error { + exe, err := os.Executable() + if err != nil { + return trace.Wrap(err) + } + // Assume that the message file is shipped next to tsh.exe. + msgFilePath := filepath.Join(filepath.Dir(exe), "msgfile.dll") + + // This should create a registry entry under + // SYSTEM\CurrentControlSet\Services\EventLog\Teleport\ with an absolute path to msgfile.dll. + // If the user moves Teleport Connect to some other directory, logs will still be captured, but + // they might display a message about missing event ID until the user reinstalls the app. + err = eventlogutils.Install(eventlogutils.LogName, name, msgFilePath, false /* useExpandKey */) + return trace.Wrap(err) +} + +func logInstallationEvent(name string, eventMessage string) error { + log, err := eventlog.Open(name) + if err != nil { + return trace.Wrap(err, "opening logger") + } + + if err := log.Info(eventlogutils.EventID, fmt.Sprintf("%s version:%s", eventMessage, teleport.Version)); err != nil { + return trace.Wrap(err, "writing log message") + } + + return trace.Wrap(log.Close(), "closing logger") +} diff --git a/lib/windowsservice/run_windows.go b/lib/windowsservice/run_windows.go new file mode 100644 index 0000000000000..896febba00dbe --- /dev/null +++ b/lib/windowsservice/run_windows.go @@ -0,0 +1,153 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package windowsservice + +import ( + "cmp" + "context" + "errors" + "log/slog" + "os" + "strconv" + "time" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows/svc" + + "github.com/gravitational/teleport" + logutils "github.com/gravitational/teleport/lib/utils/log" +) + +const defaultTerminateTimeout = 30 * time.Second + +// ServiceHandler abstracts the core service workload behind a Windows service. +type ServiceHandler interface { + // Execute will be called by the package code at the start of + // the service, and the service will exit once Execute completes. + Execute(ctx context.Context, args []string) error +} + +// RunConfig defines the inputs for running a Windows service. +type RunConfig struct { + // Name is the Windows service name registered with the SCM. + Name string + // Handler runs the service workload. + Handler ServiceHandler + // Logger is logger for the service. + Logger *slog.Logger + // TerminateTimeout bounds how long the service waits for shutdown. + // If zero, a default timeout is used. + TerminateTimeout time.Duration +} + +// runner wires a handler into the Windows service lifecycle. +type runner struct { + handler ServiceHandler + logger *slog.Logger + terminateTimeout time.Duration +} + +// InitSlogEventLogger sets up a new slog handler that writes to the Windows Event Log as source. +func InitSlogEventLogger(source string) (func() error, error) { + level := slog.LevelInfo + if envVar := os.Getenv(teleport.VerboseLogsEnvVar); envVar != "" { + isDebug, err := strconv.ParseBool(envVar) + if err != nil { + return nil, trace.Wrap(err, "parsing %s", teleport.VerboseLogsEnvVar) + } + if isDebug { + level = slog.LevelDebug + } + } + + handler, close, err := logutils.NewSlogEventLogHandler(source, level) + if err != nil { + return nil, trace.Wrap(err, "initializing log handler") + } + slog.SetDefault(slog.New(handler)) + return close, nil +} + +// Run wires logging, runs the service, and closes logging resources. +func Run(cfg *RunConfig) error { + if cfg.Name == "" { + return trace.BadParameter("service name is required") + } + if cfg.Handler == nil { + return trace.BadParameter("handler is required") + } + + terminateTimeout := cfg.TerminateTimeout + if terminateTimeout == 0 { + terminateTimeout = defaultTerminateTimeout + } + + err := svc.Run(cfg.Name, &runner{ + handler: cfg.Handler, + logger: cfg.Logger, + terminateTimeout: terminateTimeout, + }) + return trace.Wrap(err, "running Windows service") +} + +// Execute implements [svc.Handler.Execute]. +func (s *runner) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { + const cmdsAccepted = svc.AcceptStop // Interrogate is always accepted and there is no const for it. + status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errCh := make(chan error) + go func() { errCh <- s.handler.Execute(ctx, args) }() + + var terminateTimedOut <-chan time.Time +loop: + for { + select { + case request := <-requests: + switch request.Cmd { + case svc.Interrogate: + state := svc.Running + if ctx.Err() != nil { + state = svc.StopPending + } + status <- svc.Status{State: state, Accepts: cmdsAccepted} + case svc.Stop: + s.logger.InfoContext(ctx, "Received stop command, shutting down service") + // Cancel the context passed to s.handler.Execute to terminate the service. + cancel() + terminateTimedOut = cmp.Or(terminateTimedOut, time.After(s.terminateTimeout)) + status <- svc.Status{State: svc.StopPending} + } + case <-terminateTimedOut: + s.logger.ErrorContext(ctx, "Service failed to terminate within timeout, exiting process", + slog.Duration("timeout", s.terminateTimeout)) + exitCode = 1 + break loop + case err := <-errCh: + if err == nil || errors.Is(err, context.Canceled) { + s.logger.InfoContext(ctx, "Service terminated") + } else { + s.logger.ErrorContext(ctx, "Service terminated", "error", err) + exitCode = 1 + } + break loop + } + } + status <- svc.Status{State: svc.Stopped, Win32ExitCode: exitCode} + return false, exitCode +} diff --git a/proto/teleport/lib/teleterm/auto_update/v1/auto_update_service.proto b/proto/teleport/lib/teleterm/auto_update/v1/auto_update_service.proto index 74f21508339a7..1e9a50563ce25 100644 --- a/proto/teleport/lib/teleterm/auto_update/v1/auto_update_service.proto +++ b/proto/teleport/lib/teleterm/auto_update/v1/auto_update_service.proto @@ -24,10 +24,15 @@ option go_package = "github.com/gravitational/teleport/gen/proto/go/teleport/lib service AutoUpdateService { // GetClusterVersions returns client tools versions for all clusters. rpc GetClusterVersions(GetClusterVersionsRequest) returns (GetClusterVersionsResponse); - // GetDownloadBaseUrl returns a base URL (e.g. cdn.teleport.dev) for downloading packages. - // Can be overridden with TELEPORT_CDN_BASE_URL env var. - // OSS builds require this env var to be set, otherwise an error is returned. - rpc GetDownloadBaseUrl(GetDownloadBaseUrlRequest) returns (GetDownloadBaseUrlResponse); + // GetConfigRequest retrieves the local auto updates configuration. + // It resolves settings using platform-specific mechanisms: + // * macOS/Linux: Environment variables. + // * Windows: System Registry policies (respecting per-machine vs. per-user installation scopes), + // with a fallback to the deprecated environment variables when policy values are not set. + rpc GetConfig(GetConfigRequest) returns (GetConfigResponse); + // GetInstallationMetadata returns installation metadata of the currently running app instance. + // Implemented only on Windows. + rpc GetInstallationMetadata(GetInstallationMetadataRequest) returns (GetInstallationMetadataResponse); } // Request for GetClusterVersions. @@ -59,10 +64,55 @@ message UnreachableCluster { string error_message = 2; } -// Request for GetDownloadBaseUrl. -message GetDownloadBaseUrlRequest {} +// Request for GetConfig. +message GetConfigRequest {} -// Response for GetDownloadBaseUrl. -message GetDownloadBaseUrlResponse { - string base_url = 1; +// Response for GetConfig. +message GetConfigResponse { + // A base URL (e.g. cdn.teleport.dev) for downloading packages. + // Sources resolved by platform: + // * macOS/Linux: The `TELEPORT_CDN_BASE_URL` environment variable. + // * Windows: The `CdnBaseUrl` value in the system registry (SOFTWARE\Policies\Teleport\TeleportConnect). + // - HKEY_LOCAL_MACHINE (Machine Policy): Takes precedence over user policies. + // - HKEY_CURRENT_USER (User Policy): Used only for per-user installations if no machine policy is defined. + // - If policy values are missing, falls back to `TELEPORT_CDN_BASE_URL` (deprecated). + // + // Note: OSS builds require this to be set (via env var or registry), otherwise an empty value is returned. + ConfigValue cdn_base_url = 1; + + // The specific client tools version to use. The 'off' value disables automatic updates. + // Sources resolved by platform: + // * macOS/Linux: The `TELEPORT_TOOLS_VERSION` environment variable. + // * Windows: The `ToolsVersion` value in the system registry (SOFTWARE\Policies\Teleport\TeleportConnect). + // - HKEY_LOCAL_MACHINE (Machine Policy): Takes precedence over user policies. + // - HKEY_CURRENT_USER (User Policy): Used only for per-user installations if no machine policy is defined. + // - If policy values are missing, falls back to `TELEPORT_TOOLS_VERSION` (deprecated). + ConfigValue tools_version = 2; +} + +// Contains the config value and its source. +message ConfigValue { + string value = 1; + // Source of the config. + ConfigSource source = 2; +} + +// Source of the config. +enum ConfigSource { + CONFIG_SOURCE_UNSPECIFIED = 0; + // Configuration comes from an environment variable. + CONFIG_SOURCE_ENV_VAR = 1; + // Configuration comes from SOFTWARE\Policies\Teleport\TeleportConnect. + CONFIG_SOURCE_POLICY = 2; + // Configuration comes from a hardcoded default. + CONFIG_SOURCE_DEFAULT = 3; +} + +// Request for GetInstallationMetadata. +message GetInstallationMetadataRequest {} + +// Response for GetInstallationMetadata. +message GetInstallationMetadataResponse { + // Determines whether updates should target a per-machine installation. + bool is_per_machine_install = 1; } diff --git a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto index 0336e122b523a..d3b8662ddd3aa 100644 --- a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto +++ b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto @@ -37,6 +37,10 @@ service VnetService { // VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. rpc GetBackgroundItemStatus(GetBackgroundItemStatusRequest) returns (GetBackgroundItemStatusResponse); + // CheckInstallTimeRequirements validates install-time prerequisites (for example, VNet service presence) that can + // only be changed by reinstalling the app. + rpc CheckInstallTimeRequirements(CheckInstallTimeRequirementsRequest) returns (CheckInstallTimeRequirementsResponse); + // 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); @@ -97,6 +101,25 @@ enum BackgroundItemStatus { BACKGROUND_ITEM_STATUS_NOT_SUPPORTED = 5; } +// WindowsServiceStatus maps to service-related errors in golang.org/x/sys/windows/zerrors_windows.go. +enum WindowsServiceStatus { + WINDOWS_SERVICE_STATUS_UNSPECIFIED = 0; + WINDOWS_SERVICE_STATUS_OK = 1; + WINDOWS_SERVICE_STATUS_DOES_NOT_EXIST = 2; + // The VNet service cannot start because its version differs from the client. + WINDOWS_SERVICE_STATUS_VERSION_MISMATCH = 3; +} + +// Request for CheckInstallTimeRequirementsRequest. +message CheckInstallTimeRequirementsRequest {} + +// Response for CheckInstallTimeRequirementsResponse. +message CheckInstallTimeRequirementsResponse { + oneof status { + WindowsServiceStatus windows_service_status = 1; + } +} + // Request for RunDiagnostics. message RunDiagnosticsRequest {} diff --git a/tool/tsh/common/connect_updater.go b/tool/tsh/common/connect_updater.go new file mode 100644 index 0000000000000..e17276abc250f --- /dev/null +++ b/tool/tsh/common/connect_updater.go @@ -0,0 +1,40 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import "github.com/alecthomas/kingpin/v2" + +type privilegedUpdaterCLICommand interface { + FullCommand() string + run(*CLIConf) error +} + +func newConnectUpdaterServiceInstallCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return newPlatformConnectUpdaterServiceInstallCommand(parent) +} + +func newConnectUpdaterServiceUninstallCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return newPlatformConnectUpdaterServiceUninstallCommand(parent) +} + +func newConnectUpdaterServiceRunCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return newPlatformConnectUpdaterServiceRunCommand(parent) +} + +func newConnectUpdaterServiceInstallUpdateCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return newPlatformConnectUpdaterServiceInstallUpdateCommand(parent) +} diff --git a/tool/tsh/common/connect_updater_other.go b/tool/tsh/common/connect_updater_other.go new file mode 100644 index 0000000000000..9a8a2d7144d20 --- /dev/null +++ b/tool/tsh/common/connect_updater_other.go @@ -0,0 +1,50 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build !windows + +package common + +import ( + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" +) + +func newPlatformConnectUpdaterServiceRunCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return updateServiceCommandNotSupported{} +} + +func newPlatformConnectUpdaterServiceInstallCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return updateServiceCommandNotSupported{} +} + +func newPlatformConnectUpdaterServiceUninstallCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return updateServiceCommandNotSupported{} +} + +func newPlatformConnectUpdaterServiceInstallUpdateCommand(parent *kingpin.CmdClause) privilegedUpdaterCLICommand { + return updateServiceCommandNotSupported{} +} + +type updateServiceCommandNotSupported struct{} + +func (updateServiceCommandNotSupported) FullCommand() string { + return "" +} + +func (updateServiceCommandNotSupported) run(*CLIConf) error { + return trace.NotImplemented("this command is not supported on this platform") +} diff --git a/tool/tsh/common/connect_updater_windows.go b/tool/tsh/common/connect_updater_windows.go new file mode 100644 index 0000000000000..3fd03f2487700 --- /dev/null +++ b/tool/tsh/common/connect_updater_windows.go @@ -0,0 +1,101 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package common + +import ( + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + "golang.org/x/sys/windows/svc" + + "github.com/gravitational/teleport/lib/teleterm/autoupdate/privilegedupdater" +) + +type updateServiceCommand struct { + *kingpin.CmdClause +} + +func newPlatformConnectUpdaterServiceRunCommand(parent *kingpin.CmdClause) *updateServiceCommand { + return &updateServiceCommand{ + CmdClause: parent.Command(privilegedupdater.ServiceSubCommand, "Start the Teleport Connect updater service.").Hidden(), + } +} + +func (c *updateServiceCommand) run(_ *CLIConf) error { + isSvc, err := svc.IsWindowsService() + if err != nil { + return trace.Wrap(err, "failed to determine if running as a Windows service, cannot run %s command", c.FullCommand()) + } + if !isSvc { + return trace.Errorf("not running as a Windows service, cannot run %s command", c.FullCommand()) + } + if err = privilegedupdater.RunService(); err != nil { + return trace.Wrap(err, "running Teleport Connect updater service") + } + return nil +} + +type connectUpdaterServiceInstallCommand struct { + *kingpin.CmdClause +} + +func newPlatformConnectUpdaterServiceInstallCommand(parent *kingpin.CmdClause) *connectUpdaterServiceInstallCommand { + return &connectUpdaterServiceInstallCommand{ + CmdClause: parent.Command("install-service", "Install the Teleport Connect updater service.").Hidden(), + } +} + +func (c *connectUpdaterServiceInstallCommand) run(cf *CLIConf) error { + return trace.Wrap(privilegedupdater.InstallService(cf.Context), "installing updater service") +} + +type connectUpdaterServiceUninstallCommand struct { + *kingpin.CmdClause +} + +func newPlatformConnectUpdaterServiceUninstallCommand(parent *kingpin.CmdClause) *connectUpdaterServiceUninstallCommand { + return &connectUpdaterServiceUninstallCommand{ + CmdClause: parent.Command("uninstall-service", "Uninstall the Teleport Connect updater service.").Hidden(), + } +} + +func (c *connectUpdaterServiceUninstallCommand) run(cf *CLIConf) error { + return trace.Wrap(privilegedupdater.UninstallService(cf.Context), "uninstalling updater service") +} + +type connectUpdaterServiceInstallUpdateCommand struct { + *kingpin.CmdClause + path string + forceRun bool + version string +} + +func newPlatformConnectUpdaterServiceInstallUpdateCommand(parent *kingpin.CmdClause) *connectUpdaterServiceInstallUpdateCommand { + cmd := &connectUpdaterServiceInstallUpdateCommand{ + CmdClause: parent.Command("install-update", "Install the update with the Teleport Connect updater service.").Hidden(), + } + cmd.Flag("path", "Path to the update.").Required().StringVar(&cmd.path) + cmd.Flag("update-version", "Update version").Required().StringVar(&cmd.version) + cmd.Flag("force-run", "Run the app after installing the update.").BoolVar(&cmd.forceRun) + return cmd +} + +func (c *connectUpdaterServiceInstallUpdateCommand) run(cf *CLIConf) error { + return trace.Wrap( + privilegedupdater.RunServiceAndInstallUpdateFromClient(cf.Context, c.path, c.forceRun, c.version), + "installing update via Teleport Connect updater service", + ) +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 0561f8aa9fea4..4a03c97a964c8 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -1514,6 +1514,12 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { vnetInstallServiceCommand := newVnetInstallServiceCommand(app) vnetUninstallServiceCommand := newVnetUninstallServiceCommand(app) + connectUpdater := app.Command("connect-updater", "Teleport Connect updater commands.").Hidden() + connectUpdaterServiceCommand := newConnectUpdaterServiceRunCommand(connectUpdater) + connectUpdaterServiceInstallCommand := newConnectUpdaterServiceInstallCommand(connectUpdater) + connectUpdaterServiceUninstallCommand := newConnectUpdaterServiceUninstallCommand(connectUpdater) + connectUpdaterServiceInstallUpdateCommand := newConnectUpdaterServiceInstallUpdateCommand(connectUpdater) + gitCmd := newGitCommands(app) pivCmd := newPIVCommands(app) mcpCmd := newMCPCommands(app, &cf) @@ -1958,6 +1964,14 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { err = vnetServiceCommand.run(&cf) case vnetInstallServiceCommand.FullCommand(): err = vnetInstallServiceCommand.run(&cf) + case connectUpdaterServiceInstallCommand.FullCommand(): + err = connectUpdaterServiceInstallCommand.run(&cf) + case connectUpdaterServiceUninstallCommand.FullCommand(): + err = connectUpdaterServiceUninstallCommand.run(&cf) + case connectUpdaterServiceCommand.FullCommand(): + err = connectUpdaterServiceCommand.run(&cf) + case connectUpdaterServiceInstallUpdateCommand.FullCommand(): + err = connectUpdaterServiceInstallUpdateCommand.run(&cf) case vnetUninstallServiceCommand.FullCommand(): err = vnetUninstallServiceCommand.run(&cf) case gitCmd.list.FullCommand(): diff --git a/web/packages/teleterm/README.md b/web/packages/teleterm/README.md index 1296942d6b800..f4589360492a4 100644 --- a/web/packages/teleterm/README.md +++ b/web/packages/teleterm/README.md @@ -151,6 +151,18 @@ GOOS=windows CGO_ENABLED=1 go build -o build/tsh.exe -ldflags '-w -s' -buildvcs It's important for the executable to end with `.exe`. If that command doesn't work, you can always inspect what we currently do in [our Windows build pipeline scripts](https://github.com/gravitational/teleport/blob/983017b23f65e49350615bfbbe52b7f1080ea7b9/build.assets/windows/build.ps1#L377). +#### Certificate requirements for updater + +The privileged updater on Windows (for per-machine updates) verifies update signatures before running the installer: + +- `WinVerifyTrust` must succeed for the update executable. +- The signer subject must match the Teleport publisher subject: + `CN=Gravitational, Inc., O=Gravitational, Inc., L=Oakland, ST=California, C=US`. + +The hardcoded values are kept in `authenticode_windows.go`. +As a result, the service binary itself does not need to be signed (e.g., in OSS builds), but all updates must be +properly signed. + #### Native dependencies on Windows On Windows, you need to pay special attention to [the dev tools needed by node-pty](https://github.com/microsoft/node-pty?tab=readme-ov-file#windows), diff --git a/web/packages/teleterm/build_resources/installer.nsh b/web/packages/teleterm/build_resources/installer.nsh index a904ffb2ca322..26bca8365c8f7 100644 --- a/web/packages/teleterm/build_resources/installer.nsh +++ b/web/packages/teleterm/build_resources/installer.nsh @@ -1,49 +1,136 @@ -# https://github.com/electron-userland/electron-builder/blob/v24.0.0-alpha.5/docs/configuration/nsis.md#custom-nsis-script - -# electron-builder adds `BUILD_RESOURCES_DIR\x86-unicode` as a plugin dir. -# But that dir name isn't very descriptive, so we add a custom plugin dir. -!addplugindir "${BUILD_RESOURCES_DIR}\nsis-plugins" - -# The EnVar plugin is recommended for env var modification as EnvVarUpdate doesn't handle long -# strings very well. -# https://nsis.sourceforge.io/Environmental_Variables:_append,_prepend,_and_remove_entries -# https://nsis.sourceforge.io/EnVar_plug-in - -!macro customInstall - # Make EnVar define system env vars since Connect is installed per-machine. - EnVar::SetHKLM - EnVar::AddValue "Path" $INSTDIR\resources\bin - - # Disable client tools managed updates for the bundled tsh.exe commands below. - # This function has no effect on the system environment variables or the environment variables of other processes. - System::Call 'kernel32::SetEnvironmentVariable(t, t)i("TELEPORT_TOOLS_VERSION", "off").r0' - - nsExec::ExecToStack '"$INSTDIR\resources\bin\tsh.exe" vnet-install-service' - Pop $0 # ExitCode - Pop $1 # Output - ${If} $0 != 0 - MessageBox MB_ICONSTOP \ - "tsh.exe vnet-install-service failed with exit code $0. Output: $1" - Quit - ${Endif} -!macroend - -!macro customUnInstall - EnVar::SetHKLM - # Inside the uninstaller, $INSTDIR is the directory where the uninstaller lies. - # Fortunately, electron-builder puts the uninstaller directly into the actual installation dir. - # https://nsis.sourceforge.io/Docs/Chapter4.html#varother - EnVar::DeleteValue "Path" $INSTDIR\resources\bin - - # Disable client tools managed updates for the bundled tsh.exe commands below. - # This function has no effect on the system environment variables or the environment variables of other processes. - System::Call 'kernel32::SetEnvironmentVariable(t, t)i("TELEPORT_TOOLS_VERSION", "off").r0' - - nsExec::ExecToStack '"$INSTDIR\resources\bin\tsh.exe" vnet-uninstall-service' - Pop $0 # ExitCode - Pop $1 # Output - ${If} $0 != 0 - MessageBox MB_ICONSTOP \ - "tsh.exe vnet-uninstall-service failed with exit code $0. The uninstaller is going to continue. Output: $1" - ${Endif} -!macroend +# https://github.com/electron-userland/electron-builder/blob/v24.0.0-alpha.5/docs/configuration/nsis.md#custom-nsis-script + +# electron-builder adds `BUILD_RESOURCES_DIR\x86-unicode` as a plugin dir. +# But that dir name isn't very descriptive, so we add a custom plugin dir. +!addplugindir "${BUILD_RESOURCES_DIR}\nsis-plugins" + +# The EnVar plugin is recommended for env var modification as EnvVarUpdate doesn't handle long +# strings very well. +# https://nsis.sourceforge.io/Environmental_Variables:_append,_prepend,_and_remove_entries +# https://nsis.sourceforge.io/EnVar_plug-in + +# To inform the user that VNet is available only in the per-machine mode, we need to display a message in the wizard. +# It could be added on a separate welcome page (which can be fully customized), but that would introduce an +# additional step in the wizard and create unnecessary friction. +# Instead, we modify the existing "selectUserMode" and "forAll" strings by hand (electron-builder doesn't allow customizing the translations from +# https://github.com/electron-userland/electron-builder/blob/6c20eeb1cf9fd10980cde3c9ce0602fa6b7c6972/packages/app-builder-lib/templates/nsis/assistedMessages.yml). +# Important: the message can't be too long, the template was designed for around two lines of text, the rest is clipped. +# +# Because of electron-builder's default setting which treats warnings as errors, we need to disable warnings 6030 +# (warning 6030: LangString "selectUserMode" set multiple times for 1033, wasting space). + +!pragma warning disable 6030 +!macro customHeader + LangString selectUserMode ${LANG_ENGLISH} "Select installation mode. Only the 'Anyone who uses this computer' option comes with VNet, Teleport's VPN-like experience for accessing TCP applications and SSH servers." + LangString forAll ${LANG_ENGLISH} "Anyone who uses this computer (&all users). Includes VNet support." +!macroend + +# For fresh silent installs with no explicit mode, align with selectPerMachineByDefault: true. +# This is a workaround for https://github.com/electron-userland/electron-builder/issues/9644. +# Remove once fixed. +# Using customInit instead of customInstallMode, since this hook is always invoked (customInstallMode runs only in +# non-silent mode). +!macro customInit + ${If} ${Silent} + ${AndIfNot} ${isUpdated} + ${AndIfNot} ${isForAllUsers} + ${AndIfNot} ${isForCurrentUser} + ${AndIf} $hasPerMachineInstallation == "0" + ${AndIf} $hasPerUserInstallation == "0" + StrCpy $hasPerMachineInstallation "1" + ${EndIf} +!macroend + +# Migration from one-click -> assisted multi-user. +# In non-silent updater runs without an explicit mode flag (now set by NsisDualModeUpdater), elevated launches can wait +# on the install-mode page without a visible UI. To prevent this, enforce a mode when the installer is run with --update but no install mode flag is provided. +# +# Mode decision (only when neither /allusers nor /currentuser is present): +# - If the application is installed per-machine (HKLM), enforce an all-users installation. +# - Otherwise, fall back to a current-user installation (this scenario should not occur, as automatic updates were not available for per-user installs). +# +# /allusers and /currentuser map to ${isForAllUsers} / ${isForCurrentUser}. +# These are command-line flags and are different from $installMode (resolved installer state). +!macro customInstallMode + ${if} ${isUpdated} + ${AndIfNot} ${isForAllUsers} + ${AndIfNot} ${isForCurrentUser} + + # Keep legacy machine-only installs machine-wide. + ${if} $hasPerMachineInstallation == "1" + StrCpy $isForceMachineInstall "1" + ${else} + # Fallback to per-user for all other cases. + StrCpy $isForceCurrentInstall "1" + ${endif} + + ${endif} +!macroend + +!macro customInstall + ${If} $installMode == "all" + # Make EnVar define system env vars when the app is installed per-machine. + EnVar::SetHKLM + EnVar::AddValue "Path" $INSTDIR\resources\bin + + # Disable client tools managed updates for the bundled tsh.exe commands below. + # This function has no effect on the system environment variables or the environment variables of other processes. + System::Call 'kernel32::SetEnvironmentVariable(t, t)i("TELEPORT_TOOLS_VERSION", "off").r0' + + nsExec::ExecToStack '"$INSTDIR\resources\bin\tsh.exe" vnet-install-service' + Pop $0 # ExitCode + Pop $1 # Output + ${If} $0 != 0 + MessageBox MB_ICONSTOP \ + "tsh.exe vnet-install-service failed with exit code $0. The installer is going to continue. Output: $1" + ${Endif} + + nsExec::ExecToStack '"$INSTDIR\resources\bin\tsh.exe" connect-updater install-service' + Pop $0 # ExitCode + Pop $1 # Output + ${If} $0 != 0 + MessageBox MB_ICONSTOP \ + "tsh.exe connect-updater install-service failed with exit code $0. The installer is going to continue. Output: $1" + ${Endif} + + ${Else} + # Make EnVar define system user vars when the app is installed per-user. + EnVar::SetHKCU + EnVar::AddValue "Path" "$INSTDIR\resources\bin" + + ${EndIf} +!macroend + +!macro customUnInstall + ${If} $installMode == "all" + EnVar::SetHKLM + # Inside the uninstaller, $INSTDIR is the directory where the uninstaller lies. + # Fortunately, electron-builder puts the uninstaller directly into the actual installation dir. + # https://nsis.sourceforge.io/Docs/Chapter4.html#varother + EnVar::DeleteValue "Path" $INSTDIR\resources\bin + + # Disable client tools managed updates for the bundled tsh.exe commands below. + # This function has no effect on the system environment variables or the environment variables of other processes. + System::Call 'kernel32::SetEnvironmentVariable(t, t)i("TELEPORT_TOOLS_VERSION", "off").r0' + + nsExec::ExecToStack '"$INSTDIR\resources\bin\tsh.exe" vnet-uninstall-service' + Pop $0 # ExitCode + Pop $1 # Output + ${If} $0 != 0 + MessageBox MB_ICONSTOP \ + "tsh.exe vnet-uninstall-service failed with exit code $0. The uninstaller is going to continue. Output: $1" + ${Endif} + + nsExec::ExecToStack '"$INSTDIR\resources\bin\tsh.exe" connect-updater uninstall-service' + Pop $0 # ExitCode + Pop $1 # Output + ${If} $0 != 0 + MessageBox MB_ICONSTOP \ + "tsh.exe connect-updater uninstall-service failed with exit code $0. The uninstaller is going to continue. Output: $1" + ${Endif} + + ${Else} + EnVar::SetHKCU + EnVar::DeleteValue "Path" "$INSTDIR\resources\bin" + ${EndIf} +!macroend diff --git a/web/packages/teleterm/electron-builder-config.js b/web/packages/teleterm/electron-builder-config.js index ade99097601a9..cfad3e0deacc3 100644 --- a/web/packages/teleterm/electron-builder-config.js +++ b/web/packages/teleterm/electron-builder-config.js @@ -212,6 +212,7 @@ module.exports = { extraResources: [ env.CONNECT_TSH_BIN_PATH && { from: env.CONNECT_TSH_BIN_PATH, + // Keep in sync with lib/teleterm/autoupdate/per_machine_windows.go. to: './bin/tsh.exe', }, env.CONNECT_WINTUN_DLL_PATH && { @@ -227,13 +228,20 @@ module.exports = { ].filter(Boolean), }, nsis: { + // Static app guid, calculated from appId and electron-builder's UUID. + guid: '22539266-67e8-54a3-83b9-dfdca7b33ee1', // Turn off blockmaps since we don't support automatic updates. // https://github.com/electron-userland/electron-builder/issues/2900#issuecomment-730571696 differentialPackage: false, - // Use a per-machine installation to support VNet. - // VNet installs a Windows service per-machine, and tsh.exe must be - // installed in a path that is not user-writable. - perMachine: true, + // Per-machine and per-user modes differ in features. + // VNet is available only in per-machine mode. + perMachine: false, + oneClick: false, + selectPerMachineByDefault: true, + // In installer.nsh, the `selectUserMode` message is overridden to display information + // about VNet availability. The message is only in English, so the multi-language + // installer should be disabled to avoid mixing languages in the installation wizard. + multiLanguageInstaller: false, }, rpm: { artifactName: '${name}-${version}.${arch}.${ext}', diff --git a/web/packages/teleterm/src/helpers.ts b/web/packages/teleterm/src/helpers.ts index f21ec32989aad..067481e0c5154 100644 --- a/web/packages/teleterm/src/helpers.ts +++ b/web/packages/teleterm/src/helpers.ts @@ -23,6 +23,10 @@ import { Server } from 'gen-proto-ts/teleport/lib/teleterm/v1/server_pb'; import { PaginatedResource } from 'gen-proto-ts/teleport/lib/teleterm/v1/service_pb'; import * as api from 'gen-proto-ts/teleport/lib/teleterm/v1/tshd_events_service_pb'; import { WindowsDesktop } from 'gen-proto-ts/teleport/lib/teleterm/v1/windows_desktop_pb'; +import { + CheckInstallTimeRequirementsResponse, + WindowsServiceStatus, +} from 'gen-proto-ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb'; import { CheckReport, RouteConflictReport, @@ -204,3 +208,12 @@ export function reportOneOfIsSSHConfigurationReport( } { return report.oneofKind === 'sshConfigurationReport'; } + +export function statusOneOfIsWindowsServiceStatus( + status: CheckInstallTimeRequirementsResponse['status'] +): status is { + oneofKind: 'windowsServiceStatus'; + windowsServiceStatus: WindowsServiceStatus; +} { + return status.oneofKind === 'windowsServiceStatus'; +} diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index 21cd4177e1e41..58dbd90ce4def 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -52,11 +52,7 @@ import { TSH_AUTOUPDATE_ENV_VAR, TSH_AUTOUPDATE_OFF, } from 'teleterm/node/tshAutoupdate'; -import { - AppUpdater, - AppUpdaterStorage, - TELEPORT_TOOLS_VERSION_ENV_VAR, -} from 'teleterm/services/appUpdater'; +import { AppUpdater, AppUpdaterStorage } from 'teleterm/services/appUpdater'; import { subscribeToFileStorageEvents } from 'teleterm/services/fileStorage'; import * as grpcCreds from 'teleterm/services/grpcCredentials'; import { @@ -193,22 +189,28 @@ export default class MainProcess { this.initResolvingChildProcessAddressesAndTshdClients(); this.initIpc(); - const getClusterVersions = async () => { - const { autoUpdateService } = await this.tshdClients; - const { response } = await autoUpdateService.getClusterVersions({}); - return response; - }; - const getDownloadBaseUrl = async () => { - const { autoUpdateService } = await this.tshdClients; - const { - response: { baseUrl }, - } = await autoUpdateService.getDownloadBaseUrl({}); - return baseUrl; - }; this.appUpdater = new AppUpdater( + this.settings.tshd.binaryPath, makeAppUpdaterStorage(this.appStateFileStorage), - getClusterVersions, - getDownloadBaseUrl, + { + getConfig: async () => { + const { autoUpdateService } = await this.tshdClients; + const { response } = await autoUpdateService.getConfig({}); + return response; + }, + getClusterVersions: async () => { + const { autoUpdateService } = await this.tshdClients; + const { response } = await autoUpdateService.getClusterVersions({}); + return response; + }, + getInstallationMetadata: async () => { + const { autoUpdateService } = await this.tshdClients; + const { response } = await autoUpdateService.getInstallationMetadata( + {} + ); + return response; + }, + }, event => { if (event.kind === 'error') { event.error = serializeError(event.error); @@ -216,8 +218,7 @@ export default class MainProcess { this.windowsManager .getWindow() .webContents.send(RendererIpc.AppUpdateEvent, event); - }, - process.env[TELEPORT_TOOLS_VERSION_ENV_VAR] + } ); this.clusterStore = new ClusterStore( () => this.tshdClients.then(c => c.terminalService), @@ -285,6 +286,7 @@ export default class MainProcess { ...process.env, TELEPORT_HOME: this.configService.get('tshHome').value, [TSH_AUTOUPDATE_ENV_VAR]: TSH_AUTOUPDATE_OFF, + FORWARDED_TELEPORT_TOOLS_VERSION: process.env[TSH_AUTOUPDATE_ENV_VAR], }, } ); diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts index 0c88bd45cbb55..20b67acf45f02 100644 --- a/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.test.ts @@ -20,7 +20,13 @@ import { createHash } from 'node:crypto'; import { MacUpdater } from 'electron-updater'; -import type { GetClusterVersionsResponse } from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; +import { + ConfigSource, + ConfigValue, + GetClusterVersionsResponse, + GetInstallationMetadataResponse, +} from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; +import { compare } from 'shared/utils/semVer'; import { wait } from 'shared/utils/wait'; import Logger, { NullService } from 'teleterm/logger'; @@ -100,25 +106,35 @@ class MockedMacUpdater extends MacUpdater { function setUpAppUpdater(options: { clusters: GetClusterVersionsResponse; storage?: AppUpdaterStorage; - processEnvVar?: string; + configToolsVersion?: ConfigValue; + installationMetadata?: GetInstallationMetadataResponse; }) { - const clusterGetter = async () => { - return options.clusters; - }; - const nativeUpdater = new MockedMacUpdater(); const checkForUpdatesSpy = jest.spyOn(nativeUpdater, 'checkForUpdates'); const downloadUpdateSpy = jest.spyOn(nativeUpdater, 'downloadUpdate'); let lastEvent: { value?: AppUpdateEvent } = {}; const appUpdater = new AppUpdater( + 'path/to/tsh', options.storage || makeUpdaterStorage(), - clusterGetter, - async () => 'https://cdn.teleport.dev', + { + getClusterVersions: async () => options.clusters, + getConfig: async () => ({ + cdnBaseUrl: { + value: 'https://cdn.teleport.dev', + source: ConfigSource.ENV_VAR, + }, + toolsVersion: options.configToolsVersion ?? { + value: '', + source: ConfigSource.UNSPECIFIED, + }, + }), + getInstallationMetadata: async () => + options.installationMetadata ?? { isPerMachineInstall: false }, + }, event => { lastEvent.value = event; }, - options.processEnvVar, nativeUpdater ); @@ -193,9 +209,12 @@ test('does not auto-download update when there are unreachable clusters', async expect(setup.downloadUpdateSpy).toHaveBeenCalledTimes(0); }); -test('does not auto-download update when env var is set to off', async () => { +test('does not auto-download update when local config tools version is set to off', async () => { const setup = setUpAppUpdater({ - processEnvVar: 'off', + configToolsVersion: { + value: 'off', + source: ConfigSource.ENV_VAR, + }, clusters: { reachableClusters: [ { @@ -214,7 +233,7 @@ test('does not auto-download update when env var is set to off', async () => { expect.objectContaining({ kind: 'update-not-available', autoUpdatesStatus: expect.objectContaining({ - reason: 'disabled-by-env-var', + reason: 'disabled-by-env-config', }), }) ); @@ -349,14 +368,47 @@ test('discards previous update if the latest check returns no update', async () expect(setup.nativeUpdater.autoInstallOnAppQuit).toBeFalsy(); }); -test('when the update is older than app updateKind equals downgrade', async () => { +test('downgrades are not allowed', async () => { + const toolsVersion = '17.7.4'; + const clusters = { + reachableClusters: [ + { + clusterUri: '/clusters/foo', + toolsAutoUpdate: true, + toolsVersion, + minToolsVersion: '16.0.0-aa', + }, + ], + unreachableClusters: [], + }; + const setup = setUpAppUpdater({ + clusters, + }); + + // Ensure the app version is greater than the update version. + expect(compare(toolsVersion, mockedAppVersion)).toBe(-1); + await setup.appUpdater.checkForUpdates(); + expect(setup.lastEvent.value).toEqual( + expect.objectContaining({ + kind: 'update-not-available', + }) + ); +}); + +test('when the app is installed per-machine and configured with env vars, UAC prompt is required to install', async () => { + const setup = setUpAppUpdater({ + configToolsVersion: { + value: '20.0.0', + source: ConfigSource.ENV_VAR, + }, + installationMetadata: { isPerMachineInstall: true }, clusters: { reachableClusters: [ { clusterUri: '/clusters/foo', toolsAutoUpdate: true, - toolsVersion: '17.7.5', + toolsVersion: '19.7.5', minToolsVersion: '16.0.0-aa', }, ], @@ -369,33 +421,32 @@ test('when the update is older than app updateKind equals downgrade', async () = expect.objectContaining({ kind: 'update-available', update: expect.objectContaining({ - updateKind: 'downgrade', + requiresUacPrompt: true, }), }) ); }); -test('when the update is newer than app updateKind equals upgrade', async () => { +test("quitAndInstall emits 'installing' event", async () => { const setup = setUpAppUpdater({ + configToolsVersion: { + value: '20.0.0', + source: ConfigSource.POLICY, + }, clusters: { - reachableClusters: [ - { - clusterUri: '/clusters/foo', - toolsAutoUpdate: true, - toolsVersion: '19.7.5', - minToolsVersion: '16.0.0-aa', - }, - ], + reachableClusters: [], unreachableClusters: [], }, }); await setup.appUpdater.checkForUpdates(); + setup.appUpdater.quitAndInstall(); + expect(setup.lastEvent.value).toEqual( expect.objectContaining({ - kind: 'update-available', + kind: 'installing', update: expect.objectContaining({ - updateKind: 'upgrade', + version: '20.0.0', }), }) ); diff --git a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts index 3962861483c4a..7873ac658b9f3 100644 --- a/web/packages/teleterm/src/services/appUpdater/appUpdater.ts +++ b/web/packages/teleterm/src/services/appUpdater/appUpdater.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import { EventEmitter } from 'node:events'; import { rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -27,18 +28,22 @@ import { UpdateInfo as ElectronUpdateInfo, MacUpdater, AppUpdater as NativeUpdater, - NsisUpdater, ProgressInfo, RpmUpdater, UpdateCheckResult, } from 'electron-updater'; import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider'; -import type { GetClusterVersionsResponse } from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; +import { + ConfigSource, + GetClusterVersionsResponse, + GetConfigResponse, + GetInstallationMetadataResponse, +} from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; import { AbortError } from 'shared/utils/error'; -import { compare } from 'shared/utils/semVer'; import Logger from 'teleterm/logger'; +import { isTshdRpcError } from 'teleterm/services/tshd'; import { RootClusterUri } from 'teleterm/ui/uri'; import { @@ -51,11 +56,14 @@ import { ClientToolsUpdateProvider, ClientToolsVersionGetter, } from './clientToolsUpdateProvider'; - -export const TELEPORT_TOOLS_VERSION_ENV_VAR = 'TELEPORT_TOOLS_VERSION'; +import { + NsisDualModeUpdater, + NsisDualModeUpdaterOptions, +} from './nsisDualModeUpdater'; export class AppUpdater { private readonly logger = new Logger('AppUpdater'); + private readonly customEvents = new EventEmitter(); private readonly unregisterEventHandlers: () => void; private autoUpdatesStatus: AutoUpdatesStatus | undefined; private updateCheckResult: UpdateCheckResult | undefined; @@ -63,27 +71,64 @@ export class AppUpdater { private downloadPromise: Promise | undefined; private isUpdateDownloaded = false; private forceNoAutoDownload = false; + private readonly nsisUpdaterSettings: NsisDualModeUpdaterOptions = { + privilegedUpdaterCannotBeUsed: false, + }; constructor( + private tshPath: string, private readonly storage: AppUpdaterStorage, - private readonly getClusterVersions: () => Promise, - readonly getDownloadBaseUrl: () => Promise, + private readonly client: { + getConfig(): Promise; + getClusterVersions(): Promise; + getInstallationMetadata(): Promise; + }, private readonly emit: (event: AppUpdateEvent) => void, - private versionEnvVar: string, /** Allows overring autoUpdater in tests. */ private nativeUpdater: NativeUpdater = autoUpdater ) { const getClientToolsVersion: ClientToolsVersionGetter = async () => { - await this.refreshAutoUpdatesStatus(); + const config = await this.client.getConfig(); + + const cdnBaseUrl = config.cdnBaseUrl?.value || ''; + await this.refreshAutoUpdatesStatus({ + toolsVersion: config.toolsVersion?.value || '', + cdnBaseUrl, + }); if (this.autoUpdatesStatus.enabled) { + let isPerMachineInstall: boolean; + try { + const installationMetadata = await client.getInstallationMetadata(); + isPerMachineInstall = installationMetadata.isPerMachineInstall; + } catch (error) { + if (!isTshdRpcError(error, 'UNIMPLEMENTED')) { + throw error; + } + isPerMachineInstall = false; + } + + if (isPerMachineInstall) { + this.nsisUpdaterSettings.privilegedUpdaterCannotBeUsed = + config.toolsVersion?.source === ConfigSource.ENV_VAR || + config.cdnBaseUrl?.source === ConfigSource.ENV_VAR; + } + return { version: this.autoUpdatesStatus.version, - baseUrl: await getDownloadBaseUrl(), + baseUrl: cdnBaseUrl, + isPerMachineInstall, }; } }; + if (process.platform === 'win32') { + this.nativeUpdater = new NsisDualModeUpdater( + this.nsisUpdaterSettings, + this.tshPath + ); + } + this.nativeUpdater.setFeedURL({ provider: 'custom', // Wraps ClientToolsUpdateProvider to allow passing getClientToolsVersion. @@ -99,7 +144,6 @@ export class AppUpdater { }); this.nativeUpdater.logger = this.logger; - this.nativeUpdater.allowDowngrade = true; this.nativeUpdater.autoDownload = false; // Must be set to true before any download starts. // electron-updater registers a listener to install the update when @@ -115,9 +159,11 @@ export class AppUpdater { this.unregisterEventHandlers = registerEventHandlers( this.nativeUpdater, + this.customEvents, this.emit, () => this.autoUpdatesStatus, - () => this.shouldAutoDownload() + () => this.shouldAutoDownload(), + () => this.nsisUpdaterSettings.privilegedUpdaterCannotBeUsed ); } @@ -135,7 +181,7 @@ export class AppUpdater { supportsUpdates(): boolean { return ( this.nativeUpdater instanceof MacUpdater || - this.nativeUpdater instanceof NsisUpdater || + this.nativeUpdater instanceof NsisDualModeUpdater || this.nativeUpdater instanceof DebUpdater || this.nativeUpdater instanceof RpmUpdater ); @@ -309,6 +355,7 @@ export class AppUpdater { * It should only be called after update-downloaded has been emitted. */ quitAndInstall(): void { + this.customEvents.emit('installing'); try { this.nativeUpdater.quitAndInstall(); } catch (error) { @@ -324,13 +371,17 @@ export class AppUpdater { ); } - private async refreshAutoUpdatesStatus(): Promise { + private async refreshAutoUpdatesStatus(config: { + cdnBaseUrl: string; + toolsVersion: string; + }): Promise { const { managingClusterUri } = this.storage.get(); this.autoUpdatesStatus = await resolveAutoUpdatesStatus({ - versionEnvVar: this.versionEnvVar, + cdnBaseUrl: config.cdnBaseUrl, + configToolsVersion: config.toolsVersion, managingClusterUri, - getClusterVersions: this.getClusterVersions, + getClusterVersions: this.client.getClusterVersions, }); this.logger.info('Resolved auto updates status', this.autoUpdatesStatus); } @@ -379,8 +430,13 @@ export class AppUpdater { } export interface UpdateInfo extends ElectronUpdateInfo { - /** Indicates whether the update version is newer or older than the current app version. */ - updateKind: 'upgrade' | 'downgrade'; + /** + * Deprecated per‑machine env‑var configuration requires a UAC prompt and prevents use of the privileged updater. + * Windows only. + * + * TODO(gzdunek): REMOVE IN 19.0.0 + */ + requiresUacPrompt: boolean; } export interface AppUpdaterStorage< @@ -393,11 +449,17 @@ export interface AppUpdaterStorage< put(value: Partial): void; } +interface CustomEvents { + installing: void[]; +} + function registerEventHandlers( nativeUpdater: NativeUpdater, + customEvents: EventEmitter, emit: (event: AppUpdateEvent) => void, getAutoUpdatesStatus: () => AutoUpdatesStatus, - getAutoDownload: () => boolean + getAutoDownload: () => boolean, + requiresUacPrompt: () => boolean ): () => void { // updateInfo becomes defined when an update is available (see onUpdateAvailable). // It is later attached to other events, like 'download-progress' or 'error'. @@ -412,10 +474,7 @@ function registerEventHandlers( const onUpdateAvailable = (update: ElectronUpdateInfo) => { updateInfo = { ...update, - updateKind: - compare(update.version, app.getVersion()) === 1 - ? 'upgrade' - : 'downgrade', + requiresUacPrompt: requiresUacPrompt(), }; emit({ kind: 'update-available', @@ -455,6 +514,12 @@ function registerEventHandlers( update: updateInfo, autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, }); + const onInstalling = () => + emit({ + kind: 'installing', + update: updateInfo, + autoUpdatesStatus: getAutoUpdatesStatus() as AutoUpdatesEnabled, + }); nativeUpdater.on('checking-for-update', onCheckingForUpdate); nativeUpdater.on('update-available', onUpdateAvailable); @@ -462,6 +527,7 @@ function registerEventHandlers( nativeUpdater.on('error', onError); nativeUpdater.on('download-progress', onDownloadProgress); nativeUpdater.on('update-downloaded', onUpdateDownloaded); + customEvents.on('installing', onInstalling); return () => { nativeUpdater.off('checking-for-update', onCheckingForUpdate); @@ -470,6 +536,7 @@ function registerEventHandlers( nativeUpdater.off('error', onError); nativeUpdater.off('download-progress', onDownloadProgress); nativeUpdater.off('update-downloaded', onUpdateDownloaded); + customEvents.off('installing', onInstalling); }; } @@ -533,4 +600,12 @@ export type AppUpdateEvent = update: UpdateInfo; /** Status of enabled auto updates. */ autoUpdatesStatus: AutoUpdatesEnabled; + } + | { + /** The app is quitting to install the downloaded update. */ + kind: 'installing'; + /** Information about the update being installed. */ + update: UpdateInfo; + /** Status of enabled auto updates. */ + autoUpdatesStatus: AutoUpdatesEnabled; }; diff --git a/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.test.ts b/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.test.ts index 300b2c9f316ca..b6a35071aff92 100644 --- a/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.test.ts +++ b/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.test.ts @@ -30,19 +30,45 @@ beforeAll(() => { Logger.init(new NullService()); }); +const cdnBaseUrl = 'https://cdn.teleport.dev'; + test.each<{ title: string; input: { - versionEnvVar: string; + configToolsVersion: string; + cdnBaseUrl: string; managingClusterUri: string; getClusterVersions: () => Promise; }; expected: AutoUpdatesStatus; }>([ { - title: 'disabled when env var is "off"', + title: 'disabled when tools version is "off"', + input: { + configToolsVersion: 'off', + cdnBaseUrl: '', + managingClusterUri: '', + getClusterVersions: async () => ({ + reachableClusters: [], + unreachableClusters: [], + }), + }, + expected: { + enabled: false, + reason: 'disabled-by-env-config', + options: { + managingClusterUri: '', + highestCompatibleVersion: '', + unreachableClusters: [], + clusters: [], + }, + }, + }, + { + title: 'disabled when there is no download base URL defined', input: { - versionEnvVar: 'off', + cdnBaseUrl: '', + configToolsVersion: '', managingClusterUri: '', getClusterVersions: async () => ({ reachableClusters: [], @@ -51,7 +77,7 @@ test.each<{ }, expected: { enabled: false, - reason: 'disabled-by-env-var', + reason: 'no-base-url', options: { managingClusterUri: '', highestCompatibleVersion: '', @@ -61,9 +87,10 @@ test.each<{ }, }, { - title: 'resolving with env var when set to version', + title: 'resolving with tools version when set to version', input: { - versionEnvVar: '14.0.0', + cdnBaseUrl, + configToolsVersion: '14.0.0', managingClusterUri: '', getClusterVersions: async () => ({ reachableClusters: [], @@ -73,7 +100,7 @@ test.each<{ expected: { enabled: true, version: '14.0.0', - source: 'env-var', + source: 'env-config', options: { managingClusterUri: '', highestCompatibleVersion: '', @@ -85,7 +112,8 @@ test.each<{ { title: 'disabled when no versions with auto-update enabled', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', managingClusterUri: undefined, getClusterVersions: async () => ({ reachableClusters: [ @@ -121,7 +149,8 @@ test.each<{ { title: 'resolving with managing cluster if specified', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { @@ -159,7 +188,8 @@ test.each<{ title: 'resolving using most compatible version when clusters are on the same version', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { @@ -210,7 +240,8 @@ test.each<{ title: 'resolving using most compatible version when clusters are on the same major version', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { @@ -261,7 +292,8 @@ test.each<{ title: 'resolving to stable version when both stable and pre-release are available', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { @@ -312,7 +344,8 @@ test.each<{ title: 'resolving using most compatible version when clusters are on different major version', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { @@ -363,7 +396,8 @@ test.each<{ title: 'disabled when cluster managing updates no longer has `toolsAutoUpdate` set to true', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { @@ -412,7 +446,8 @@ test.each<{ { title: 'disabled when cluster managing updates is unreachable', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { @@ -459,7 +494,8 @@ test.each<{ title: 'resolving with no compatible version when clusters are on incompatibles versions', input: { - versionEnvVar: '', + cdnBaseUrl, + configToolsVersion: '', getClusterVersions: async () => ({ reachableClusters: [ { diff --git a/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.ts b/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.ts index f315d95cf044b..c3a4036aaba67 100644 --- a/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.ts +++ b/web/packages/teleterm/src/services/appUpdater/autoUpdatesStatus.ts @@ -32,20 +32,21 @@ const logger = new Logger('resolveAutoUpdatesStatus'); * Determines the auto-update status based on all connected clusters. * Updates can be either enabled or disabled, depending on the passed sources. * The version is selected using the following precedence: - * 1. `TELEPORT_TOOLS_VERSION` env var, if defined. + * 1. Tools version from the local environment, if defined. * 2. `tools_version` from the cluster manually selected to manage updates, if selected. * 3. Highest compatible version, if found. * 4. If there's no version at this point, stop auto updates. */ export async function resolveAutoUpdatesStatus(sources: { - versionEnvVar: string; + cdnBaseUrl: string; + configToolsVersion: string; managingClusterUri: string | undefined; getClusterVersions(): Promise; }): Promise { - if (sources.versionEnvVar === 'off') { + if (sources.configToolsVersion === 'off') { return { enabled: false, - reason: 'disabled-by-env-var', + reason: 'disabled-by-env-config', options: { clusters: [], unreachableClusters: [], @@ -54,11 +55,23 @@ export async function resolveAutoUpdatesStatus(sources: { }, }; } - if (sources.versionEnvVar) { + if (sources.cdnBaseUrl === '') { + return { + enabled: false, + reason: 'no-base-url', + options: { + clusters: [], + unreachableClusters: [], + highestCompatibleVersion: '', + managingClusterUri: '', + }, + }; + } + if (sources.configToolsVersion) { return { enabled: true, - version: sources.versionEnvVar, - source: 'env-var', + version: sources.configToolsVersion, + source: 'env-config', options: { clusters: [], unreachableClusters: [], @@ -163,7 +176,7 @@ function findVersionFromClusters( export function shouldAutoDownload(updatesStatus: AutoUpdatesEnabled): boolean { const { source } = updatesStatus; switch (source) { - case 'env-var': + case 'env-config': case 'managing-cluster': return true; case 'highest-compatible': @@ -239,11 +252,11 @@ export interface AutoUpdatesEnabled { version: string; /** * Source of the update: - * - `env-var` - TELEPORT_TOOLS_VERSION configures app version. + * - `env-config` - `TELEPORT_TOOLS_VERSION` env var or `ToolsVersion` in system registry configures app version. * - `managing-cluster` - updates are managed by a manually configured cluster. * - `highest-compatible` - updates are determined by the highest compatible version available. */ - source: 'env-var' | 'managing-cluster' | 'highest-compatible'; + source: 'env-config' | 'managing-cluster' | 'highest-compatible'; /** * Represents the options considered during the auto-update version resolution process. * If updates are configured via the environment variable, all fields will be empty or undefined. @@ -255,7 +268,8 @@ export interface AutoUpdatesDisabled { enabled: false; /** * Reason the updates are disabled: - * `disabled-by-env-var` - `TELEPORT_TOOLS_VERSION` is 'off'. + * `no-base-url` - the build is OSS and `TELEPORT_CDN_BASE_URL` env var or `CdnBaseUrl` doesn't provide a URL. + * `disabled-by-env-config` - `TELEPORT_TOOLS_VERSION` env var or `ToolsVersion` in system registry is 'off'. * `managing-cluster-unable-to-manage` - the manually selected managing cluster is either * unreachable or it has since disabled autoupdates. * `no-cluster-with-auto-update` - there is no cluster that could manage updates. @@ -263,7 +277,8 @@ export interface AutoUpdatesDisabled { * they specify incompatible client tools versions. */ reason: - | 'disabled-by-env-var' + | 'no-base-url' + | 'disabled-by-env-config' | 'managing-cluster-unable-to-manage' | 'no-cluster-with-auto-update' | 'no-compatible-version'; diff --git a/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts index 247ac117c06b7..9796bd5508966 100644 --- a/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts +++ b/web/packages/teleterm/src/services/appUpdater/clientToolsUpdateProvider.ts @@ -21,7 +21,6 @@ import { AppUpdater, DebUpdater, MacUpdater, - NsisUpdater, Provider, ResolvedUpdateFileInfo, RpmUpdater, @@ -32,6 +31,7 @@ import { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider' import { compare, major } from 'shared/utils/semVer'; import { UnsupportedVersionError } from './errors'; +import { NsisDualModeUpdater } from './nsisDualModeUpdater'; const CHECKSUM_FETCH_TIMEOUT = 5_000; // Example: 99a2fe26681073de56de4229dd9cd6655fef22759579b7b9bc359e018ea1007099a2fe26681073de56de4229dd9cd6655fef22759579b7b9bc359e018ea10070 Teleport Connect-17.5.4-mac.zip @@ -71,8 +71,11 @@ export class ClientToolsUpdateProvider extends Provider { }; } - const { baseUrl, version } = clientTools; - const updatesSupport = areManagedUpdatesSupportedInConnect(version); + const { baseUrl, version, isPerMachineInstall } = clientTools; + const updatesSupport = areManagedUpdatesSupportedInConnect( + this.nativeUpdater, + version + ); if (updatesSupport.supported === false) { throw new UnsupportedVersionError(version, updatesSupport.minVersion); } @@ -87,10 +90,11 @@ export class ClientToolsUpdateProvider extends Provider { sha512: '', files: [ { - // Effective only on Windows. - isAdminRightsRequired: true, url: fileUrl, sha512, + // Taken into account only on Windows. + // Read in NsisDualModeUpdater. + isAdminRightsRequired: isPerMachineInstall, }, ], }; @@ -116,6 +120,11 @@ export type ClientToolsVersionGetter = () => Promise< baseUrl: string; /** Version to download. */ version: string; + /** + * Determines whether updates should target a per-machine installation. + * Applicable only on Windows. + */ + isPerMachineInstall: boolean; } | undefined >; @@ -124,7 +133,7 @@ function makeDownloadFilename(updater: AppUpdater, version: string): string { if (updater instanceof MacUpdater) { return `Teleport Connect-${version}-mac.zip`; } - if (updater instanceof NsisUpdater) { + if (updater instanceof NsisDualModeUpdater) { return `Teleport Connect Setup-${version}.exe`; } if (updater instanceof RpmUpdater) { @@ -158,6 +167,7 @@ async function fetchChecksum(fileUrl: string): Promise { // TODO(gzdunek) DELETE IN v20.0.0 function areManagedUpdatesSupportedInConnect( + updater: AppUpdater, version: string ): { supported: true } | { supported: false; minVersion: string } { const thresholds = { @@ -165,6 +175,12 @@ function areManagedUpdatesSupportedInConnect( 17: '17.7.3', }; + if (updater instanceof NsisDualModeUpdater) { + // TODO(gzdunek): Update the thresholds to disallow downgrades + // to versions that don't support the per-user mode or don't + // install per-machine updates through the update service. + } + const majorVersion = major(version); if (majorVersion >= 19) { return { supported: true }; diff --git a/web/packages/teleterm/src/services/appUpdater/nsisDualModeUpdater.ts b/web/packages/teleterm/src/services/appUpdater/nsisDualModeUpdater.ts new file mode 100644 index 0000000000000..ae9c4774e3ac2 --- /dev/null +++ b/web/packages/teleterm/src/services/appUpdater/nsisDualModeUpdater.ts @@ -0,0 +1,229 @@ +/** + * Teleport + * Copyright (C) 2026 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { spawnSync } from 'child_process'; +import path from 'node:path'; + +import { shell } from 'electron'; +import { NsisUpdater } from 'electron-updater'; +import { DownloadUpdateOptions } from 'electron-updater/out/AppUpdater'; +import { InstallOptions } from 'electron-updater/out/BaseUpdater'; + +import { getErrorMessage } from 'shared/utils/error'; + +import { + TSH_AUTOUPDATE_ENV_VAR, + TSH_AUTOUPDATE_OFF, +} from 'teleterm/node/tshAutoupdate'; + +export interface NsisDualModeUpdaterOptions { + /** + * Deprecated per‑machine env‑var config forces UAC (no privileged updater). + * TODO(gzdunek): REMOVE IN 19.0.0 + */ + privilegedUpdaterCannotBeUsed: boolean; +} + +/** + * Extends the standard NSIS updater to support per-machine updates via the privileged + * TeleportConnectUpdater service, enabling updates without prompting for admin credentials. + * For per-user installs, the updater is executed directly. + */ +export class NsisDualModeUpdater extends NsisUpdater { + private updateVersion: string; + constructor( + private options: NsisDualModeUpdaterOptions, + private tshPath: string + ) { + super(); + } + + protected override doDownloadUpdate( + downloadUpdateOptions: DownloadUpdateOptions + ): Promise> { + this.updateVersion = + downloadUpdateOptions.updateInfoAndProvider.info.version; + return super.doDownloadUpdate(downloadUpdateOptions); + } + + protected override doInstall(options: InstallOptions): boolean { + if (!options.isAdminRightsRequired) { + return this.doInstallPerScope(options, 'user'); + } + + if (this.options.privilegedUpdaterCannotBeUsed) { + return this.doInstallPerScope(options, 'machine'); + } + + return this.doInstallPerMachineWithPrivilegedService(options); + } + + private doInstallPerMachineWithPrivilegedService( + options: InstallOptions + ): boolean { + if (!this.installerPath) { + this.dispatchError( + new Error("No update filepath provided, can't quit and install") + ); + return false; + } + // options.isSilent is ignored. + // The system service runs in Session 0 and cannot display any UI. + const args = [ + 'connect-updater', + 'install-update', + `--path=${this.installerPath}`, + `--update-version=${this.updateVersion}`, + ]; + if (options.isForceRunAfter) { + args.push('--force-run'); + } + + try { + // Spawn synchronously to ensure that no errors are missed. + this.spawnSync(this.tshPath, args, { + [TSH_AUTOUPDATE_ENV_VAR]: TSH_AUTOUPDATE_OFF, + }); + } catch (error) { + this.dispatchError(error); + const errorMessage = getErrorMessage(error); + if (!errorMessage.includes('failed to ensure service is running')) { + // If not a problem with starting the service, keep the app open and surface the error in the UI. + return false; + } + // Otherwise, fall back to UAC installer. + this.logger.warn( + 'Failed to start privileged update service, falling back to regular installation' + ); + return this.doInstallPerScope(options, 'machine'); + } + return true; + } + + /** + * Copied from `doInstall` in NSIS updater: + * https://github.com/electron-userland/electron-builder/blob/7b5901b77dfae417c29944656b80c583384de026/packages/electron-updater/src/NsisUpdater.ts#L126-L181 + * (commit 8ba9be481e3b777aa77884d265fd9b7f927a8a99). + * + * The only change is adding the `scope` parameter. It enforces that the currently + * used app instance is updated, when both of them are available in the system. + */ + protected doInstallPerScope( + options: InstallOptions, + scope: 'user' | 'machine' + ): boolean { + const installerPath = this.installerPath; + if (installerPath == null) { + this.dispatchError( + new Error("No update filepath provided, can't quit and install") + ); + return false; + } + + const args = ['--updated']; + + switch (scope) { + case 'user': + // Do not attempt to update the per-machine version if it exists. + args.push('/currentuser'); + break; + case 'machine': + args.push('/allusers'); + } + + if (options.isSilent) { + args.push('/S'); + } + + if (options.isForceRunAfter) { + args.push('--force-run'); + } + + if (this.installDirectory) { + // maybe check if folder exists + args.push(`/D=${this.installDirectory}`); + } + + const packagePath = + this.downloadedUpdateHelper == null + ? null + : this.downloadedUpdateHelper.packageFile; + if (packagePath != null) { + // only = form is supported + args.push(`--package-file=${packagePath}`); + } + + const callUsingElevation = (): void => { + this.spawnLog( + path.join(process.resourcesPath, 'elevate.exe'), + [installerPath].concat(args) + ).catch(e => this.dispatchError(e)); + }; + + if (options.isAdminRightsRequired) { + this._logger.info( + 'isAdminRightsRequired is set to true, run installer using elevate.exe' + ); + callUsingElevation(); + return true; + } + + this.spawnLog(installerPath, args).catch((e: Error) => { + // https://github.com/electron-userland/electron-builder/issues/1129 + // Node 8 sends errors: https://nodejs.org/dist/latest-v8.x/docs/api/errors.html#errors_common_system_errors + const errorCode = (e as NodeJS.ErrnoException).code; + this._logger.info( + `Cannot run installer: error code: ${errorCode}, error message: "${e.message}", will be executed again using elevate if EACCES, and will try to use electron.shell.openItem if ENOENT` + ); + if (errorCode === 'UNKNOWN' || errorCode === 'EACCES') { + callUsingElevation(); + } else if (errorCode === 'ENOENT') { + shell + .openPath(installerPath) + .catch((err: Error) => this.dispatchError(err)); + } else { + this.dispatchError(e); + } + }); + return true; + } + + protected spawnSync(cmd: string, args: string[] = [], env = {}): void { + this.logger.info(`Executing: ${cmd} with args: ${args}`); + const response = spawnSync(cmd, args, { + env: env, + encoding: 'utf-8', + }); + + const { error, status, stdout, stderr } = response; + if (error != null) { + this.logger.error(stderr); + throw error; + } else if (status != null && status !== 0) { + this.logger.error(stderr); + throw new Error( + `Command ${cmd} exited with code ${status} and error ${stderr}` + ); + } + this.logger.info( + [`Command exited successfully`, stdout ? `, output: ${stdout}` : ''] + .filter(Boolean) + .join(' ') + ); + } +} diff --git a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts index dfdfa9d6d379c..9fa399860472c 100644 --- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -17,6 +17,7 @@ */ import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb'; +import { ConfigSource } from 'gen-proto-ts/teleport/lib/teleterm/auto_update/v1/auto_update_service_pb'; import { ClientVersionStatus } from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb'; import { @@ -129,6 +130,8 @@ export class MockVnetClient implements VnetClient { '/Users/user/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config', }); getBackgroundItemStatus = () => new MockedUnaryCall({ status: 0 }); + checkInstallTimeRequirements = () => + new MockedUnaryCall({ status: { oneofKind: undefined } }); runDiagnostics() { return new MockedUnaryCall({ report: { @@ -146,5 +149,11 @@ export class MockAutoUpdateClient implements AutoUpdateClient { reachableClusters: [], unreachableClusters: [], }); - getDownloadBaseUrl = () => new MockedUnaryCall({ baseUrl: '' }); + getConfig = () => + new MockedUnaryCall({ + cdnBaseUrl: { value: '', source: ConfigSource.UNSPECIFIED }, + toolsVersion: { value: '', source: ConfigSource.UNSPECIFIED }, + }); + getInstallationMetadata = () => + new MockedUnaryCall({ isPerMachineInstall: false }); } diff --git a/web/packages/teleterm/src/services/tshd/testHelpers.ts b/web/packages/teleterm/src/services/tshd/testHelpers.ts index 7df11b6e051dc..489cb2cd09fd8 100644 --- a/web/packages/teleterm/src/services/tshd/testHelpers.ts +++ b/web/packages/teleterm/src/services/tshd/testHelpers.ts @@ -382,3 +382,16 @@ export const makeAuthSettings = ( clientVersionStatus: ClientVersionStatus.OK, ...props, }); + +export const makeTshdRpcError = ( + props: Partial = {} +): TshdRpcError => { + return { + name: 'TshdRpcError', + isResolvableWithRelogin: false, + code: 'UNKNOWN', + message: 'Error occurred', + toString: () => 'Error occurred', + ...props, + }; +}; diff --git a/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx b/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx index f0a2909adc167..75f75aa2fee5e 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/AppUpdater.story.tsx @@ -36,6 +36,7 @@ import { makeCheckingForUpdateEvent, makeDownloadProgressEvent, makeErrorEvent, + makeInstallingEvent, makeUpdateAvailableEvent, makeUpdateDownloadedEvent, makeUpdateInfo, @@ -50,9 +51,10 @@ export interface StoryProps { | 'Update available' | 'Download progress' | 'Error' - | 'Update downloaded'; + | 'Update downloaded' + | 'Installing'; updateSource: string; - envVar: 'Set to "off"' | 'Set to version - v15' | 'Unset'; + configToolsVersion: 'Set to "off"' | 'Set to version - v15' | 'Unset'; platform: Platform; clusterFoo: | 'Does not exist' @@ -67,18 +69,19 @@ export interface StoryProps { | 'Enabled client updates - v16 cluster' | 'Disabled client updates - v17 cluster'; clusterBarSetToManageUpdates: boolean; - updateKind: 'Upgrade' | 'Downgrade'; - nonTeleportCdn: boolean; + cdnBaseUrl: 'Unset (OSS build)' | 'Official' | 'Unofficial'; + updateRequiresUacPrompt: boolean; } const meta: Meta = { title: 'Teleterm/AppUpdater', component: WidgetAndDetails, argTypes: { - envVar: { + configToolsVersion: { control: { type: 'radio' }, options: ['Off', 'Set to version - v15', 'Unset'], - description: '`TELEPORT_TOOLS_VERSION` value', + description: + 'Tools version from the local config (env var/system registry value)', }, clusterFoo: { control: { type: 'select' }, @@ -106,12 +109,6 @@ const meta: Meta = { control: { type: 'boolean' }, description: 'Whether cluster "bar" is manually set to control updates', }, - updateKind: { - control: { type: 'radio' }, - options: ['Upgrade', 'Downgrade'], - description: - 'Indicates whether the update version is newer or older than the current application version.', - }, step: { control: { type: 'radio' }, options: [ @@ -121,6 +118,7 @@ const meta: Meta = { 'Download progress', 'Error', 'Update downloaded', + 'Installing', ], description: 'Updating process step', }, @@ -129,21 +127,26 @@ const meta: Meta = { options: ['win32', 'darwin', 'linux'], description: 'Operating system', }, - nonTeleportCdn: { + cdnBaseUrl: { + control: { type: 'radio' }, + description: 'CDN Base URL', + options: ['Unset (OSS build)', 'Official', 'Unofficial'], + }, + updateRequiresUacPrompt: { control: { type: 'boolean' }, description: - 'Whether `TELEPORT_CDN_BASE_URL` is set to non-Teleport CDN URL', + 'Deprecated per‑machine env‑var configuration requires a UAC prompt and prevents use of the privileged updater. Windows only.', }, }, args: { - envVar: 'Unset', + configToolsVersion: 'Unset', clusterFoo: 'Enabled client updates - v18 cluster', clusterBar: 'Does not exist', clusterBarSetToManageUpdates: false, - updateKind: 'Upgrade', step: 'Update available', platform: 'darwin', - nonTeleportCdn: false, + cdnBaseUrl: 'Official', + updateRequiresUacPrompt: false, }, }; @@ -155,10 +158,16 @@ context.addRootCluster(makeRootCluster({ uri: '/clusters/bar', name: 'bar' })); async function resolveEvent(storyProps: StoryProps): Promise { const status = await resolveAutoUpdatesStatus({ - versionEnvVar: - storyProps.envVar === 'Set to version - v15' + cdnBaseUrl: + storyProps.cdnBaseUrl === 'Unset (OSS build)' + ? '' + : storyProps.cdnBaseUrl === 'Official' + ? 'https://cdn.teleport.dev' + : 'https://custom-hosting.local', + configToolsVersion: + storyProps.configToolsVersion === 'Set to version - v15' ? '15.0.0' - : storyProps.envVar === 'Unset' + : storyProps.configToolsVersion === 'Unset' ? undefined : 'off', managingClusterUri: storyProps.clusterBarSetToManageUpdates @@ -242,12 +251,17 @@ async function resolveEvent(storyProps: StoryProps): Promise { }, }); + const nonTeleportCdn = storyProps.cdnBaseUrl === 'Unofficial'; const updateInfo = makeUpdateInfo( - storyProps.nonTeleportCdn, + nonTeleportCdn, status.enabled ? status.version : '', - storyProps.updateKind === 'Upgrade' ? 'upgrade' : 'downgrade' + storyProps.updateRequiresUacPrompt ); + if (storyProps.platform !== 'win32' && storyProps.updateRequiresUacPrompt) { + return; + } + switch (storyProps.step) { case 'Checking for update': return makeCheckingForUpdateEvent(status); @@ -273,6 +287,11 @@ async function resolveEvent(storyProps: StoryProps): Promise { return makeErrorEvent(updateInfo, status); } return; + case 'Installing': + if (status.enabled) { + return makeInstallingEvent(updateInfo, status); + } + return; } } @@ -327,6 +346,7 @@ function WidgetAndDetails(storyProps: StoryProps) { {}} @@ -340,9 +360,9 @@ function WidgetAndDetails(storyProps: StoryProps) { ); } -export const EnabledWithEnvVar: StoryObj = { +export const EnabledWithLocalConfigToolsVersion: StoryObj = { args: { - envVar: 'Set to version - v15', + configToolsVersion: 'Set to version - v15', }, }; @@ -428,3 +448,9 @@ export const DisabledBecauseClustersRequireIncompatibleVersions: StoryObj = { + args: { + cdnBaseUrl: 'Unset (OSS build)', + }, +}; diff --git a/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx b/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx index 09158fa129b0a..8086910055d81 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/AutoUpdatesManagement.tsx @@ -26,6 +26,7 @@ import { RadioGroup } from 'design/RadioGroup'; import { H3, P3 } from 'design/Text'; import { pluralize } from 'shared/utils/text'; +import type { Platform } from 'teleterm/mainProcess/types'; import { AppUpdateEvent, AutoUpdatesDisabled, @@ -40,6 +41,7 @@ const listFormatter = new Intl.ListFormat('en', { }); export function AutoUpdatesManagement(props: { + platform: Platform; status: AutoUpdatesStatus; updateEventKind: AppUpdateEvent['kind']; changeManagingCluster(clusterUri: RootClusterUri | undefined): void; @@ -50,7 +52,7 @@ export function AutoUpdatesManagement(props: { const content = status.enabled === true ? makeContentForEnabledAutoUpdates(status) - : makeContentForDisabledAutoUpdates(status); + : makeContentForDisabledAutoUpdates(status, props.platform); const retryButton = { content: 'Retry', onClick: props.onCheckForUpdates, @@ -284,10 +286,10 @@ function makeContentForEnabledAutoUpdates(status: AutoUpdatesEnabled): { showRetry?: boolean; } { switch (status.source) { - case 'env-var': + case 'env-config': return { kind: 'neutral', - description: `The app is set to stay on version ${status.version} by your device settings.`, + description: `Your device settings require client version ${status.version}.`, }; case 'managing-cluster': return; @@ -308,14 +310,41 @@ function makeContentForEnabledAutoUpdates(status: AutoUpdatesEnabled): { } } -function makeContentForDisabledAutoUpdates(updateSource: AutoUpdatesDisabled): { +function makeContentForDisabledAutoUpdates( + updateSource: AutoUpdatesDisabled, + platform: Platform +): { title?: string; description?: ReactNode; kind: 'danger' | 'neutral'; showRetry?: boolean; } { switch (updateSource.reason) { - case 'disabled-by-env-var': + case 'no-base-url': + return { + kind: 'danger', + description: ( + <> + Client tools updates are disabled because the download base URL is + not configured. To use Community Edition builds or custom binaries, + set{' '} + {platform === 'win32' ? ( + <> + CdnBaseUrl in{' '} + + HKEY_LOCAL_MACHINE|HKEY_CURRENT_USER\SOFTWARE\Policies\Teleport\TeleportConnect + {' '} + in the system registry. + + ) : ( + <> + the TELEPORT_CDN_BASE_URL environment variable. + + )} + + ), + }; + case 'disabled-by-env-config': return { kind: 'neutral', description: 'App updates are disabled by your device settings.', diff --git a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx index 7463166dcdf8d..76dbb95efec91 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.test.tsx @@ -28,30 +28,28 @@ import { test('download button is available when autoDownload is false', async () => { render( {}} onDownload={() => {}} @@ -97,6 +95,7 @@ test('when there are multiple clusters available, managing cluster can be select }, })} platform="darwin" + currentVersion="14.7.2" onCheckForUpdates={() => {}} onDownload={() => {}} onCancelDownload={() => {}} diff --git a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx index 5ec25193f894e..cbc16260ff0ce 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/DetailsView.tsx @@ -56,6 +56,7 @@ export function DetailsView({ changeManagingCluster, updateEvent, platform, + currentVersion, onCheckForUpdates, onDownload, onCancelDownload, @@ -63,6 +64,7 @@ export function DetailsView({ }: { updateEvent: AppUpdateEvent; platform: Platform; + currentVersion: string; onCheckForUpdates(): void; onInstall(): void; onDownload(): void; @@ -73,6 +75,7 @@ export function DetailsView({ {updateEvent.autoUpdatesStatus && ( Checking for updates… onCheckForAppUpdates()}> - Check For Updates + Check for Updates ); @@ -146,9 +152,14 @@ function UpdaterState({ return ( {event.autoUpdatesStatus.enabled && ( - + - Teleport Connect is up to date. + + No updates available. + + Teleport Connect {currentVersion} + + )} - Check For Updates + Check for Updates ); @@ -196,18 +207,19 @@ function UpdaterState({ ); case 'update-downloaded': - const label = - platform === 'darwin' - ? 'Ready to install' - : 'Ready to install. Admin permissions may be required.'; + case 'installing': return ( - + - - Restart + + {event.kind === 'installing' ? 'Restarting…' : 'Restart'} ); @@ -220,11 +232,7 @@ function AvailableUpdate(props: { update: UpdateInfo; platform: Platform }) { return ( - - {props.update.updateKind === 'upgrade' - ? 'A new version is available.' - : 'The app needs to be downgraded to match the required version.'} - + A new version is available. {props.platform === 'darwin' ? ( App icon @@ -260,6 +268,26 @@ function AvailableUpdate(props: { update: UpdateInfo; platform: Platform }) { )} + + {props.update.requiresUacPrompt && ( + + + + Teleport Connect updates are currently configured using deprecated + environment variables (TELEPORT_TOOLS_VERSION or{' '} + TELEPORT_CDN_BASE_URL). To continue receiving updates + without requiring UAC prompts, migrate these settings to the{' '} + + system policy registry keys + {' '} + (HKLM\SOFTWARE\Policies\Teleport\TeleportConnect). + + + )} + ); } diff --git a/web/packages/teleterm/src/ui/AppUpdater/WidgetView.test.tsx b/web/packages/teleterm/src/ui/AppUpdater/WidgetView.test.tsx index eea2388d72a2c..bb292342281fc 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/WidgetView.test.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/WidgetView.test.tsx @@ -28,30 +28,27 @@ import { WidgetView } from './WidgetView'; test('download button is available when autoDownload is false', async () => { render( {}} onMore={() => {}} diff --git a/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx b/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx index ab5db79d2555a..b0643127fe87d 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx +++ b/web/packages/teleterm/src/ui/AppUpdater/WidgetView.tsx @@ -137,17 +137,25 @@ export function WidgetView({ ? updateEvent.autoUpdatesStatus.options.unreachableClusters : []; const downloadBaseUrl = getDownloadHost(updateEvent); + const requiresUacPrompt = updateEvent.update.requiresUacPrompt; return ( @@ -159,6 +167,7 @@ function AvailableUpdate({ downloadHost, onMore, platform, + requiresUacPrompt, primaryButton, unreachableClusters, version, @@ -169,10 +178,12 @@ function AvailableUpdate({ unreachableClusters: UnreachableCluster[]; downloadHost: string; platform: Platform; + requiresUacPrompt: boolean; onMore(): void; primaryButton?: { name: string; - onClick(): void; + disabled?: boolean; + onClick?(): void; }; } & SpaceProps) { const hasUnreachableClusters = !!unreachableClusters.length; @@ -212,7 +223,11 @@ function AvailableUpdate({ {primaryButton && ( - + {primaryButton.name} )} @@ -221,7 +236,7 @@ function AvailableUpdate({ - {(hasUnreachableClusters || isNonTeleportServer) && ( + {(hasUnreachableClusters || isNonTeleportServer || requiresUacPrompt) && ( {hasUnreachableClusters && ( )} + {requiresUacPrompt && ( + + )} )} @@ -268,7 +289,8 @@ function makeUpdaterContent({ description: string; button?: { name: string; - action(): void; + disabled?: boolean; + action?(): void; }; } { switch (updateEvent.kind) { @@ -277,21 +299,14 @@ function makeUpdaterContent({ description: `Downloaded ${formatMB(updateEvent.progress.transferred)} of ${formatMB(updateEvent.progress.total)}`, }; case 'update-available': - const { updateKind } = updateEvent.update; if (updateEvent.autoDownload) { return { - description: - updateKind === 'upgrade' - ? 'Update available. Starting download…' - : 'Downloading required version…', + description: 'Update available. Starting download…', }; } return { - description: - updateKind === 'upgrade' - ? 'Update available' - : 'Downgrade to required version', + description: 'Update available', button: { name: 'Download', action: onDownload, @@ -305,6 +320,14 @@ function makeUpdaterContent({ action: onInstall, }, }; + case 'installing': + return { + description: 'Ready to install', + button: { + name: 'Restarting…', + disabled: true, + }, + }; case 'error': return { description: 'Update failed', diff --git a/web/packages/teleterm/src/ui/AppUpdater/testHelpers.ts b/web/packages/teleterm/src/ui/AppUpdater/testHelpers.ts index 69f7e67b1b3fc..610432d5a53ff 100644 --- a/web/packages/teleterm/src/ui/AppUpdater/testHelpers.ts +++ b/web/packages/teleterm/src/ui/AppUpdater/testHelpers.ts @@ -27,7 +27,7 @@ import { shouldAutoDownload } from 'teleterm/services/appUpdater/autoUpdatesStat export function makeUpdateInfo( nonTeleportCdn: boolean, version: string, - updateKind: 'upgrade' | 'downgrade' + requiresUacPrompt?: boolean ): UpdateInfo { return { files: [ @@ -39,8 +39,8 @@ export function makeUpdateInfo( size: 123214312, }, ], + requiresUacPrompt, releaseDate: '', - updateKind, version, path: '', sha512: '', @@ -117,3 +117,14 @@ export function makeUpdateDownloadedEvent( autoUpdatesStatus: status, }; } + +export function makeInstallingEvent( + updateInfo: UpdateInfo, + status: AutoUpdatesEnabled +): AppUpdateEvent { + return { + kind: 'installing', + update: updateInfo, + autoUpdatesStatus: status, + }; +} diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx index 8e8cb5ea6221f..0ee354c602f04 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.tsx @@ -198,6 +198,7 @@ function ClusterLoginForm({ const AppUpdateDetails = ({ refCallback, platform, + currentVersion, downloadAppUpdate, checkForAppUpdates, cancelAppUpdateDownload, @@ -224,6 +225,7 @@ const AppUpdateDetails = ({ quitAndInstallAppUpdate()} platform={platform} + currentVersion={currentVersion} changeManagingCluster={clusterUri => changeAppUpdatesManagingCluster(clusterUri) } diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx index 300a9c0d46e12..9b41eff8a4bf7 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/storyHelpers.tsx @@ -102,6 +102,7 @@ export function makeProps( shouldSkipVersionCheck: false, disableVersionCheck: () => {}, platform: 'darwin', + currentVersion: '14.7.2', changeAppUpdatesManagingCluster: async () => {}, checkForAppUpdates: async () => {}, downloadAppUpdate: async () => {}, @@ -148,7 +149,6 @@ export function makeProps( props.appUpdateEvent = { kind: 'update-available', update: { - updateKind: 'upgrade', version: '19.0.0', files: [ { @@ -159,6 +159,7 @@ export function makeProps( path: '', releaseDate: '', sha512: '', + requiresUacPrompt: false, }, autoDownload: true, autoUpdatesStatus: { diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts index 55f8999924873..4d61271718e2b 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/useClusterLogin.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { AuthProvider } from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb'; import { @@ -185,7 +185,10 @@ export function useClusterLogin(props: Props) { configService.set('skipVersionCheck', true); setShouldSkipVersionCheck(true); } - const { platform } = mainProcessClient.getRuntimeSettings(); + const { platform, appVersion } = useMemo( + () => mainProcessClient.getRuntimeSettings(), + [mainProcessClient] + ); return { ssoPrompt, @@ -204,6 +207,7 @@ export function useClusterLogin(props: Props) { shouldSkipVersionCheck, disableVersionCheck, platform, + currentVersion: appVersion, appUpdateEvent: appUpdaterContext.updateEvent, downloadAppUpdate: mainProcessClient.downloadAppUpdate, cancelAppUpdateDownload: mainProcessClient.cancelAppUpdateDownload, diff --git a/web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx b/web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx index ad44154bab917..38b6d933a200d 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/modals/AppUpdates.tsx @@ -44,8 +44,8 @@ export function AppUpdates(props: { hidden?: boolean; onClose(): void }) { void checkForAppUpdates(); }, [checkForAppUpdates]); - const platform = useMemo(() => { - return appContext.mainProcessClient.getRuntimeSettings().platform; + const { platform, appVersion } = useMemo(() => { + return appContext.mainProcessClient.getRuntimeSettings(); }, [appContext.mainProcessClient]); return ( @@ -72,6 +72,7 @@ export function AppUpdates(props: { hidden?: boolean; onClose(): void }) { void cancelAppUpdateDownload()} onDownload={() => void downloadAppUpdate()} diff --git a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx index 34f26903fbfad..f47abc0e3a0cd 100644 --- a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx @@ -62,7 +62,7 @@ const displayResultsAction: SearchAction = { perform() {}, }; -it('does not display empty results copy after selecting two filters', () => { +it('does not display empty results copy after selecting two filters', async () => { const appContext = setUpContext('/clusters/foo'); const mockActionAttempts = { @@ -97,11 +97,11 @@ it('does not display empty results copy after selecting two filters', () => { ); - const results = screen.getByRole('menu'); + const results = await screen.findByRole('menu'); expect(results).not.toHaveTextContent('No matching results found'); }); -it('displays empty results copy after providing search query for which there is no results', () => { +it('displays empty results copy after providing search query for which there is no results', async () => { const appContext = setUpContext('/clusters/foo'); const mockActionAttempts = { @@ -131,11 +131,11 @@ it('displays empty results copy after providing search query for which there is ); - const results = screen.getByRole('menu'); + const results = await screen.findByRole('menu'); expect(results).toHaveTextContent('No matching results found.'); }); -it('includes offline cluster names in the empty results copy', () => { +it('includes offline cluster names in the empty results copy', async () => { const cluster = makeRootCluster({ connected: false }); const appContext = setUpContext(cluster.uri); appContext.clustersService.setState(draftState => { @@ -169,7 +169,7 @@ it('includes offline cluster names in the empty results copy', () => { ); - const results = screen.getByRole('menu'); + const results = await screen.findByRole('menu'); expect(results).toHaveTextContent('No matching results found.'); expect(results).toHaveTextContent( `The cluster ${cluster.name} was excluded from the search because you are not logged in to it.` @@ -217,7 +217,7 @@ it('notifies about resource search errors and allows to display details', async ); - const results = screen.getByRole('menu'); + const results = await screen.findByRole('menu'); expect(results).toHaveTextContent( 'Some of the search results are incomplete.' ); @@ -269,7 +269,8 @@ it('maintains focus on the search input after closing a resource search error mo ); - await act(() => user.type(screen.getByRole('searchbox'), 'foo')); + const searchbox = await screen.findByRole('searchbox'); + await act(() => user.type(searchbox, 'foo')); expect(screen.getByRole('menu')).toHaveTextContent( 'Some of the search results are incomplete.' @@ -333,7 +334,8 @@ it('shows a login modal when a request to a cluster from the current workspace f ); - await user.type(screen.getByRole('searchbox'), 'foo'); + const searchbox = await screen.findByRole('searchbox'); + await user.type(searchbox, 'foo'); // Verify that the login modal was shown after typing in the search box. await waitFor(() => { @@ -378,7 +380,8 @@ it('closes on a click on an unfocusable element outside of the search bar', asyn ); - await user.type(screen.getByRole('searchbox'), 'foo'); + const searchbox = await screen.findByRole('searchbox'); + await user.type(searchbox, 'foo'); expect(screen.getByRole('menu')).toBeInTheDocument(); await user.click(screen.getByTestId('unfocusable-element')); diff --git a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.test.tsx b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.test.tsx index 54f6b626b5d2d..5378fd58e7de5 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.test.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.test.tsx @@ -42,6 +42,8 @@ describe('VnetSliderStepHeader', () => { ); + await screen.findByText('Start VNet'); + const listItem = screen.getByTitle('Go back to Connections'); const openDocumentationButton = screen.getByTitle( 'Open VNet documentation' @@ -84,6 +86,8 @@ describe('VnetSliderStepHeader', () => { ); + await screen.findByText('Start VNet'); + expect(document.body).toHaveFocus(); await user.tab(); diff --git a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx index 9b59d2a654d4f..31d462562f00a 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetConnectionItem.tsx @@ -114,6 +114,7 @@ const VnetConnectionItemBase = forwardRef< diagnosticsAttempt, getDisabledDiagnosticsReason, showDiagWarningIndicator, + installTimeRequirementsCheck, } = useVnetContext(); const { close: closeConnectionsPanel } = useConnectionsContext(); const rootClusterUri = useStoreSelector( @@ -122,7 +123,9 @@ const VnetConnectionItemBase = forwardRef< ); const isUserInWorkspace = !!rootClusterUri; const isProcessing = - startAttempt.status === 'processing' || stopAttempt.status === 'processing'; + startAttempt.status === 'processing' || + stopAttempt.status === 'processing' || + installTimeRequirementsCheck.status === 'unknown'; const disabledDiagnosticsReason = getDisabledDiagnosticsReason(diagnosticsAttempt); const indicatorStatus: ConnectionStatus = useMemo(() => { @@ -130,6 +133,7 @@ const VnetConnectionItemBase = forwardRef< if ( startAttempt.status === 'error' || stopAttempt.status === 'error' || + installTimeRequirementsCheck.status === 'failed' || (status.value === 'stopped' && status.reason.value === 'unexpected-shutdown') ) { @@ -145,7 +149,13 @@ const VnetConnectionItemBase = forwardRef< } return 'on'; - }, [startAttempt, stopAttempt, status, showDiagWarningIndicator]); + }, [ + startAttempt, + stopAttempt, + status, + showDiagWarningIndicator, + installTimeRequirementsCheck, + ]); const onEnterPress = (event: React.KeyboardEvent) => { if ( @@ -361,6 +371,7 @@ const VnetConnectionItemBase = forwardRef< key={toggleVnetButtonKey} size="small" width={toggleVnetButtonWidth} + disabled={installTimeRequirementsCheck.status === 'failed'} title="" onClick={e => { e.stopPropagation(); @@ -373,6 +384,7 @@ const VnetConnectionItemBase = forwardRef< { e.stopPropagation(); start(); diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx index fa6abef75c780..3d195228dfd06 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.story.tsx @@ -19,6 +19,7 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { useEffect } from 'react'; import { Box } from 'design'; +import { WindowsServiceStatus } from 'gen-proto-ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb'; import { CheckAttemptStatus, CheckReportStatus, @@ -50,6 +51,10 @@ type StoryProps = { diagReport: 'ok' | 'issues-found' | 'failed-checks'; isWorkspacePresent: boolean; unexpectedShutdown: boolean; + installTimeRequirementsCheck: + | 'success' + | 'windows-service-not-installed' + | 'windows-service-version-mismatch'; }; const defaultArgs: StoryProps = { @@ -63,6 +68,7 @@ const defaultArgs: StoryProps = { diagReport: 'ok', isWorkspacePresent: true, unexpectedShutdown: false, + installTimeRequirementsCheck: 'success', }; const meta: Meta = { @@ -109,6 +115,15 @@ const meta: Meta = { description: "If there's no workspace, the button to open the diag report is disabled.", }, + installTimeRequirementsCheck: { + control: { type: 'radio' }, + options: [ + 'success', + 'windows-service-not-installed', + 'windows-service-version-mismatch', + ], + description: 'VNet-related checks performed before startup.', + }, }, render: props => , }; @@ -117,6 +132,27 @@ export default meta; function VnetSliderStep(props: StoryProps) { const appContext = new MockAppContext(); + if (props.installTimeRequirementsCheck === 'windows-service-not-installed') { + appContext.vnet.checkInstallTimeRequirements = () => + new MockedUnaryCall({ + status: { + oneofKind: 'windowsServiceStatus' as const, + windowsServiceStatus: WindowsServiceStatus.DOES_NOT_EXIST, + }, + }); + } + if ( + props.installTimeRequirementsCheck === 'windows-service-version-mismatch' + ) { + appContext.vnet.checkInstallTimeRequirements = () => + new MockedUnaryCall({ + status: { + oneofKind: 'windowsServiceStatus' as const, + windowsServiceStatus: WindowsServiceStatus.VERSION_MISMATCH, + }, + }); + } + if (props.isWorkspacePresent) { appContext.addRootCluster(makeRootCluster()); } diff --git a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx index 49e4afd0a75ba..1f692c04e4b45 100644 --- a/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx +++ b/web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx @@ -43,6 +43,7 @@ export const VnetSliderStep = (props: StepComponentProps) => { status, startAttempt, stopAttempt, + installTimeRequirementsCheck, runDiagnostics, reinstateDiagnosticsAlert, } = useVnetContext(); @@ -88,6 +89,38 @@ export const VnetSliderStep = (props: StepComponentProps) => { } `} > + {installTimeRequirementsCheck.status === 'failed' && ( + <> + {installTimeRequirementsCheck.reason.kind === + 'missing-windows-service' && ( + + VNet system service is not installed.
+ To use VNet, uninstall Teleport Connect and install it again + selecting 'Anyone who uses this computer' option. + Administrator privileges will be required. +
+ )} + {installTimeRequirementsCheck.reason.kind === + 'windows-service-version-mismatch' && ( + + The VNet system service version does not match the application + version.
+ This can happen if Teleport Connect is installed both per-user + and per-machine. To use VNet, uninstall both Teleport Connect + installations and install it again selecting 'Anyone who + uses this computer' option. Administrator privileges will + be required. +
+ )} + {installTimeRequirementsCheck.reason.kind === 'error' && ( + + Could not perform VNet installation requirements checks:{' '} + {installTimeRequirementsCheck.reason.statusText} + + )} + + )} + {startAttempt.status === 'error' && ( Could not start VNet: {startAttempt.statusText} )} diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx index da3147402f1a6..7c84dd9020c79 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.test.tsx @@ -26,6 +26,8 @@ import { useImperativeHandle, } from 'react'; +import { WindowsServiceStatus } from 'gen-proto-ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb'; + import { MockedUnaryCall } from 'teleterm/services/tshd/cloneableClient'; import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; import { @@ -106,14 +108,86 @@ describe('autostart', () => { ...appContext.statePersistenceService.getState(), vnet: { autoStart: false, hasEverStarted: false }, }); + const { promise, resolve } = Promise.withResolvers(); + appContext.vnet.checkInstallTimeRequirements = async () => { + const response = new MockedUnaryCall({ + status: { + oneofKind: undefined, + }, + }); + resolve(response); + return response; + }; + const { result } = renderHook(() => useVnetContext(), { + wrapper: createWrapper(Wrapper, { appContext }), + }); + await act(() => promise); + expect(result.current.startAttempt.status).toEqual(''); + }); + + it('does not start VNet if Windows system service does not exist', async () => { + const appContext = new MockAppContext(); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true, hasEverStarted: true }, + }); + const { promise, resolve } = Promise.withResolvers(); + appContext.vnet.checkInstallTimeRequirements = async () => { + const response = new MockedUnaryCall({ + status: { + oneofKind: 'windowsServiceStatus' as const, + windowsServiceStatus: WindowsServiceStatus.DOES_NOT_EXIST, + }, + }); + resolve(response); + return response; + }; const { result } = renderHook(() => useVnetContext(), { wrapper: createWrapper(Wrapper, { appContext }), }); + await act(() => promise); expect(result.current.startAttempt.status).toEqual(''); }); + it('does not start VNet if Windows system service version does not match client version', async () => { + const appContext = new MockAppContext(); + appContext.workspacesService.setState(draft => { + draft.isInitialized = true; + }); + appContext.statePersistenceService.putState({ + ...appContext.statePersistenceService.getState(), + vnet: { autoStart: true, hasEverStarted: true }, + }); + const { promise, resolve } = Promise.withResolvers(); + appContext.vnet.checkInstallTimeRequirements = async () => { + const response = new MockedUnaryCall({ + status: { + oneofKind: 'windowsServiceStatus' as const, + windowsServiceStatus: WindowsServiceStatus.VERSION_MISMATCH, + }, + }); + resolve(response); + return response; + }; + const { result } = renderHook(() => useVnetContext(), { + wrapper: createWrapper(Wrapper, { appContext }), + }); + await act(() => promise); + + expect(result.current.startAttempt.status).toEqual(''); + expect(result.current.installTimeRequirementsCheck).toEqual({ + status: 'failed', + reason: { + kind: 'windows-service-version-mismatch', + }, + }); + }); + it('switches off if start fails', async () => { const appContext = new MockAppContext(); appContext.workspacesService.setState(draft => { diff --git a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx index d0130ddd155e8..6f8d631f4d186 100644 --- a/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx +++ b/web/packages/teleterm/src/ui/Vnet/vnetContext.tsx @@ -32,11 +32,14 @@ import { Action } from 'design/Alert'; import { BackgroundItemStatus, GetServiceInfoResponse, + WindowsServiceStatus, + type CheckInstallTimeRequirementsResponse, } 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'; +import { statusOneOfIsWindowsServiceStatus } from 'teleterm/helpers'; import { cloneAbortSignal, isTshdRpcError } from 'teleterm/services/tshd'; import { hasReportFoundIssues } from 'teleterm/services/vnet/diag'; import { useAppContext } from 'teleterm/ui/appContextProvider'; @@ -84,6 +87,11 @@ export type VnetContext = { getDisabledDiagnosticsReason: ( runDiagnosticsAttempt: Attempt ) => string; + /** + * Status of install-time prerequisites (for example, VNet service presence) that can + * only be changed by reinstalling the app. + */ + installTimeRequirementsCheck: InstallTimeRequirementsCheck; /** * Dismisses the diagnostics alert shown in the VNet panel. It won't be shown again until the user * reinstates the alert by manually requesting diagnostics to be run from the VNet panel. @@ -169,6 +177,22 @@ export const VnetContextProvider: FC< ); const isSupported = platform === 'darwin' || platform === 'win32'; + const [checkInstallTimeRequirementsAttempt, checkInstallTimeRequirements] = + useAsync( + useCallback(async () => { + const { response } = await vnet.checkInstallTimeRequirements({}); + return response; + }, [vnet]) + ); + const installTimeRequirementsCheck = useMemo( + () => makeInstallTimeRequirements(checkInstallTimeRequirementsAttempt), + [checkInstallTimeRequirementsAttempt] + ); + + useEffect(() => { + checkInstallTimeRequirements(); + }, [checkInstallTimeRequirements]); + const [startAttempt, start] = useAsync( useCallback(async () => { const { didBackgroundItemRequireEnablement } = @@ -339,7 +363,8 @@ export const VnetContextProvider: FC< // Accessing resources through VNet might trigger the MFA modal, // so we have to wait for the tshd events service to be initialized. isWorkspaceStateInitialized && - startAttempt.status === '' + startAttempt.status === '' && + installTimeRequirementsCheck.status === 'success' ) { const [, error] = await start(); @@ -352,7 +377,7 @@ export const VnetContextProvider: FC< }; handleAutoStart(); - }, [isWorkspaceStateInitialized]); + }, [isWorkspaceStateInitialized, installTimeRequirementsCheck.status]); useEffect( function handleUnexpectedShutdown() { @@ -533,6 +558,7 @@ export const VnetContextProvider: FC< showDiagWarningIndicator, hasEverStarted, openSSHConfigurationModal, + installTimeRequirementsCheck, }} > {children} @@ -584,3 +610,72 @@ const checkDaemonBackgroundItemStatus = async ( ); return { didBackgroundItemRequireEnablement: true }; }; + +function makeInstallTimeRequirements( + attempt: Attempt +): InstallTimeRequirementsCheck { + switch (attempt.status) { + case '': + case 'processing': + return { status: 'unknown' }; + case 'error': + if (isTshdRpcError(attempt.error, 'UNIMPLEMENTED')) { + return { status: 'success' }; + } + return { + status: 'failed', + reason: { + kind: 'error', + statusText: attempt.statusText, + }, + }; + } + + const { status } = attempt.data; + if ( + statusOneOfIsWindowsServiceStatus(status) && + status.windowsServiceStatus === WindowsServiceStatus.DOES_NOT_EXIST + ) { + return { + status: 'failed', + reason: { + kind: 'missing-windows-service', + }, + }; + } + if ( + statusOneOfIsWindowsServiceStatus(status) && + status.windowsServiceStatus === WindowsServiceStatus.VERSION_MISMATCH + ) { + return { + status: 'failed', + reason: { + kind: 'windows-service-version-mismatch', + }, + }; + } + + return { status: 'success' }; +} + +type InstallTimeRequirementsCheck = + | { + status: 'unknown'; + } + | { + status: 'success'; + } + | { + status: 'failed'; + reason: + | { + kind: 'missing-windows-service'; + } + | { + kind: 'windows-service-version-mismatch'; + } + | { + kind: 'error'; + statusText: string; + }; + };