Connect: close cluster clients when profile changes (#61090)

* Include expiration time in `LoggedInUser`

This will allow the profile watcher to detect when the user relogged.

* Display expiration time in UI

* Add `ClearStaleClusterClients` RPC

* Implement `ClearStaleClusterClients`

* Clear stale clients when profile changes

* Improve session expiration component

* Move refresh button back to top

* `ClearCachedStaleClientsForRoot` -> `ClearStaleCachedClientsForRoot`

* `unchanged` -> `stale`

* Make "closing stale clients" a subtest

* Add `clientcache` test

* Remove `getProfile` error wrapping

* Improve comment

* Convert story to controls
This commit is contained in:
Grzegorz Zdunek
2025-11-17 11:02:40 +00:00
committed by GitHub
parent 1032b8eafd
commit 6615e42ecc
23 changed files with 1394 additions and 662 deletions
+7 -1
View File
@@ -177,7 +177,7 @@ func (p *Profile) TLSConfig() (*tls.Config, error) {
// Expiry returns the credential expiry.
func (p *Profile) Expiry() (time.Time, bool) {
certPEMBlock, err := os.ReadFile(p.TLSCertPath())
certPEMBlock, err := p.TLSCert()
if err != nil {
return time.Time{}, false
}
@@ -188,6 +188,12 @@ func (p *Profile) Expiry() (time.Time, bool) {
return cert.NotAfter, true
}
// TLSCert returns the profile's TLS certificate.
func (p *Profile) TLSCert() ([]byte, error) {
certPEMBlock, err := os.ReadFile(p.TLSCertPath())
return certPEMBlock, trace.Wrap(err)
}
// RequireKubeLocalProxy returns true if this profile indicates a local proxy
// is required for kube access.
func (p *Profile) RequireKubeLocalProxy() bool {
@@ -27,6 +27,7 @@ import (
types "github.com/gravitational/teleport/api/types"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
@@ -328,8 +329,10 @@ type LoggedInUser struct {
IsDeviceTrusted bool `protobuf:"varint,9,opt,name=is_device_trusted,json=isDeviceTrusted,proto3" json:"is_device_trusted,omitempty"`
// Indicates whether access may be hindered by the lack of a trusted device.
TrustedDeviceRequirement types.TrustedDeviceRequirement `protobuf:"varint,10,opt,name=trusted_device_requirement,json=trustedDeviceRequirement,proto3,enum=types.TrustedDeviceRequirement" json:"trusted_device_requirement,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// Expiration time of the certificate.
ValidUntil *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=valid_until,json=validUntil,proto3" json:"valid_until,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LoggedInUser) Reset() {
@@ -425,6 +428,13 @@ func (x *LoggedInUser) GetTrustedDeviceRequirement() types.TrustedDeviceRequirem
return types.TrustedDeviceRequirement(0)
}
func (x *LoggedInUser) GetValidUntil() *timestamppb.Timestamp {
if x != nil {
return x.ValidUntil
}
return nil
}
// ACL is the access control list of the user
type ACL struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -756,7 +766,7 @@ var File_teleport_lib_teleterm_v1_cluster_proto protoreflect.FileDescriptor
const file_teleport_lib_teleterm_v1_cluster_proto_rawDesc = "" +
"\n" +
"&teleport/lib/teleterm/v1/cluster.proto\x12\x18teleport.lib.teleterm.v1\x1a6teleport/legacy/types/trusted_device_requirement.proto\"\xf8\x03\n" +
"&teleport/lib/teleterm/v1/cluster.proto\x12\x18teleport.lib.teleterm.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a6teleport/legacy/types/trusted_device_requirement.proto\"\xf8\x03\n" +
"\aCluster\x12\x10\n" +
"\x03uri\x18\x01 \x01(\tR\x03uri\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1d\n" +
@@ -771,7 +781,7 @@ const file_teleport_lib_teleterm_v1_cluster_proto_rawDesc = "" +
" \x01(\tR\fproxyVersion\x12N\n" +
"\x0eshow_resources\x18\v \x01(\x0e2'.teleport.lib.teleterm.v1.ShowResourcesR\rshowResources\x120\n" +
"\x14profile_status_error\x18\f \x01(\tR\x12profileStatusError\x12\x19\n" +
"\bsso_host\x18\r \x01(\tR\assoHost\"\xaa\x04\n" +
"\bsso_host\x18\r \x01(\tR\assoHost\"\xe7\x04\n" +
"\fLoggedInUser\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
"\x05roles\x18\x02 \x03(\tR\x05roles\x12/\n" +
@@ -782,7 +792,9 @@ const file_teleport_lib_teleterm_v1_cluster_proto_rawDesc = "" +
"\tuser_type\x18\b \x01(\x0e2/.teleport.lib.teleterm.v1.LoggedInUser.UserTypeR\buserType\x12*\n" +
"\x11is_device_trusted\x18\t \x01(\bR\x0fisDeviceTrusted\x12]\n" +
"\x1atrusted_device_requirement\x18\n" +
" \x01(\x0e2\x1f.types.TrustedDeviceRequirementR\x18trustedDeviceRequirement\"M\n" +
" \x01(\x0e2\x1f.types.TrustedDeviceRequirementR\x18trustedDeviceRequirement\x12;\n" +
"\vvalid_until\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"validUntil\"M\n" +
"\bUserType\x12\x19\n" +
"\x15USER_TYPE_UNSPECIFIED\x10\x00\x12\x13\n" +
"\x0fUSER_TYPE_LOCAL\x10\x01\x12\x11\n" +
@@ -844,6 +856,7 @@ var file_teleport_lib_teleterm_v1_cluster_proto_goTypes = []any{
(*ResourceAccess)(nil), // 5: teleport.lib.teleterm.v1.ResourceAccess
(*Features)(nil), // 6: teleport.lib.teleterm.v1.Features
(types.TrustedDeviceRequirement)(0), // 7: types.TrustedDeviceRequirement
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
}
var file_teleport_lib_teleterm_v1_cluster_proto_depIdxs = []int32{
3, // 0: teleport.lib.teleterm.v1.Cluster.logged_in_user:type_name -> teleport.lib.teleterm.v1.LoggedInUser
@@ -852,24 +865,25 @@ var file_teleport_lib_teleterm_v1_cluster_proto_depIdxs = []int32{
4, // 3: teleport.lib.teleterm.v1.LoggedInUser.acl:type_name -> teleport.lib.teleterm.v1.ACL
1, // 4: teleport.lib.teleterm.v1.LoggedInUser.user_type:type_name -> teleport.lib.teleterm.v1.LoggedInUser.UserType
7, // 5: teleport.lib.teleterm.v1.LoggedInUser.trusted_device_requirement:type_name -> types.TrustedDeviceRequirement
5, // 6: teleport.lib.teleterm.v1.ACL.auth_connectors:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 7: teleport.lib.teleterm.v1.ACL.roles:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 8: teleport.lib.teleterm.v1.ACL.users:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 9: teleport.lib.teleterm.v1.ACL.trusted_clusters:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 10: teleport.lib.teleterm.v1.ACL.events:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 11: teleport.lib.teleterm.v1.ACL.tokens:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 12: teleport.lib.teleterm.v1.ACL.servers:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 13: teleport.lib.teleterm.v1.ACL.apps:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 14: teleport.lib.teleterm.v1.ACL.dbs:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 15: teleport.lib.teleterm.v1.ACL.kubeservers:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 16: teleport.lib.teleterm.v1.ACL.access_requests:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 17: teleport.lib.teleterm.v1.ACL.recorded_sessions:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 18: teleport.lib.teleterm.v1.ACL.active_sessions:type_name -> teleport.lib.teleterm.v1.ResourceAccess
19, // [19:19] is the sub-list for method output_type
19, // [19:19] is the sub-list for method input_type
19, // [19:19] is the sub-list for extension type_name
19, // [19:19] is the sub-list for extension extendee
0, // [0:19] is the sub-list for field type_name
8, // 6: teleport.lib.teleterm.v1.LoggedInUser.valid_until:type_name -> google.protobuf.Timestamp
5, // 7: teleport.lib.teleterm.v1.ACL.auth_connectors:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 8: teleport.lib.teleterm.v1.ACL.roles:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 9: teleport.lib.teleterm.v1.ACL.users:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 10: teleport.lib.teleterm.v1.ACL.trusted_clusters:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 11: teleport.lib.teleterm.v1.ACL.events:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 12: teleport.lib.teleterm.v1.ACL.tokens:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 13: teleport.lib.teleterm.v1.ACL.servers:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 14: teleport.lib.teleterm.v1.ACL.apps:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 15: teleport.lib.teleterm.v1.ACL.dbs:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 16: teleport.lib.teleterm.v1.ACL.kubeservers:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 17: teleport.lib.teleterm.v1.ACL.access_requests:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 18: teleport.lib.teleterm.v1.ACL.recorded_sessions:type_name -> teleport.lib.teleterm.v1.ResourceAccess
5, // 19: teleport.lib.teleterm.v1.ACL.active_sessions:type_name -> teleport.lib.teleterm.v1.ResourceAccess
20, // [20:20] is the sub-list for method output_type
20, // [20:20] is the sub-list for method input_type
20, // [20:20] is the sub-list for extension type_name
20, // [20:20] is the sub-list for extension extendee
0, // [0:20] is the sub-list for field type_name
}
func init() { file_teleport_lib_teleterm_v1_cluster_proto_init() }
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,7 @@ const (
TerminalService_Login_FullMethodName = "/teleport.lib.teleterm.v1.TerminalService/Login"
TerminalService_LoginPasswordless_FullMethodName = "/teleport.lib.teleterm.v1.TerminalService/LoginPasswordless"
TerminalService_Logout_FullMethodName = "/teleport.lib.teleterm.v1.TerminalService/Logout"
TerminalService_ClearStaleClusterClients_FullMethodName = "/teleport.lib.teleterm.v1.TerminalService/ClearStaleClusterClients"
TerminalService_TransferFile_FullMethodName = "/teleport.lib.teleterm.v1.TerminalService/TransferFile"
TerminalService_ReportUsageEvent_FullMethodName = "/teleport.lib.teleterm.v1.TerminalService/ReportUsageEvent"
TerminalService_UpdateHeadlessAuthenticationState_FullMethodName = "/teleport.lib.teleterm.v1.TerminalService/UpdateHeadlessAuthenticationState"
@@ -178,6 +179,8 @@ type TerminalServiceClient interface {
// Optionally removes the profile.
// This operation is idempotent and can be safely invoked multiple times.
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*EmptyResponse, error)
// Closes root and leaf cluster clients that use outdated TLS certificates.
ClearStaleClusterClients(ctx context.Context, in *ClearStaleClusterClientsRequest, opts ...grpc.CallOption) (*ClearStaleClusterClientsResponse, error)
// TransferFile sends a request to download/upload a file
TransferFile(ctx context.Context, in *FileTransferRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileTransferProgress], error)
// ReportUsageEvent allows to send usage events that are then anonymized and forwarded to prehog
@@ -520,6 +523,16 @@ func (c *terminalServiceClient) Logout(ctx context.Context, in *LogoutRequest, o
return out, nil
}
func (c *terminalServiceClient) ClearStaleClusterClients(ctx context.Context, in *ClearStaleClusterClientsRequest, opts ...grpc.CallOption) (*ClearStaleClusterClientsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ClearStaleClusterClientsResponse)
err := c.cc.Invoke(ctx, TerminalService_ClearStaleClusterClients_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *terminalServiceClient) TransferFile(ctx context.Context, in *FileTransferRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileTransferProgress], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &TerminalService_ServiceDesc.Streams[1], TerminalService_TransferFile_FullMethodName, cOpts...)
@@ -779,6 +792,8 @@ type TerminalServiceServer interface {
// Optionally removes the profile.
// This operation is idempotent and can be safely invoked multiple times.
Logout(context.Context, *LogoutRequest) (*EmptyResponse, error)
// Closes root and leaf cluster clients that use outdated TLS certificates.
ClearStaleClusterClients(context.Context, *ClearStaleClusterClientsRequest) (*ClearStaleClusterClientsResponse, error)
// TransferFile sends a request to download/upload a file
TransferFile(*FileTransferRequest, grpc.ServerStreamingServer[FileTransferProgress]) error
// ReportUsageEvent allows to send usage events that are then anonymized and forwarded to prehog
@@ -922,6 +937,9 @@ func (UnimplementedTerminalServiceServer) LoginPasswordless(grpc.BidiStreamingSe
func (UnimplementedTerminalServiceServer) Logout(context.Context, *LogoutRequest) (*EmptyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented")
}
func (UnimplementedTerminalServiceServer) ClearStaleClusterClients(context.Context, *ClearStaleClusterClientsRequest) (*ClearStaleClusterClientsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ClearStaleClusterClients not implemented")
}
func (UnimplementedTerminalServiceServer) TransferFile(*FileTransferRequest, grpc.ServerStreamingServer[FileTransferProgress]) error {
return status.Errorf(codes.Unimplemented, "method TransferFile not implemented")
}
@@ -1481,6 +1499,24 @@ func _TerminalService_Logout_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _TerminalService_ClearStaleClusterClients_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ClearStaleClusterClientsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TerminalServiceServer).ClearStaleClusterClients(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TerminalService_ClearStaleClusterClients_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TerminalServiceServer).ClearStaleClusterClients(ctx, req.(*ClearStaleClusterClientsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _TerminalService_TransferFile_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(FileTransferRequest)
if err := stream.RecvMsg(m); err != nil {
@@ -1848,6 +1884,10 @@ var TerminalService_ServiceDesc = grpc.ServiceDesc{
MethodName: "Logout",
Handler: _TerminalService_Logout_Handler,
},
{
MethodName: "ClearStaleClusterClients",
Handler: _TerminalService_ClearStaleClusterClients_Handler,
},
{
MethodName: "ReportUsageEvent",
Handler: _TerminalService_ReportUsageEvent_Handler,
+15 -1
View File
@@ -30,6 +30,7 @@ import { UnknownFieldHandler } from "@protobuf-ts/runtime";
import type { PartialMessage } from "@protobuf-ts/runtime";
import { reflectionMergePartial } from "@protobuf-ts/runtime";
import { MessageType } from "@protobuf-ts/runtime";
import { Timestamp } from "../../../../google/protobuf/timestamp_pb";
import { TrustedDeviceRequirement } from "../../../legacy/types/trusted_device_requirement_pb";
/**
* Cluster describes cluster fields.
@@ -191,6 +192,12 @@ export interface LoggedInUser {
* @generated from protobuf field: types.TrustedDeviceRequirement trusted_device_requirement = 10;
*/
trustedDeviceRequirement: TrustedDeviceRequirement;
/**
* Expiration time of the certificate.
*
* @generated from protobuf field: google.protobuf.Timestamp valid_until = 11;
*/
validUntil?: Timestamp;
}
/**
* UserType indicates whether the user was created through an SSO provider or in Teleport itself.
@@ -541,7 +548,8 @@ class LoggedInUser$Type extends MessageType<LoggedInUser> {
{ no: 7, name: "requestable_roles", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ },
{ no: 8, name: "user_type", kind: "enum", T: () => ["teleport.lib.teleterm.v1.LoggedInUser.UserType", LoggedInUser_UserType, "USER_TYPE_"] },
{ no: 9, name: "is_device_trusted", kind: "scalar", T: 8 /*ScalarType.BOOL*/ },
{ no: 10, name: "trusted_device_requirement", kind: "enum", T: () => ["types.TrustedDeviceRequirement", TrustedDeviceRequirement, "TRUSTED_DEVICE_REQUIREMENT_"] }
{ no: 10, name: "trusted_device_requirement", kind: "enum", T: () => ["types.TrustedDeviceRequirement", TrustedDeviceRequirement, "TRUSTED_DEVICE_REQUIREMENT_"] },
{ no: 11, name: "valid_until", kind: "message", T: () => Timestamp }
]);
}
create(value?: PartialMessage<LoggedInUser>): LoggedInUser {
@@ -590,6 +598,9 @@ class LoggedInUser$Type extends MessageType<LoggedInUser> {
case /* types.TrustedDeviceRequirement trusted_device_requirement */ 10:
message.trustedDeviceRequirement = reader.int32();
break;
case /* google.protobuf.Timestamp valid_until */ 11:
message.validUntil = Timestamp.internalBinaryRead(reader, reader.uint32(), options, message.validUntil);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -629,6 +640,9 @@ class LoggedInUser$Type extends MessageType<LoggedInUser> {
/* types.TrustedDeviceRequirement trusted_device_requirement = 10; */
if (message.trustedDeviceRequirement !== 0)
writer.tag(10, WireType.Varint).int32(message.trustedDeviceRequirement);
/* google.protobuf.Timestamp valid_until = 11; */
if (message.validUntil)
Timestamp.internalBinaryWrite(message.validUntil, writer.tag(11, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
+32 -15
View File
@@ -54,6 +54,8 @@ import type { ReportUsageEventRequest } from "./usage_events_pb";
import type { FileTransferProgress } from "./service_pb";
import type { FileTransferRequest } from "./service_pb";
import type { ServerStreamingCall } from "@protobuf-ts/runtime-rpc";
import type { ClearStaleClusterClientsResponse } from "./service_pb";
import type { ClearStaleClusterClientsRequest } from "./service_pb";
import type { LogoutRequest } from "./service_pb";
import type { LoginPasswordlessResponse } from "./service_pb";
import type { LoginPasswordlessRequest } from "./service_pb";
@@ -315,6 +317,12 @@ export interface ITerminalServiceClient {
* @generated from protobuf rpc: Logout(teleport.lib.teleterm.v1.LogoutRequest) returns (teleport.lib.teleterm.v1.EmptyResponse);
*/
logout(input: LogoutRequest, options?: RpcOptions): UnaryCall<LogoutRequest, EmptyResponse>;
/**
* Closes root and leaf cluster clients that use outdated TLS certificates.
*
* @generated from protobuf rpc: ClearStaleClusterClients(teleport.lib.teleterm.v1.ClearStaleClusterClientsRequest) returns (teleport.lib.teleterm.v1.ClearStaleClusterClientsResponse);
*/
clearStaleClusterClients(input: ClearStaleClusterClientsRequest, options?: RpcOptions): UnaryCall<ClearStaleClusterClientsRequest, ClearStaleClusterClientsResponse>;
/**
* TransferFile sends a request to download/upload a file
*
@@ -723,13 +731,22 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
const method = this.methods[27], opt = this._transport.mergeOptions(options);
return stackIntercept<LogoutRequest, EmptyResponse>("unary", this._transport, method, opt, input);
}
/**
* Closes root and leaf cluster clients that use outdated TLS certificates.
*
* @generated from protobuf rpc: ClearStaleClusterClients(teleport.lib.teleterm.v1.ClearStaleClusterClientsRequest) returns (teleport.lib.teleterm.v1.ClearStaleClusterClientsResponse);
*/
clearStaleClusterClients(input: ClearStaleClusterClientsRequest, options?: RpcOptions): UnaryCall<ClearStaleClusterClientsRequest, ClearStaleClusterClientsResponse> {
const method = this.methods[28], opt = this._transport.mergeOptions(options);
return stackIntercept<ClearStaleClusterClientsRequest, ClearStaleClusterClientsResponse>("unary", this._transport, method, opt, input);
}
/**
* TransferFile sends a request to download/upload a file
*
* @generated from protobuf rpc: TransferFile(teleport.lib.teleterm.v1.FileTransferRequest) returns (stream teleport.lib.teleterm.v1.FileTransferProgress);
*/
transferFile(input: FileTransferRequest, options?: RpcOptions): ServerStreamingCall<FileTransferRequest, FileTransferProgress> {
const method = this.methods[28], opt = this._transport.mergeOptions(options);
const method = this.methods[29], opt = this._transport.mergeOptions(options);
return stackIntercept<FileTransferRequest, FileTransferProgress>("serverStreaming", this._transport, method, opt, input);
}
/**
@@ -738,7 +755,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: ReportUsageEvent(teleport.lib.teleterm.v1.ReportUsageEventRequest) returns (teleport.lib.teleterm.v1.EmptyResponse);
*/
reportUsageEvent(input: ReportUsageEventRequest, options?: RpcOptions): UnaryCall<ReportUsageEventRequest, EmptyResponse> {
const method = this.methods[29], opt = this._transport.mergeOptions(options);
const method = this.methods[30], opt = this._transport.mergeOptions(options);
return stackIntercept<ReportUsageEventRequest, EmptyResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -748,7 +765,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: UpdateHeadlessAuthenticationState(teleport.lib.teleterm.v1.UpdateHeadlessAuthenticationStateRequest) returns (teleport.lib.teleterm.v1.UpdateHeadlessAuthenticationStateResponse);
*/
updateHeadlessAuthenticationState(input: UpdateHeadlessAuthenticationStateRequest, options?: RpcOptions): UnaryCall<UpdateHeadlessAuthenticationStateRequest, UpdateHeadlessAuthenticationStateResponse> {
const method = this.methods[30], opt = this._transport.mergeOptions(options);
const method = this.methods[31], opt = this._transport.mergeOptions(options);
return stackIntercept<UpdateHeadlessAuthenticationStateRequest, UpdateHeadlessAuthenticationStateResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -759,7 +776,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: CreateConnectMyComputerRole(teleport.lib.teleterm.v1.CreateConnectMyComputerRoleRequest) returns (teleport.lib.teleterm.v1.CreateConnectMyComputerRoleResponse);
*/
createConnectMyComputerRole(input: CreateConnectMyComputerRoleRequest, options?: RpcOptions): UnaryCall<CreateConnectMyComputerRoleRequest, CreateConnectMyComputerRoleResponse> {
const method = this.methods[31], opt = this._transport.mergeOptions(options);
const method = this.methods[32], opt = this._transport.mergeOptions(options);
return stackIntercept<CreateConnectMyComputerRoleRequest, CreateConnectMyComputerRoleResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -768,7 +785,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: CreateConnectMyComputerNodeToken(teleport.lib.teleterm.v1.CreateConnectMyComputerNodeTokenRequest) returns (teleport.lib.teleterm.v1.CreateConnectMyComputerNodeTokenResponse);
*/
createConnectMyComputerNodeToken(input: CreateConnectMyComputerNodeTokenRequest, options?: RpcOptions): UnaryCall<CreateConnectMyComputerNodeTokenRequest, CreateConnectMyComputerNodeTokenResponse> {
const method = this.methods[32], opt = this._transport.mergeOptions(options);
const method = this.methods[33], opt = this._transport.mergeOptions(options);
return stackIntercept<CreateConnectMyComputerNodeTokenRequest, CreateConnectMyComputerNodeTokenResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -782,7 +799,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: WaitForConnectMyComputerNodeJoin(teleport.lib.teleterm.v1.WaitForConnectMyComputerNodeJoinRequest) returns (teleport.lib.teleterm.v1.WaitForConnectMyComputerNodeJoinResponse);
*/
waitForConnectMyComputerNodeJoin(input: WaitForConnectMyComputerNodeJoinRequest, options?: RpcOptions): UnaryCall<WaitForConnectMyComputerNodeJoinRequest, WaitForConnectMyComputerNodeJoinResponse> {
const method = this.methods[33], opt = this._transport.mergeOptions(options);
const method = this.methods[34], opt = this._transport.mergeOptions(options);
return stackIntercept<WaitForConnectMyComputerNodeJoinRequest, WaitForConnectMyComputerNodeJoinResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -791,7 +808,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: DeleteConnectMyComputerNode(teleport.lib.teleterm.v1.DeleteConnectMyComputerNodeRequest) returns (teleport.lib.teleterm.v1.DeleteConnectMyComputerNodeResponse);
*/
deleteConnectMyComputerNode(input: DeleteConnectMyComputerNodeRequest, options?: RpcOptions): UnaryCall<DeleteConnectMyComputerNodeRequest, DeleteConnectMyComputerNodeResponse> {
const method = this.methods[34], opt = this._transport.mergeOptions(options);
const method = this.methods[35], opt = this._transport.mergeOptions(options);
return stackIntercept<DeleteConnectMyComputerNodeRequest, DeleteConnectMyComputerNodeResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -800,7 +817,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: GetConnectMyComputerNodeName(teleport.lib.teleterm.v1.GetConnectMyComputerNodeNameRequest) returns (teleport.lib.teleterm.v1.GetConnectMyComputerNodeNameResponse);
*/
getConnectMyComputerNodeName(input: GetConnectMyComputerNodeNameRequest, options?: RpcOptions): UnaryCall<GetConnectMyComputerNodeNameRequest, GetConnectMyComputerNodeNameResponse> {
const method = this.methods[35], opt = this._transport.mergeOptions(options);
const method = this.methods[36], opt = this._transport.mergeOptions(options);
return stackIntercept<GetConnectMyComputerNodeNameRequest, GetConnectMyComputerNodeNameResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -809,7 +826,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: ListUnifiedResources(teleport.lib.teleterm.v1.ListUnifiedResourcesRequest) returns (teleport.lib.teleterm.v1.ListUnifiedResourcesResponse);
*/
listUnifiedResources(input: ListUnifiedResourcesRequest, options?: RpcOptions): UnaryCall<ListUnifiedResourcesRequest, ListUnifiedResourcesResponse> {
const method = this.methods[36], opt = this._transport.mergeOptions(options);
const method = this.methods[37], opt = this._transport.mergeOptions(options);
return stackIntercept<ListUnifiedResourcesRequest, ListUnifiedResourcesResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -818,7 +835,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: GetUserPreferences(teleport.lib.teleterm.v1.GetUserPreferencesRequest) returns (teleport.lib.teleterm.v1.GetUserPreferencesResponse);
*/
getUserPreferences(input: GetUserPreferencesRequest, options?: RpcOptions): UnaryCall<GetUserPreferencesRequest, GetUserPreferencesResponse> {
const method = this.methods[37], opt = this._transport.mergeOptions(options);
const method = this.methods[38], opt = this._transport.mergeOptions(options);
return stackIntercept<GetUserPreferencesRequest, GetUserPreferencesResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -828,7 +845,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: UpdateUserPreferences(teleport.lib.teleterm.v1.UpdateUserPreferencesRequest) returns (teleport.lib.teleterm.v1.UpdateUserPreferencesResponse);
*/
updateUserPreferences(input: UpdateUserPreferencesRequest, options?: RpcOptions): UnaryCall<UpdateUserPreferencesRequest, UpdateUserPreferencesResponse> {
const method = this.methods[38], opt = this._transport.mergeOptions(options);
const method = this.methods[39], opt = this._transport.mergeOptions(options);
return stackIntercept<UpdateUserPreferencesRequest, UpdateUserPreferencesResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -841,7 +858,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: AuthenticateWebDevice(teleport.lib.teleterm.v1.AuthenticateWebDeviceRequest) returns (teleport.lib.teleterm.v1.AuthenticateWebDeviceResponse);
*/
authenticateWebDevice(input: AuthenticateWebDeviceRequest, options?: RpcOptions): UnaryCall<AuthenticateWebDeviceRequest, AuthenticateWebDeviceResponse> {
const method = this.methods[39], opt = this._transport.mergeOptions(options);
const method = this.methods[40], opt = this._transport.mergeOptions(options);
return stackIntercept<AuthenticateWebDeviceRequest, AuthenticateWebDeviceResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -851,7 +868,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: GetApp(teleport.lib.teleterm.v1.GetAppRequest) returns (teleport.lib.teleterm.v1.GetAppResponse);
*/
getApp(input: GetAppRequest, options?: RpcOptions): UnaryCall<GetAppRequest, GetAppResponse> {
const method = this.methods[40], opt = this._transport.mergeOptions(options);
const method = this.methods[41], opt = this._transport.mergeOptions(options);
return stackIntercept<GetAppRequest, GetAppResponse>("unary", this._transport, method, opt, input);
}
/**
@@ -860,7 +877,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: ConnectToDesktop(stream teleport.lib.teleterm.v1.ConnectToDesktopRequest) returns (stream teleport.lib.teleterm.v1.ConnectToDesktopResponse);
*/
connectToDesktop(options?: RpcOptions): DuplexStreamingCall<ConnectToDesktopRequest, ConnectToDesktopResponse> {
const method = this.methods[41], opt = this._transport.mergeOptions(options);
const method = this.methods[42], opt = this._transport.mergeOptions(options);
return stackIntercept<ConnectToDesktopRequest, ConnectToDesktopResponse>("duplex", this._transport, method, opt);
}
/**
@@ -874,7 +891,7 @@ export class TerminalServiceClient implements ITerminalServiceClient, ServiceInf
* @generated from protobuf rpc: SetSharedDirectoryForDesktopSession(teleport.lib.teleterm.v1.SetSharedDirectoryForDesktopSessionRequest) returns (teleport.lib.teleterm.v1.SetSharedDirectoryForDesktopSessionResponse);
*/
setSharedDirectoryForDesktopSession(input: SetSharedDirectoryForDesktopSessionRequest, options?: RpcOptions): UnaryCall<SetSharedDirectoryForDesktopSessionRequest, SetSharedDirectoryForDesktopSessionResponse> {
const method = this.methods[42], opt = this._transport.mergeOptions(options);
const method = this.methods[43], opt = this._transport.mergeOptions(options);
return stackIntercept<SetSharedDirectoryForDesktopSessionRequest, SetSharedDirectoryForDesktopSessionResponse>("unary", this._transport, method, opt, input);
}
}
@@ -50,6 +50,8 @@ import { UpdateHeadlessAuthenticationStateRequest } from "./service_pb";
import { ReportUsageEventRequest } from "./usage_events_pb";
import { FileTransferProgress } from "./service_pb";
import { FileTransferRequest } from "./service_pb";
import { ClearStaleClusterClientsResponse } from "./service_pb";
import { ClearStaleClusterClientsRequest } from "./service_pb";
import { LogoutRequest } from "./service_pb";
import { LoginPasswordlessResponse } from "./service_pb";
import { LoginPasswordlessRequest } from "./service_pb";
@@ -308,6 +310,12 @@ export interface ITerminalService extends grpc.UntypedServiceImplementation {
* @generated from protobuf rpc: Logout(teleport.lib.teleterm.v1.LogoutRequest) returns (teleport.lib.teleterm.v1.EmptyResponse);
*/
logout: grpc.handleUnaryCall<LogoutRequest, EmptyResponse>;
/**
* Closes root and leaf cluster clients that use outdated TLS certificates.
*
* @generated from protobuf rpc: ClearStaleClusterClients(teleport.lib.teleterm.v1.ClearStaleClusterClientsRequest) returns (teleport.lib.teleterm.v1.ClearStaleClusterClientsResponse);
*/
clearStaleClusterClients: grpc.handleUnaryCall<ClearStaleClusterClientsRequest, ClearStaleClusterClientsResponse>;
/**
* TransferFile sends a request to download/upload a file
*
@@ -710,6 +718,16 @@ export const terminalServiceDefinition: grpc.ServiceDefinition<ITerminalService>
responseSerialize: value => Buffer.from(EmptyResponse.toBinary(value)),
requestSerialize: value => Buffer.from(LogoutRequest.toBinary(value))
},
clearStaleClusterClients: {
path: "/teleport.lib.teleterm.v1.TerminalService/ClearStaleClusterClients",
originalName: "ClearStaleClusterClients",
requestStream: false,
responseStream: false,
responseDeserialize: bytes => ClearStaleClusterClientsResponse.fromBinary(bytes),
requestDeserialize: bytes => ClearStaleClusterClientsRequest.fromBinary(bytes),
responseSerialize: value => Buffer.from(ClearStaleClusterClientsResponse.toBinary(value)),
requestSerialize: value => Buffer.from(ClearStaleClusterClientsRequest.toBinary(value))
},
transferFile: {
path: "/teleport.lib.teleterm.v1.TerminalService/TransferFile",
originalName: "TransferFile",
+87
View File
@@ -82,6 +82,20 @@ export interface LogoutRequest {
*/
removeProfile: boolean;
}
/**
* @generated from protobuf message teleport.lib.teleterm.v1.ClearStaleClusterClientsRequest
*/
export interface ClearStaleClusterClientsRequest {
/**
* @generated from protobuf field: string root_cluster_uri = 1;
*/
rootClusterUri: string;
}
/**
* @generated from protobuf message teleport.lib.teleterm.v1.ClearStaleClusterClientsResponse
*/
export interface ClearStaleClusterClientsResponse {
}
/**
* @generated from protobuf message teleport.lib.teleterm.v1.StartHeadlessWatcherRequest
*/
@@ -1537,6 +1551,78 @@ class LogoutRequest$Type extends MessageType<LogoutRequest> {
*/
export const LogoutRequest = new LogoutRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class ClearStaleClusterClientsRequest$Type extends MessageType<ClearStaleClusterClientsRequest> {
constructor() {
super("teleport.lib.teleterm.v1.ClearStaleClusterClientsRequest", [
{ no: 1, name: "root_cluster_uri", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<ClearStaleClusterClientsRequest>): ClearStaleClusterClientsRequest {
const message = globalThis.Object.create((this.messagePrototype!));
message.rootClusterUri = "";
if (value !== undefined)
reflectionMergePartial<ClearStaleClusterClientsRequest>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ClearStaleClusterClientsRequest): ClearStaleClusterClientsRequest {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string root_cluster_uri */ 1:
message.rootClusterUri = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: ClearStaleClusterClientsRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string root_cluster_uri = 1; */
if (message.rootClusterUri !== "")
writer.tag(1, WireType.LengthDelimited).string(message.rootClusterUri);
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.v1.ClearStaleClusterClientsRequest
*/
export const ClearStaleClusterClientsRequest = new ClearStaleClusterClientsRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class ClearStaleClusterClientsResponse$Type extends MessageType<ClearStaleClusterClientsResponse> {
constructor() {
super("teleport.lib.teleterm.v1.ClearStaleClusterClientsResponse", []);
}
create(value?: PartialMessage<ClearStaleClusterClientsResponse>): ClearStaleClusterClientsResponse {
const message = globalThis.Object.create((this.messagePrototype!));
if (value !== undefined)
reflectionMergePartial<ClearStaleClusterClientsResponse>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ClearStaleClusterClientsResponse): ClearStaleClusterClientsResponse {
return target ?? this.create();
}
internalBinaryWrite(message: ClearStaleClusterClientsResponse, 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.v1.ClearStaleClusterClientsResponse
*/
export const ClearStaleClusterClientsResponse = new ClearStaleClusterClientsResponse$Type();
// @generated message type with reflection information, may provide speed optimized methods
class StartHeadlessWatcherRequest$Type extends MessageType<StartHeadlessWatcherRequest> {
constructor() {
super("teleport.lib.teleterm.v1.StartHeadlessWatcherRequest", [
@@ -5879,6 +5965,7 @@ export const TerminalService = new ServiceType("teleport.lib.teleterm.v1.Termina
{ name: "Login", options: {}, I: LoginRequest, O: EmptyResponse },
{ name: "LoginPasswordless", serverStreaming: true, clientStreaming: true, options: {}, I: LoginPasswordlessRequest, O: LoginPasswordlessResponse },
{ name: "Logout", options: {}, I: LogoutRequest, O: EmptyResponse },
{ name: "ClearStaleClusterClients", options: {}, I: ClearStaleClusterClientsRequest, O: ClearStaleClusterClientsResponse },
{ name: "TransferFile", serverStreaming: true, options: {}, I: FileTransferRequest, O: FileTransferProgress },
{ name: "ReportUsageEvent", options: {}, I: ReportUsageEventRequest, O: EmptyResponse },
{ name: "UpdateHeadlessAuthenticationState", options: {}, I: UpdateHeadlessAuthenticationStateRequest, O: UpdateHeadlessAuthenticationStateResponse },
+62
View File
@@ -134,6 +134,12 @@ func TestTeleterm(t *testing.T) {
testClientCache(t, pack, creds)
})
t.Run("clearing stale cached clients", func(t *testing.T) {
t.Parallel()
testClearingStaleCachedClients(t, pack, creds)
})
t.Run("logging out", func(t *testing.T) {
t.Parallel()
testLogout(t, pack, creds)
@@ -554,6 +560,62 @@ func testClientCache(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.
require.NotEqual(t, secondCallForClient, thirdCallForClient)
}
func testClearingStaleCachedClients(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
ctx := context.Background()
tc := mustLogin(t, pack.Root.User.GetName(), pack, creds)
storageFakeClock := clockwork.NewFakeClockAt(time.Now())
storage, err := clusters.NewStorage(clusters.Config{
ClientStore: tc.ClientStore,
Clock: storageFakeClock,
InsecureSkipVerify: tc.InsecureSkipVerify,
})
require.NoError(t, err)
cluster, _, err := storage.Add(ctx, tc.WebProxyAddr)
require.NoError(t, err)
tshdEventsClient := daemon.NewTshdEventsClient(func() (grpc.DialOption, error) {
return grpc.WithTransportCredentials(insecure.NewCredentials()), nil
})
daemonService, err := daemon.New(daemon.Config{
Storage: storage,
TshdEventsClient: tshdEventsClient,
KubeconfigsDir: t.TempDir(),
AgentsDir: t.TempDir(),
})
require.NoError(t, err)
t.Cleanup(func() {
daemonService.Stop()
})
firstCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
require.NoError(t, err)
err = daemonService.ClearStaleCachedClientsForRoot(cluster.URI)
require.NoError(t, err)
// Ensure the client wasn't closed.
secondCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
require.NoError(t, err)
require.Equal(t, firstCallForClient, secondCallForClient)
// Reissue user certs by assuming a role with a bogus ID in DropAccessRequests.
accessRequest := &api.AssumeRoleRequest{
RootClusterUri: cluster.URI.String(),
DropRequestIds: []string{"does-not-matter"},
}
err = cluster.AssumeRole(ctx, firstCallForClient, accessRequest)
require.NoError(t, err)
// The cert has changed, so after clearing stale clients,
// GetCachedClient should return a new client.
err = daemonService.ClearStaleCachedClientsForRoot(cluster.URI)
require.NoError(t, err)
thirdCallForClient, err := daemonService.GetCachedClient(ctx, cluster.URI)
require.NoError(t, err)
require.NotEqual(t, secondCallForClient, thirdCallForClient)
}
func testLogout(t *testing.T, pack *dbhelpers.DatabasePack, creds *helpers.UserCreds) {
ctx := context.Background()
+91 -18
View File
@@ -17,6 +17,7 @@
package clientcache
import (
"bytes"
"context"
"log/slog"
"slices"
@@ -35,12 +36,47 @@ import (
type Cache struct {
cfg Config
mu sync.RWMutex
// clients keeps a mapping from key (profile name and leaf cluster name) to cluster client.
clients map[key]*client.ClusterClient
// clients keeps a mapping from key (profile name and leaf cluster name) to client.
clients map[key]*clientWithMetadata
// group prevents duplicate requests to create clients for a given cluster.
group singleflight.Group
}
type clientWithMetadata struct {
client *client.ClusterClient
// tlsCert is the certificate that was loaded when the client was created.
tlsCert []byte
// getProfile reads the fresh profile for the client from disk.
getProfile func() (profile, error)
}
type profile interface {
// TLSCert returns the current TLS cert, stored in the agent.
TLSCert() ([]byte, error)
}
// isTLSCertStale checks if the cached client uses the current TLS cert
// (read from the agent). If not, the TLS cert is considered stale.
func (c *clientWithMetadata) isTLSCertStale() (bool, error) {
pr, err := c.getProfile()
if err != nil {
if trace.IsNotFound(err) {
return true, nil
}
return false, trace.Wrap(err)
}
tlsCert, err := pr.TLSCert()
if err != nil {
if trace.IsNotFound(err) {
return true, nil
}
return false, trace.Wrap(err)
}
return !bytes.Equal(c.tlsCert, tlsCert), nil
}
// NewClientFunc is a function that will return a new [*client.TeleportClient] for a given profile and leaf
// cluster. [leafClusterName] may be empty, in which case implementations should return a client for the root cluster.
type NewClientFunc func(ctx context.Context, profileName, leafClusterName string) (*client.TeleportClient, error)
@@ -89,7 +125,7 @@ func New(c Config) (*Cache, error) {
return &Cache{
cfg: c,
clients: make(map[key]*client.ClusterClient),
clients: make(map[key]*clientWithMetadata),
}, nil
}
@@ -100,7 +136,7 @@ func (c *Cache) Get(ctx context.Context, profileName, leafClusterName string) (*
groupClt, err, _ := c.group.Do(k.String(), func() (any, error) {
if fromCache := c.getFromCache(k); fromCache != nil {
c.cfg.Logger.DebugContext(ctx, "Retrieved client from cache", "cluster", k)
return fromCache, nil
return fromCache.client, nil
}
tc, err := c.cfg.NewClientFunc(ctx, profileName, leafClusterName)
@@ -120,8 +156,19 @@ func (c *Cache) Get(ctx context.Context, profileName, leafClusterName string) (*
return nil, trace.Wrap(err)
}
keyRing, err := tc.LocalAgent().GetCoreKeyRing()
if err != nil {
return nil, trace.Wrap(err)
}
// Save the client in the cache, so we don't have to build a new connection next time.
c.addToCache(k, newClient)
c.addToCache(k, &clientWithMetadata{
client: newClient,
tlsCert: keyRing.TLSCert,
getProfile: func() (profile, error) {
return tc.GetProfile(tc.WebProxyAddr)
},
})
c.cfg.Logger.InfoContext(ctx, "Added client to cache", "cluster", k)
@@ -139,8 +186,25 @@ func (c *Cache) Get(ctx context.Context, profileName, leafClusterName string) (*
return clt, nil
}
// ClearOption configures ClearForRoot behavior.
type ClearOption func(*clearConfig)
type clearConfig struct {
onlyClearClientsWithStaleCert bool
}
// WithClearingOnlyClientsWithStaleCert closes only clients that use outdated certs.
func WithClearingOnlyClientsWithStaleCert() ClearOption {
return func(c *clearConfig) { c.onlyClearClientsWithStaleCert = true }
}
// ClearForRoot closes and removes clients from the cache for the root cluster and its leaf clusters.
func (c *Cache) ClearForRoot(profileName string) error {
func (c *Cache) ClearForRoot(profileName string, opts ...ClearOption) error {
cfg := &clearConfig{}
for _, o := range opts {
o(cfg)
}
c.mu.Lock()
defer c.mu.Unlock()
@@ -150,13 +214,23 @@ func (c *Cache) ClearForRoot(profileName string) error {
)
for k, clt := range c.clients {
if k.profile == profileName {
if err := clt.Close(); err != nil {
errors = append(errors, err)
}
deleted = append(deleted, k.String())
delete(c.clients, k)
if k.profile != profileName {
continue
}
if cfg.onlyClearClientsWithStaleCert {
stale, err := clt.isTLSCertStale()
// If an error occurs, close the client as well.
if err != nil {
errors = append(errors, err)
} else if !stale {
continue
}
}
if err := clt.client.Close(); err != nil {
errors = append(errors, err)
}
deleted = append(deleted, k.String())
delete(c.clients, k)
}
c.cfg.Logger.InfoContext(context.Background(), "Invalidated cached clients for root cluster",
@@ -165,7 +239,6 @@ func (c *Cache) ClearForRoot(profileName string) error {
)
return trace.NewAggregate(errors...)
}
// Clear closes and removes all clients.
@@ -175,7 +248,7 @@ func (c *Cache) Clear() error {
var errors []error
for _, clt := range c.clients {
if err := clt.Close(); err != nil {
if err := clt.client.Close(); err != nil {
errors = append(errors, err)
}
}
@@ -184,14 +257,14 @@ func (c *Cache) Clear() error {
return trace.NewAggregate(errors...)
}
func (c *Cache) addToCache(k key, clusterClient *client.ClusterClient) {
func (c *Cache) addToCache(k key, clt *clientWithMetadata) {
c.mu.Lock()
defer c.mu.Unlock()
c.clients[k] = clusterClient
c.clients[k] = clt
}
func (c *Cache) getFromCache(k key) *client.ClusterClient {
func (c *Cache) getFromCache(k key) *clientWithMetadata {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -241,7 +314,7 @@ func (c *NoCache) Get(ctx context.Context, profileName, leafClusterName string)
return newClient, nil
}
func (c *NoCache) ClearForRoot(profileName string) error {
func (c *NoCache) ClearForRoot(profileName string, _ ...ClearOption) error {
c.mu.Lock()
defer c.mu.Unlock()
+180
View File
@@ -0,0 +1,180 @@
// Teleport
// Copyright (C) 2025 Gravitational, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package clientcache
import (
"context"
"crypto/x509/pkix"
"log/slog"
"testing"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
apiprofile "github.com/gravitational/teleport/api/profile"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/auth/testauthority"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/fixtures"
"github.com/gravitational/teleport/lib/observability/tracing"
"github.com/gravitational/teleport/lib/sshca"
"github.com/gravitational/teleport/lib/tlsca"
)
func TestClearingClientsWithStaleCert(t *testing.T) {
privateKey, err := keys.ParsePrivateKey(fixtures.PEMBytes["rsa"])
require.NoError(t, err)
tlsCert, sshCert, err := makeCerts(privateKey)
require.NoError(t, err)
keyRing := client.NewKeyRing(privateKey, privateKey)
keyRing.KeyRingIndex = client.KeyRingIndex{
ProxyHost: "localhost",
Username: "testuser",
ClusterName: "root",
}
keyRing.Cert = sshCert
keyRing.TLSCert = tlsCert
profile := &apiprofile.Profile{
WebProxyAddr: keyRing.ProxyHost,
Username: keyRing.Username,
SiteName: keyRing.ClusterName,
}
clientStore := client.NewFSClientStore(t.TempDir())
err = clientStore.SaveProfile(profile, true)
require.NoError(t, err)
err = clientStore.AddKeyRing(keyRing)
require.NoError(t, err)
cache, err := New(Config{
NewClientFunc: func(ctx context.Context, profileName, leafClusterName string) (*client.TeleportClient, error) {
config := &client.Config{
ClientStore: clientStore,
SSHProxyAddr: "localhost:3080",
WebProxyAddr: "localhost:3080",
Username: "testuser",
Tracer: tracing.NoopProvider().Tracer("test"),
SiteName: "root",
}
if leafClusterName != "" {
config.SiteName = leafClusterName
}
tc, err := client.NewClient(config)
return tc, err
},
RetryWithReloginFunc: func(ctx context.Context, tc *client.TeleportClient, fn func() error, opts ...client.RetryWithReloginOption) error {
return fn()
},
Logger: slog.New(slog.DiscardHandler),
})
require.NoError(t, err)
// Get clients.
rootClient, err := cache.Get(t.Context(), "root", "")
require.NoError(t, err)
leaf1Client, err := cache.Get(t.Context(), "root", "leaf1")
require.NoError(t, err)
// Update the TLS cert.
tlsCert, _, err = makeCerts(privateKey)
require.NoError(t, err)
keyRing.TLSCert = tlsCert
err = clientStore.AddKeyRing(keyRing)
require.NoError(t, err)
// Get the client for a new leaf after the cert has been updated.
leaf2Client, err := cache.Get(t.Context(), "root", "leaf2")
require.NoError(t, err)
// Clear stale clients.
err = cache.ClearForRoot("root", WithClearingOnlyClientsWithStaleCert())
require.NoError(t, err)
newRootClient, err := cache.Get(t.Context(), "root", "")
require.NoError(t, err)
newLeaf1Client, err := cache.Get(t.Context(), "root", "leaf1")
require.NoError(t, err)
newLeaf2Client, err := cache.Get(t.Context(), "root", "leaf2")
require.NoError(t, err)
// Clients opened before updating the cert should be reopened.
require.NotEqual(t, newRootClient, rootClient)
require.NotEqual(t, newLeaf1Client, leaf1Client)
// The client opened after updating the cert should be untouched.
require.Equal(t, newLeaf2Client, leaf2Client)
}
// makeCerts makes TSL and SSH certs.
func makeCerts(privateKey *keys.PrivateKey) ([]byte, []byte, error) {
cert, err := tlsca.GenerateSelfSignedCAWithSigner(privateKey, pkix.Name{
CommonName: "root",
Organization: []string{"localhost"},
}, nil, defaults.CATTL)
if err != nil {
return nil, nil, trace.Wrap(err)
}
ca, err := tlsca.FromCertAndSigner(cert, privateKey)
if err != nil {
return nil, nil, trace.Wrap(err)
}
keygen := testauthority.New()
clock := clockwork.NewRealClock()
identity := tlsca.Identity{
Username: "testuser",
}
subject, err := identity.Subject()
if err != nil {
return nil, nil, trace.Wrap(err)
}
tlsCert, err := ca.GenerateCertificate(tlsca.CertificateRequest{
Clock: clock,
PublicKey: privateKey.Public(),
Subject: subject,
NotAfter: clock.Now().UTC().Add(defaults.CATTL),
})
if err != nil {
return nil, nil, trace.Wrap(err)
}
signer, err := keys.ParsePrivateKey([]byte(fixtures.SSHCAPrivateKey))
if err != nil {
return nil, nil, trace.Wrap(err)
}
caSigner, err := ssh.NewSignerFromKey(signer)
if err != nil {
return nil, nil, trace.Wrap(err)
}
sshCert, err := keygen.GenerateUserCert(sshca.UserCertificateRequest{
CASigner: caSigner,
PublicUserKey: ssh.MarshalAuthorizedKey(privateKey.SSHPublicKey()),
Identity: sshca.Identity{
Username: "testuser",
Principals: []string{"testuser"},
},
})
return tlsCert, sshCert, trace.Wrap(err)
}
@@ -22,9 +22,11 @@ import (
"context"
"github.com/gravitational/trace"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/gravitational/teleport/api/constants"
api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1"
"github.com/gravitational/teleport/lib/teleterm/api/uri"
"github.com/gravitational/teleport/lib/teleterm/clusters"
)
@@ -82,6 +84,17 @@ func (s *Handler) GetCluster(ctx context.Context, req *api.GetClusterRequest) (*
return apiRootClusterWithDetails, trace.Wrap(err)
}
// ClearStaleClusterClients closes root and leaf cluster clients that use outdated TLS certificates.
func (s *Handler) ClearStaleClusterClients(_ context.Context, req *api.ClearStaleClusterClientsRequest) (*api.ClearStaleClusterClientsResponse, error) {
parsed, err := uri.Parse(req.RootClusterUri)
if err != nil {
return &api.ClearStaleClusterClientsResponse{}, trace.Wrap(err)
}
err = s.DaemonService.ClearStaleCachedClientsForRoot(parsed)
return &api.ClearStaleClusterClientsResponse{}, trace.Wrap(err)
}
func newAPIRootCluster(cluster *clusters.Cluster) *api.Cluster {
loggedInUser := cluster.GetLoggedInUser()
@@ -95,6 +108,7 @@ func newAPIRootCluster(cluster *clusters.Cluster) *api.Cluster {
Roles: loggedInUser.Roles,
ActiveRequests: loggedInUser.ActiveRequests,
IsDeviceTrusted: cluster.HasDeviceTrustExtensions(),
ValidUntil: timestamppb.New(loggedInUser.ValidUntil),
},
SsoHost: cluster.SSOHost,
}
+4
View File
@@ -21,6 +21,7 @@ package clusters
import (
"context"
"log/slog"
"time"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
@@ -312,6 +313,7 @@ func (c *Cluster) GetLoggedInUser() LoggedInUser {
SSHLogins: c.status.Logins,
Roles: c.status.Roles,
ActiveRequests: c.status.ActiveRequests,
ValidUntil: c.status.ValidUntil,
}
}
@@ -353,6 +355,8 @@ type LoggedInUser struct {
Roles []string
// ActiveRequests is the user active requests
ActiveRequests []string
// ValidUntil is expiration time of the certificate.
ValidUntil time.Time
}
// AddMetadataToRetryableError is Connect's equivalent of client.RetryWithRelogin. By adding the
+1 -1
View File
@@ -95,7 +95,7 @@ type ClientCache interface {
Get(ctx context.Context, profileName, leafClusterName string) (*client.ClusterClient, error)
// ClearForRoot closes and removes clients from the cache
// for the root cluster and its leaf clusters.
ClearForRoot(profileName string) error
ClearForRoot(profileName string, opts ...clientcache.ClearOption) error
// Clear closes and removes all clients.
Clear() error
}
+9
View File
@@ -40,6 +40,7 @@ import (
api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/client/clientcache"
"github.com/gravitational/teleport/lib/client/sso"
dtauthn "github.com/gravitational/teleport/lib/devicetrust/authn"
"github.com/gravitational/teleport/lib/teleterm/api/uri"
@@ -1283,6 +1284,14 @@ func (s *Service) ClearCachedClientsForRoot(clusterURI uri.ResourceURI) error {
return trace.Wrap(s.clientCache.ClearForRoot(profileName))
}
// ClearStaleCachedClientsForRoot closes and removes clients from the cache
// for the root cluster and its leaf clusters, if their cert is outdated.
func (s *Service) ClearStaleCachedClientsForRoot(clusterURI uri.ResourceURI) error {
profileName := clusterURI.GetProfileName()
err := s.clientCache.ClearForRoot(profileName, clientcache.WithClearingOnlyClientsWithStaleCert())
return trace.Wrap(err)
}
// SetSharedDirectoryForDesktopSession opens a directory for a desktop session and enables file system operations for it.
// If there is no active desktop session associated with the specified desktop_uri and login,
// an error is returned.
@@ -20,6 +20,7 @@ syntax = "proto3";
package teleport.lib.teleterm.v1;
import "google/protobuf/timestamp.proto";
import "teleport/legacy/types/trusted_device_requirement.proto";
option go_package = "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1;teletermv1";
@@ -111,6 +112,8 @@ message LoggedInUser {
bool is_device_trusted = 9;
// Indicates whether access may be hindered by the lack of a trusted device.
types.TrustedDeviceRequirement trusted_device_requirement = 10;
// Expiration time of the certificate.
google.protobuf.Timestamp valid_until = 11;
}
// ACL is the access control list of the user
@@ -135,6 +135,8 @@ service TerminalService {
// Optionally removes the profile.
// This operation is idempotent and can be safely invoked multiple times.
rpc Logout(LogoutRequest) returns (EmptyResponse);
// Closes root and leaf cluster clients that use outdated TLS certificates.
rpc ClearStaleClusterClients(ClearStaleClusterClientsRequest) returns (ClearStaleClusterClientsResponse);
// TransferFile sends a request to download/upload a file
rpc TransferFile(FileTransferRequest) returns (stream FileTransferProgress);
// ReportUsageEvent allows to send usage events that are then anonymized and forwarded to prehog
@@ -204,6 +206,12 @@ message LogoutRequest {
bool remove_profile = 2;
}
message ClearStaleClusterClientsRequest {
string root_cluster_uri = 1;
}
message ClearStaleClusterClientsResponse {}
message StartHeadlessWatcherRequest {
string root_cluster_uri = 1;
}
@@ -209,6 +209,12 @@ export class ClusterLifecycleManager {
if (hasLoggedOut) {
await this.handleClusterLogout(next);
} else {
const client = await this.getTshdClient();
// Only clear clients with outdated certificates.
// The watcher 'changed' event may be emitted right after the user logs in
// or assumes a role via Connect (which already closes all clients
// for the profile), so we avoid closing them again if they're already up to date.
await client.clearStaleClusterClients({ rootClusterUri: next.uri });
await this.syncOrUpdateCluster(next);
}
}
@@ -103,6 +103,7 @@ export function mergeClusterProfileWithDetails({
activeRequests: profile.loggedInUser.activeRequests,
roles: profile.loggedInUser.roles,
isDeviceTrusted: profile.loggedInUser.isDeviceTrusted,
validUntil: profile.loggedInUser.validUntil,
userType:
details.loggedInUser?.userType || LoggedInUser_UserType.UNSPECIFIED,
trustedDeviceRequirement:
@@ -74,6 +74,7 @@ export class MockTshClient implements TshdClient {
login = () => new MockedUnaryCall({});
loginPasswordless = undefined;
logout = () => new MockedUnaryCall({});
clearStaleClusterClients = () => new MockedUnaryCall({});
transferFile = undefined;
reportUsageEvent = () => new MockedUnaryCall({});
createConnectMyComputerRole = () =>
@@ -16,6 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import { TrustedDeviceRequirement } from 'gen-proto-ts/teleport/legacy/types/trusted_device_requirement_pb';
import { App } from 'gen-proto-ts/teleport/lib/teleterm/v1/app_pb';
import {
@@ -24,6 +25,7 @@ import {
} from 'gen-proto-ts/teleport/lib/teleterm/v1/auth_settings_pb';
import {
ACL,
LoggedInUser_UserType,
ShowResources,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { WindowsDesktop } from 'gen-proto-ts/teleport/lib/teleterm/v1/windows_desktop_pb';
@@ -259,7 +261,8 @@ export const makeLoggedInUser = (
roles: [],
requestableRoles: [],
suggestedReviewers: [],
userType: tsh.LoggedInUser_UserType.LOCAL,
userType: LoggedInUser_UserType.LOCAL,
validUntil: Timestamp.fromDate(new Date()),
...props,
});
@@ -16,9 +16,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Meta, StoryObj } from '@storybook/react-vite';
import { useLayoutEffect } from 'react';
import Flex from 'design/Flex';
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import { TrustedDeviceRequirement } from 'gen-proto-ts/teleport/legacy/types/trusted_device_requirement_pb';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
@@ -32,21 +34,122 @@ import { RootClusterUri } from 'teleterm/ui/uri';
import { IdentityContainer } from './Identity';
export default {
interface StoryProps {
clusters: ('violet' | 'orange' | 'green')[];
activeCluster: boolean;
activeClusterExpired: boolean;
deviceTrust: 'enrolled' | 'required-not-enrolled' | 'not-enrolled';
showProfileErrors: boolean;
}
const meta: Meta<StoryProps> = {
title: 'Teleterm/Identity',
component: props => {
const hasOrange = props.clusters.includes('orange');
const hasViolet = props.clusters.includes('violet');
const hasGreen = props.clusters.includes('green');
const clusters = [
hasOrange &&
makeRootCluster({
...clusterOrange,
profileStatusError: props.showProfileErrors ? profileStatusError : '',
}),
hasViolet &&
makeRootCluster({
...clusterViolet,
profileStatusError: props.showProfileErrors ? profileStatusError : '',
}),
hasGreen &&
makeRootCluster({
...clusterGreen,
profileStatusError: props.showProfileErrors ? profileStatusError : '',
}),
].filter(Boolean);
const hasClusterWithLoggedInUser =
props.activeCluster && (hasOrange || hasViolet);
if (hasClusterWithLoggedInUser) {
clusters[0].loggedInUser = makeLoggedInUser({
...clusters[0].loggedInUser,
validUntil: Timestamp.fromDate(
props.activeClusterExpired
? new Date()
: new Date(Date.now() + 24 * 60 * 60 * 1000)
),
isDeviceTrusted: props.deviceTrust === 'enrolled',
trustedDeviceRequirement:
props.deviceTrust === 'required-not-enrolled'
? TrustedDeviceRequirement.REQUIRED
: TrustedDeviceRequirement.NOT_REQUIRED,
});
}
return (
<OpenIdentityPopover
clusters={clusters}
activeClusterUri={hasClusterWithLoggedInUser && clusters[0]?.uri}
/>
);
},
argTypes: {
clusters: {
control: { type: 'check' },
options: ['violet', 'orange', 'green'],
description: 'List of clusters to show.',
},
activeCluster: {
control: { type: 'boolean' },
description: 'Makes "violet" or "orange" an active cluster.',
},
deviceTrust: {
control: { type: 'radio' },
options: ['enrolled', 'required-not-enrolled', 'not-enrolled'],
description: 'Controls device trust requirement.',
},
activeClusterExpired: {
control: { type: 'boolean' },
description: 'Whether the active cluster has expired cert.',
},
showProfileErrors: {
control: { type: 'boolean' },
description: 'Shows profile errors for all clusters.',
},
},
args: {
clusters: ['violet', 'orange', 'green'],
activeCluster: true,
deviceTrust: 'not-enrolled',
activeClusterExpired: false,
showProfileErrors: false,
},
};
export default meta;
const clusterOrange = makeRootCluster({
name: 'orange',
name: 'orange-psv-eindhoven-eredivisie-production-lorem-ipsum',
loggedInUser: makeLoggedInUser({
name: 'bob',
roles: ['access', 'editor'],
name: 'ruud-van-nistelrooy-van-der-sar',
roles: [
'circle-mark-app-access',
'grafana-lite-app-access',
'grafana-gold-app-access',
'release-lion-app-access',
'release-fox-app-access',
'sales-center-lorem-app-access',
'sales-center-ipsum-db-access',
'sales-center-shop-app-access',
'sales-center-floor-db-access',
],
}),
uri: '/clusters/orange',
});
const clusterViolet = makeRootCluster({
name: 'violet',
loggedInUser: makeLoggedInUser({ name: 'sammy' }),
loggedInUser: makeLoggedInUser({
name: 'sammy',
roles: ['access', 'editor'],
}),
uri: '/clusters/violet',
});
const clusterGreen = makeRootCluster({
@@ -66,8 +169,14 @@ const OpenIdentityPopover = (props: {
props.clusters.forEach(c => {
ctx.addRootCluster(c);
});
ctx.workspacesService.addWorkspace(clusterGreen.uri);
ctx.workspacesService.addWorkspace(clusterViolet.uri);
ctx.workspacesService.addWorkspace(clusterOrange.uri);
ctx.workspacesService.setState(draftState => {
draftState.rootClusterUri = props.activeClusterUri;
draftState.workspaces[clusterGreen.uri].color = 'green';
draftState.workspaces[clusterViolet.uri].color = 'purple';
draftState.workspaces[clusterOrange.uri].color = 'yellow';
});
useOpenPopover();
@@ -98,125 +207,60 @@ const useOpenPopover = () => {
}, []);
};
export function NoRootClusters() {
return <OpenIdentityPopover clusters={[]} activeClusterUri={undefined} />;
}
export const NoRootClusters: StoryObj<StoryProps> = {
args: {
clusters: [],
},
};
export function OneClusterWithNoActiveCluster() {
return (
<OpenIdentityPopover
activeClusterUri={undefined}
clusters={[makeRootCluster({ loggedInUser: undefined })]}
/>
);
}
export const OneClusterWithNoActiveCluster: StoryObj<StoryProps> = {
args: {
clusters: ['orange'],
activeCluster: false,
},
};
export function OneClusterWithActiveCluster() {
const cluster = makeRootCluster({
loggedInUser: makeLoggedInUser({
name: 'alice',
roles: ['access', 'editor'],
}),
});
export const OneClusterWithActiveCluster: StoryObj<StoryProps> = {
args: {
clusters: ['violet'],
},
};
return (
<OpenIdentityPopover clusters={[cluster]} activeClusterUri={cluster.uri} />
);
}
export const ManyClustersWithNoActiveCluster: StoryObj<StoryProps> = {
args: {
clusters: ['orange', 'green', 'violet'],
activeCluster: false,
},
};
export function ManyClustersWithNoActiveCluster() {
return (
<OpenIdentityPopover
clusters={[clusterOrange, clusterViolet, clusterGreen]}
activeClusterUri={undefined}
/>
);
}
export const ManyClustersWithActiveCluster: StoryObj<StoryProps> = {
args: {
clusters: ['orange', 'green', 'violet'],
},
};
export function ManyClustersWithActiveCluster() {
return (
<OpenIdentityPopover
clusters={[clusterOrange, clusterViolet, clusterGreen]}
activeClusterUri={clusterOrange.uri}
/>
);
}
export const ManyClustersWithProfileErrorsAndActiveCluster: StoryObj<StoryProps> =
{
args: {
clusters: ['orange', 'green', 'violet'],
showProfileErrors: true,
},
};
export function ManyClustersWithProfileErrorsAndActiveCluster() {
return (
<OpenIdentityPopover
clusters={[
makeRootCluster({ ...clusterOrange, profileStatusError }),
makeRootCluster({ ...clusterViolet, profileStatusError }),
makeRootCluster({ ...clusterGreen, profileStatusError }),
]}
activeClusterUri={clusterOrange.uri}
/>
);
}
export const TrustedDeviceEnrolled: StoryObj<StoryProps> = {
args: {
deviceTrust: 'enrolled',
},
};
export function LongNamesWithManyRoles() {
return (
<OpenIdentityPopover
clusters={[
clusterOrange,
makeRootCluster({
...clusterViolet,
name: 'psv-eindhoven-eredivisie-production-lorem-ipsum',
loggedInUser: makeLoggedInUser({
roles: [
'circle-mark-app-access',
'grafana-lite-app-access',
'grafana-gold-app-access',
'release-lion-app-access',
'release-fox-app-access',
'sales-center-lorem-app-access',
'sales-center-ipsum-db-access',
'sales-center-shop-app-access',
'sales-center-floor-db-access',
],
name: 'ruud-van-nistelrooy-van-der-sar',
}),
}),
clusterGreen,
]}
activeClusterUri={clusterViolet.uri}
/>
);
}
export const TrustedDeviceRequiredButNotEnrolled: StoryObj<StoryProps> = {
args: {
deviceTrust: 'required-not-enrolled',
},
};
export function TrustedDeviceEnrolled() {
return (
<OpenIdentityPopover
clusters={[
clusterOrange,
makeRootCluster({
...clusterViolet,
loggedInUser: makeLoggedInUser({
isDeviceTrusted: true,
roles: ['circle-mark-app-access', 'grafana-lite-app-access'],
}),
}),
]}
activeClusterUri={clusterViolet.uri}
/>
);
}
export function TrustedDeviceRequiredButNotEnrolled() {
return (
<OpenIdentityPopover
clusters={[
clusterOrange,
makeRootCluster({
...clusterViolet,
loggedInUser: makeLoggedInUser({
trustedDeviceRequirement: TrustedDeviceRequirement.REQUIRED,
roles: ['circle-mark-app-access', 'grafana-lite-app-access'],
}),
}),
]}
activeClusterUri={clusterViolet.uri}
/>
);
}
export const ActiveClusterExpired: StoryObj<StoryProps> = {
args: {
activeClusterExpired: true,
},
};
@@ -16,12 +16,20 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { formatDistanceToNowStrict, isPast } from 'date-fns';
import { JSX } from 'react';
import styled from 'styled-components';
import { ButtonText, Flex, Label, P3 } from 'design';
import { Logout, Refresh, ShieldCheck, ShieldWarning } from 'design/Icon';
import { ButtonText, Flex, Label, P3, Stack } from 'design';
import {
Clock,
Logout,
Refresh,
ShieldCheck,
ShieldWarning,
} from 'design/Icon';
import Link from 'design/Link';
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { ProfileStatusError } from 'teleterm/ui/components/ProfileStatusError';
@@ -46,6 +54,10 @@ export function ActiveCluster(props: {
onLogout(): void;
}) {
const clusterName = routing.parseClusterName(props.activeCluster.uri);
const validUntil =
props.activeCluster.loggedInUser?.validUntil &&
Timestamp.toDate(props.activeCluster.loggedInUser.validUntil);
return (
<>
<Flex p={3} pb={2} flexWrap="nowrap" gap={2} flexDirection="column">
@@ -103,7 +115,35 @@ export function ActiveCluster(props: {
</Label>
))}
</Flex>
<DeviceTrustMessage status={props.deviceTrustStatus} />
<Stack gap={0}>
{validUntil && (
<Flex gap={1} color="text.slightlyMuted">
<Clock size="small" />
<P3>
{isPast(validUntil) ? (
'Session expired.'
) : (
<>
Session expires{' '}
<span
title={validUntil.toLocaleString()}
css={`
text-decoration: underline;
text-decoration-style: dotted;
`}
>
{formatDistanceToNowStrict(validUntil, {
addSuffix: true,
})}
.
</span>
</>
)}
</P3>
</Flex>
)}
<DeviceTrustMessage status={props.deviceTrustStatus} />
</Stack>
</Flex>
<Separator />
</>