diff --git a/api/profile/profile.go b/api/profile/profile.go index ed9020e433b61..ade1410fec17c 100644 --- a/api/profile/profile.go +++ b/api/profile/profile.go @@ -317,19 +317,26 @@ func FullProfilePath(dir string) string { // defaultProfilePath retrieves the default path of the TSH profile. func defaultProfilePath() string { - // start with UserHomeDir, which is the fastest option as it - // relies only on environment variables and does not perform - // a user lookup (which can be very slow on large AD environments) - home, err := os.UserHomeDir() - if err == nil && home != "" { - return filepath.Join(home, profileDir) + home, ok := UserHomeDir() + if !ok { + home = os.TempDir() } + return filepath.Join(home, profileDir) +} - home = os.TempDir() +// UserHomeDir returns the current user's home directory if it can be found. +func UserHomeDir() (string, bool) { + // Start with os.UserHomeDir, which is the fastest option as it relies only + // on environment variables and does not perform a user lookup (which can be + // very slow on large AD environments). + if home, err := os.UserHomeDir(); err == nil && home != "" { + return home, true + } + // Fall back to the user lookup. if u, err := user.Current(); err == nil && u.HomeDir != "" { - home = u.HomeDir + return u.HomeDir, true } - return filepath.Join(home, profileDir) + return "", false } // FromDir reads the user profile from a given directory. If dir is empty, diff --git a/api/utils/keypaths/keypaths.go b/api/utils/keypaths/keypaths.go index 60b2c472f8df5..ff619e49ebd8a 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -82,9 +82,9 @@ const ( // vnetKnownHosts is the file name of the known_hosts file trusted by // third-party SSH clients connecting to VNet SSH. vnetKnownHosts = "vnet_known_hosts" - // vnetSSHConfig is the file name of the generated OpenSSH-compatible config + // VNetSSHConfig is the file name of the generated OpenSSH-compatible config // file to be used by third-party SSH clients connecting to VNet SSH. - vnetSSHConfig = "vnet_ssh_config" + VNetSSHConfig = "vnet_ssh_config" ) // Here's the file layout of all these keypaths. @@ -463,7 +463,7 @@ func VNetKnownHostsPath(baseDir string) string { // VNetSSHConfigPath returns the path to VNet's generated OpenSSH-compatible // config file. func VNetSSHConfigPath(baseDir string) string { - return filepath.Join(baseDir, vnetSSHConfig) + return filepath.Join(baseDir, VNetSSHConfig) } // TrimKeyPathSuffix returns the given path with any key suffix/extension trimmed off. diff --git a/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go b/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go index 16e907e5481b7..1eb4f2d78f753 100644 --- a/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go +++ b/gen/proto/go/teleport/lib/vnet/diag/v1/diag.pb.go @@ -496,6 +496,7 @@ type CheckReport struct { // Types that are valid to be assigned to Report: // // *CheckReport_RouteConflictReport + // *CheckReport_SshConfigurationReport Report isCheckReport_Report `protobuf_oneof:"report"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -554,6 +555,15 @@ func (x *CheckReport) GetRouteConflictReport() *RouteConflictReport { return nil } +func (x *CheckReport) GetSshConfigurationReport() *SSHConfigurationReport { + if x != nil { + if x, ok := x.Report.(*CheckReport_SshConfigurationReport); ok { + return x.SshConfigurationReport + } + } + return nil +} + type isCheckReport_Report interface { isCheckReport_Report() } @@ -564,8 +574,15 @@ type CheckReport_RouteConflictReport struct { RouteConflictReport *RouteConflictReport `protobuf:"bytes,2,opt,name=route_conflict_report,json=routeConflictReport,proto3,oneof"` } +type CheckReport_SshConfigurationReport struct { + // ssh_configuration_report reports the status of the system's SSH configuration. + SshConfigurationReport *SSHConfigurationReport `protobuf:"bytes,3,opt,name=ssh_configuration_report,json=sshConfigurationReport,proto3,oneof"` +} + func (*CheckReport_RouteConflictReport) isCheckReport_Report() {} +func (*CheckReport_SshConfigurationReport) isCheckReport_Report() {} + // CommandAttempt describes the attempt at running a particular command associated with a diagnostic // check. type CommandAttempt struct { @@ -761,6 +778,93 @@ func (x *RouteConflict) GetInterfaceApp() string { return "" } +// SSHConfigurationReport describes the state of the system's SSH configuration. +type SSHConfigurationReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + // user_openssh_config_path is the full path to the user's default OpenSSH + // config file (~/.ssh/config). + UserOpensshConfigPath string `protobuf:"bytes,1,opt,name=user_openssh_config_path,json=userOpensshConfigPath,proto3" json:"user_openssh_config_path,omitempty"` + // vnet_ssh_config_path is the path to VNet's generated OpenSSH-compatible + // config file. + VnetSshConfigPath string `protobuf:"bytes,2,opt,name=vnet_ssh_config_path,json=vnetSshConfigPath,proto3" json:"vnet_ssh_config_path,omitempty"` + // user_openssh_config_includes_vnet_ssh_config is true if the default + // OpenSSH user configuration file includes VNet's SSH config file. + UserOpensshConfigIncludesVnetSshConfig bool `protobuf:"varint,3,opt,name=user_openssh_config_includes_vnet_ssh_config,json=userOpensshConfigIncludesVnetSshConfig,proto3" json:"user_openssh_config_includes_vnet_ssh_config,omitempty"` + // user_openssh_config_exists is true if a file exists at + // user_openssh_config_path (~/.ssh/config). + UserOpensshConfigExists bool `protobuf:"varint,4,opt,name=user_openssh_config_exists,json=userOpensshConfigExists,proto3" json:"user_openssh_config_exists,omitempty"` + // user_openssh_config_contents contains the contents of the file at + // user_openssh_config_path if it exists. + UserOpensshConfigContents string `protobuf:"bytes,5,opt,name=user_openssh_config_contents,json=userOpensshConfigContents,proto3" json:"user_openssh_config_contents,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHConfigurationReport) Reset() { + *x = SSHConfigurationReport{} + mi := &file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHConfigurationReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHConfigurationReport) ProtoMessage() {} + +func (x *SSHConfigurationReport) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SSHConfigurationReport.ProtoReflect.Descriptor instead. +func (*SSHConfigurationReport) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_diag_v1_diag_proto_rawDescGZIP(), []int{8} +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigPath() string { + if x != nil { + return x.UserOpensshConfigPath + } + return "" +} + +func (x *SSHConfigurationReport) GetVnetSshConfigPath() string { + if x != nil { + return x.VnetSshConfigPath + } + return "" +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigIncludesVnetSshConfig() bool { + if x != nil { + return x.UserOpensshConfigIncludesVnetSshConfig + } + return false +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigExists() bool { + if x != nil { + return x.UserOpensshConfigExists + } + return false +} + +func (x *SSHConfigurationReport) GetUserOpensshConfigContents() string { + if x != nil { + return x.UserOpensshConfigContents + } + return "" +} + var File_teleport_lib_vnet_diag_v1_diag_proto protoreflect.FileDescriptor const file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc = "" + @@ -785,10 +889,11 @@ const file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc = "" + "\x06status\x18\x01 \x01(\x0e2-.teleport.lib.vnet.diag.v1.CheckAttemptStatusR\x06status\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12I\n" + "\fcheck_report\x18\x03 \x01(\v2&.teleport.lib.vnet.diag.v1.CheckReportR\vcheckReport\x12E\n" + - "\bcommands\x18\x04 \x03(\v2).teleport.lib.vnet.diag.v1.CommandAttemptR\bcommands\"\xc3\x01\n" + + "\bcommands\x18\x04 \x03(\v2).teleport.lib.vnet.diag.v1.CommandAttemptR\bcommands\"\xb2\x02\n" + "\vCheckReport\x12D\n" + "\x06status\x18\x01 \x01(\x0e2,.teleport.lib.vnet.diag.v1.CheckReportStatusR\x06status\x12d\n" + - "\x15route_conflict_report\x18\x02 \x01(\v2..teleport.lib.vnet.diag.v1.RouteConflictReportH\x00R\x13routeConflictReportB\b\n" + + "\x15route_conflict_report\x18\x02 \x01(\v2..teleport.lib.vnet.diag.v1.RouteConflictReportH\x00R\x13routeConflictReport\x12m\n" + + "\x18ssh_configuration_report\x18\x03 \x01(\v21.teleport.lib.vnet.diag.v1.SSHConfigurationReportH\x00R\x16sshConfigurationReportB\b\n" + "\x06report\"\xa1\x01\n" + "\x0eCommandAttempt\x12G\n" + "\x06status\x18\x01 \x01(\x0e2/.teleport.lib.vnet.diag.v1.CommandAttemptStatusR\x06status\x12\x14\n" + @@ -801,7 +906,13 @@ const file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc = "" + "\x04dest\x18\x01 \x01(\tR\x04dest\x12\x1b\n" + "\tvnet_dest\x18\x02 \x01(\tR\bvnetDest\x12%\n" + "\x0einterface_name\x18\x03 \x01(\tR\rinterfaceName\x12#\n" + - "\rinterface_app\x18\x04 \x01(\tR\finterfaceApp*w\n" + + "\rinterface_app\x18\x04 \x01(\tR\finterfaceApp\"\xde\x02\n" + + "\x16SSHConfigurationReport\x127\n" + + "\x18user_openssh_config_path\x18\x01 \x01(\tR\x15userOpensshConfigPath\x12/\n" + + "\x14vnet_ssh_config_path\x18\x02 \x01(\tR\x11vnetSshConfigPath\x12\\\n" + + ",user_openssh_config_includes_vnet_ssh_config\x18\x03 \x01(\bR&userOpensshConfigIncludesVnetSshConfig\x12;\n" + + "\x1auser_openssh_config_exists\x18\x04 \x01(\bR\x17userOpensshConfigExists\x12?\n" + + "\x1cuser_openssh_config_contents\x18\x05 \x01(\tR\x19userOpensshConfigContents*w\n" + "\x12CheckAttemptStatus\x12$\n" + " CHECK_ATTEMPT_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17CHECK_ATTEMPT_STATUS_OK\x10\x01\x12\x1e\n" + @@ -828,23 +939,24 @@ func file_teleport_lib_vnet_diag_v1_diag_proto_rawDescGZIP() []byte { } var file_teleport_lib_vnet_diag_v1_diag_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes = make([]protoimpl.MessageInfo, 9) var file_teleport_lib_vnet_diag_v1_diag_proto_goTypes = []any{ - (CheckAttemptStatus)(0), // 0: teleport.lib.vnet.diag.v1.CheckAttemptStatus - (CheckReportStatus)(0), // 1: teleport.lib.vnet.diag.v1.CheckReportStatus - (CommandAttemptStatus)(0), // 2: teleport.lib.vnet.diag.v1.CommandAttemptStatus - (*Report)(nil), // 3: teleport.lib.vnet.diag.v1.Report - (*NetworkStackAttempt)(nil), // 4: teleport.lib.vnet.diag.v1.NetworkStackAttempt - (*NetworkStack)(nil), // 5: teleport.lib.vnet.diag.v1.NetworkStack - (*CheckAttempt)(nil), // 6: teleport.lib.vnet.diag.v1.CheckAttempt - (*CheckReport)(nil), // 7: teleport.lib.vnet.diag.v1.CheckReport - (*CommandAttempt)(nil), // 8: teleport.lib.vnet.diag.v1.CommandAttempt - (*RouteConflictReport)(nil), // 9: teleport.lib.vnet.diag.v1.RouteConflictReport - (*RouteConflict)(nil), // 10: teleport.lib.vnet.diag.v1.RouteConflict - (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp + (CheckAttemptStatus)(0), // 0: teleport.lib.vnet.diag.v1.CheckAttemptStatus + (CheckReportStatus)(0), // 1: teleport.lib.vnet.diag.v1.CheckReportStatus + (CommandAttemptStatus)(0), // 2: teleport.lib.vnet.diag.v1.CommandAttemptStatus + (*Report)(nil), // 3: teleport.lib.vnet.diag.v1.Report + (*NetworkStackAttempt)(nil), // 4: teleport.lib.vnet.diag.v1.NetworkStackAttempt + (*NetworkStack)(nil), // 5: teleport.lib.vnet.diag.v1.NetworkStack + (*CheckAttempt)(nil), // 6: teleport.lib.vnet.diag.v1.CheckAttempt + (*CheckReport)(nil), // 7: teleport.lib.vnet.diag.v1.CheckReport + (*CommandAttempt)(nil), // 8: teleport.lib.vnet.diag.v1.CommandAttempt + (*RouteConflictReport)(nil), // 9: teleport.lib.vnet.diag.v1.RouteConflictReport + (*RouteConflict)(nil), // 10: teleport.lib.vnet.diag.v1.RouteConflict + (*SSHConfigurationReport)(nil), // 11: teleport.lib.vnet.diag.v1.SSHConfigurationReport + (*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp } var file_teleport_lib_vnet_diag_v1_diag_proto_depIdxs = []int32{ - 11, // 0: teleport.lib.vnet.diag.v1.Report.created_at:type_name -> google.protobuf.Timestamp + 12, // 0: teleport.lib.vnet.diag.v1.Report.created_at:type_name -> google.protobuf.Timestamp 4, // 1: teleport.lib.vnet.diag.v1.Report.network_stack_attempt:type_name -> teleport.lib.vnet.diag.v1.NetworkStackAttempt 6, // 2: teleport.lib.vnet.diag.v1.Report.checks:type_name -> teleport.lib.vnet.diag.v1.CheckAttempt 0, // 3: teleport.lib.vnet.diag.v1.NetworkStackAttempt.status:type_name -> teleport.lib.vnet.diag.v1.CheckAttemptStatus @@ -854,13 +966,14 @@ var file_teleport_lib_vnet_diag_v1_diag_proto_depIdxs = []int32{ 8, // 7: teleport.lib.vnet.diag.v1.CheckAttempt.commands:type_name -> teleport.lib.vnet.diag.v1.CommandAttempt 1, // 8: teleport.lib.vnet.diag.v1.CheckReport.status:type_name -> teleport.lib.vnet.diag.v1.CheckReportStatus 9, // 9: teleport.lib.vnet.diag.v1.CheckReport.route_conflict_report:type_name -> teleport.lib.vnet.diag.v1.RouteConflictReport - 2, // 10: teleport.lib.vnet.diag.v1.CommandAttempt.status:type_name -> teleport.lib.vnet.diag.v1.CommandAttemptStatus - 10, // 11: teleport.lib.vnet.diag.v1.RouteConflictReport.route_conflicts:type_name -> teleport.lib.vnet.diag.v1.RouteConflict - 12, // [12:12] is the sub-list for method output_type - 12, // [12:12] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 11, // 10: teleport.lib.vnet.diag.v1.CheckReport.ssh_configuration_report:type_name -> teleport.lib.vnet.diag.v1.SSHConfigurationReport + 2, // 11: teleport.lib.vnet.diag.v1.CommandAttempt.status:type_name -> teleport.lib.vnet.diag.v1.CommandAttemptStatus + 10, // 12: teleport.lib.vnet.diag.v1.RouteConflictReport.route_conflicts:type_name -> teleport.lib.vnet.diag.v1.RouteConflict + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_teleport_lib_vnet_diag_v1_diag_proto_init() } @@ -870,6 +983,7 @@ func file_teleport_lib_vnet_diag_v1_diag_proto_init() { } file_teleport_lib_vnet_diag_v1_diag_proto_msgTypes[4].OneofWrappers = []any{ (*CheckReport_RouteConflictReport)(nil), + (*CheckReport_SshConfigurationReport)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -877,7 +991,7 @@ func file_teleport_lib_vnet_diag_v1_diag_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc), len(file_teleport_lib_vnet_diag_v1_diag_proto_rawDesc)), NumEnums: 3, - NumMessages: 8, + NumMessages: 9, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts b/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts index dd193b224d559..e41bee08a843b 100644 --- a/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts +++ b/gen/proto/ts/teleport/lib/vnet/diag/v1/diag_pb.ts @@ -183,6 +183,14 @@ export interface CheckReport { * @generated from protobuf field: teleport.lib.vnet.diag.v1.RouteConflictReport route_conflict_report = 2; */ routeConflictReport: RouteConflictReport; + } | { + oneofKind: "sshConfigurationReport"; + /** + * ssh_configuration_report reports the status of the system's SSH configuration. + * + * @generated from protobuf field: teleport.lib.vnet.diag.v1.SSHConfigurationReport ssh_configuration_report = 3; + */ + sshConfigurationReport: SSHConfigurationReport; } | { oneofKind: undefined; }; @@ -263,6 +271,48 @@ export interface RouteConflict { */ interfaceApp: string; } +/** + * SSHConfigurationReport describes the state of the system's SSH configuration. + * + * @generated from protobuf message teleport.lib.vnet.diag.v1.SSHConfigurationReport + */ +export interface SSHConfigurationReport { + /** + * user_openssh_config_path is the full path to the user's default OpenSSH + * config file (~/.ssh/config). + * + * @generated from protobuf field: string user_openssh_config_path = 1; + */ + userOpensshConfigPath: string; + /** + * vnet_ssh_config_path is the path to VNet's generated OpenSSH-compatible + * config file. + * + * @generated from protobuf field: string vnet_ssh_config_path = 2; + */ + vnetSshConfigPath: string; + /** + * user_openssh_config_includes_vnet_ssh_config is true if the default + * OpenSSH user configuration file includes VNet's SSH config file. + * + * @generated from protobuf field: bool user_openssh_config_includes_vnet_ssh_config = 3; + */ + userOpensshConfigIncludesVnetSshConfig: boolean; + /** + * user_openssh_config_exists is true if a file exists at + * user_openssh_config_path (~/.ssh/config). + * + * @generated from protobuf field: bool user_openssh_config_exists = 4; + */ + userOpensshConfigExists: boolean; + /** + * user_openssh_config_contents contains the contents of the file at + * user_openssh_config_path if it exists. + * + * @generated from protobuf field: string user_openssh_config_contents = 5; + */ + userOpensshConfigContents: string; +} /** * CheckAttemptStatus describes whether CheckAttempt finished successfully. This is different from * CheckReportStatus, which describes whether a successful attempt at running a check has found any @@ -599,7 +649,8 @@ class CheckReport$Type extends MessageType { constructor() { super("teleport.lib.vnet.diag.v1.CheckReport", [ { no: 1, name: "status", kind: "enum", T: () => ["teleport.lib.vnet.diag.v1.CheckReportStatus", CheckReportStatus, "CHECK_REPORT_STATUS_"] }, - { no: 2, name: "route_conflict_report", kind: "message", oneof: "report", T: () => RouteConflictReport } + { no: 2, name: "route_conflict_report", kind: "message", oneof: "report", T: () => RouteConflictReport }, + { no: 3, name: "ssh_configuration_report", kind: "message", oneof: "report", T: () => SSHConfigurationReport } ]); } create(value?: PartialMessage): CheckReport { @@ -624,6 +675,12 @@ class CheckReport$Type extends MessageType { routeConflictReport: RouteConflictReport.internalBinaryRead(reader, reader.uint32(), options, (message.report as any).routeConflictReport) }; break; + case /* teleport.lib.vnet.diag.v1.SSHConfigurationReport ssh_configuration_report */ 3: + message.report = { + oneofKind: "sshConfigurationReport", + sshConfigurationReport: SSHConfigurationReport.internalBinaryRead(reader, reader.uint32(), options, (message.report as any).sshConfigurationReport) + }; + break; default: let u = options.readUnknownField; if (u === "throw") @@ -642,6 +699,9 @@ class CheckReport$Type extends MessageType { /* teleport.lib.vnet.diag.v1.RouteConflictReport route_conflict_report = 2; */ if (message.report.oneofKind === "routeConflictReport") RouteConflictReport.internalBinaryWrite(message.report.routeConflictReport, writer.tag(2, WireType.LengthDelimited).fork(), options).join(); + /* teleport.lib.vnet.diag.v1.SSHConfigurationReport ssh_configuration_report = 3; */ + if (message.report.oneofKind === "sshConfigurationReport") + SSHConfigurationReport.internalBinaryWrite(message.report.sshConfigurationReport, writer.tag(3, WireType.LengthDelimited).fork(), options).join(); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -841,3 +901,82 @@ class RouteConflict$Type extends MessageType { * @generated MessageType for protobuf message teleport.lib.vnet.diag.v1.RouteConflict */ export const RouteConflict = new RouteConflict$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class SSHConfigurationReport$Type extends MessageType { + constructor() { + super("teleport.lib.vnet.diag.v1.SSHConfigurationReport", [ + { no: 1, name: "user_openssh_config_path", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "vnet_ssh_config_path", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "user_openssh_config_includes_vnet_ssh_config", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 4, name: "user_openssh_config_exists", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 5, name: "user_openssh_config_contents", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + ]); + } + create(value?: PartialMessage): SSHConfigurationReport { + const message = globalThis.Object.create((this.messagePrototype!)); + message.userOpensshConfigPath = ""; + message.vnetSshConfigPath = ""; + message.userOpensshConfigIncludesVnetSshConfig = false; + message.userOpensshConfigExists = false; + message.userOpensshConfigContents = ""; + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SSHConfigurationReport): SSHConfigurationReport { + let message = target ?? this.create(), end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* string user_openssh_config_path */ 1: + message.userOpensshConfigPath = reader.string(); + break; + case /* string vnet_ssh_config_path */ 2: + message.vnetSshConfigPath = reader.string(); + break; + case /* bool user_openssh_config_includes_vnet_ssh_config */ 3: + message.userOpensshConfigIncludesVnetSshConfig = reader.bool(); + break; + case /* bool user_openssh_config_exists */ 4: + message.userOpensshConfigExists = reader.bool(); + break; + case /* string user_openssh_config_contents */ 5: + message.userOpensshConfigContents = reader.string(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) + (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: SSHConfigurationReport, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* string user_openssh_config_path = 1; */ + if (message.userOpensshConfigPath !== "") + writer.tag(1, WireType.LengthDelimited).string(message.userOpensshConfigPath); + /* string vnet_ssh_config_path = 2; */ + if (message.vnetSshConfigPath !== "") + writer.tag(2, WireType.LengthDelimited).string(message.vnetSshConfigPath); + /* bool user_openssh_config_includes_vnet_ssh_config = 3; */ + if (message.userOpensshConfigIncludesVnetSshConfig !== false) + writer.tag(3, WireType.Varint).bool(message.userOpensshConfigIncludesVnetSshConfig); + /* bool user_openssh_config_exists = 4; */ + if (message.userOpensshConfigExists !== false) + writer.tag(4, WireType.Varint).bool(message.userOpensshConfigExists); + /* string user_openssh_config_contents = 5; */ + if (message.userOpensshConfigContents !== "") + writer.tag(5, WireType.LengthDelimited).string(message.userOpensshConfigContents); + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.lib.vnet.diag.v1.SSHConfigurationReport + */ +export const SSHConfigurationReport = new SSHConfigurationReport$Type(); diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index f54565c2684e1..29341f7153470 100644 --- a/lib/teleterm/vnet/service.go +++ b/lib/teleterm/vnet/service.go @@ -20,6 +20,7 @@ import ( "context" "crypto/tls" "errors" + "os" "sync" "sync/atomic" "time" @@ -29,6 +30,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/types" prehogv1alpha "github.com/gravitational/teleport/gen/proto/go/prehog/v1alpha" apiteleterm "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1" @@ -90,6 +93,7 @@ type Config struct { // reporting. InstallationID string Clock clockwork.Clock + profilePath string } // CheckAndSetDefaults checks and sets the defaults @@ -110,6 +114,10 @@ func (c *Config) CheckAndSetDefaults() error { c.Clock = clockwork.NewRealClock() } + if c.profilePath == "" { + c.profilePath = profile.FullProfilePath(os.Getenv(types.HomeEnvVar)) + } + return nil } diff --git a/lib/teleterm/vnet/service_darwin.go b/lib/teleterm/vnet/service_darwin.go index 9eaddaf31dba8..362246bf292c9 100644 --- a/lib/teleterm/vnet/service_darwin.go +++ b/lib/teleterm/vnet/service_darwin.go @@ -62,10 +62,20 @@ func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsReq return nil, trace.Wrap(err) } + sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ + ProfilePath: s.cfg.profilePath, + }) + if err != nil { + return nil, trace.Wrap(err) + } + report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ Clock: s.cfg.Clock, NetworkStackAttempt: nsa, - DiagChecks: []diag.DiagCheck{routeConflictDiag}, + DiagChecks: []diag.DiagCheck{ + routeConflictDiag, + sshDiag, + }, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/vnet/diag/ssh.go b/lib/vnet/diag/ssh.go new file mode 100644 index 0000000000000..27be9b1afd741 --- /dev/null +++ b/lib/vnet/diag/ssh.go @@ -0,0 +1,253 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package diag + +import ( + "bufio" + "bytes" + "context" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "unicode/utf8" + + "github.com/dustin/go-humanize" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/utils/keypaths" + diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" +) + +const ( + maxOpenSSHConfigFileSize = 1 * 1024 * 1024 // 1 MiB +) + +// SSHConfig includes everything that [SSHDiag] needs to run. +type SSHConfig struct { + // ProfilePath is the path to the user profile (TELEPORT_HOME) where VNet's + // SSH configuration file is stored. + ProfilePath string +} + +// SSHDiag is a diagnostic check that inspects whether the default user OpenSSH +// config file includes VNet's generated SSH config file. +type SSHDiag struct { + cfg *SSHConfig + userHome string + userOpenSSHConfigPath string + vnetSSHConfigPath string + isWindows bool +} + +// NewSSHDiag returns a new [SSHDiag]. +func NewSSHDiag(cfg *SSHConfig) (*SSHDiag, error) { + userHome, ok := profile.UserHomeDir() + if !ok { + return nil, trace.Errorf("unable to find user's home directory") + } + userOpenSSHConfigPath := filepath.Join(userHome, ".ssh", "config") + vnetSSHConfigPath := filepath.Join(cfg.ProfilePath, keypaths.VNetSSHConfig) + return &SSHDiag{ + cfg: cfg, + userHome: userHome, + userOpenSSHConfigPath: userOpenSSHConfigPath, + vnetSSHConfigPath: vnetSSHConfigPath, + isWindows: runtime.GOOS == "windows", + }, nil +} + +// Commands returns no commands for this diagnostic. +func (d *SSHDiag) Commands(ctx context.Context) []*exec.Cmd { + return nil +} + +// EmptyCheckReport returns an empty SSH configuration report. +func (d *SSHDiag) EmptyCheckReport() *diagv1.CheckReport { + return &diagv1.CheckReport{Report: &diagv1.CheckReport_SshConfigurationReport{}} +} + +// Run runs the diagnostic. +func (d *SSHDiag) Run(ctx context.Context) (*diagv1.CheckReport, error) { + report, err := d.run(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + return &diagv1.CheckReport{ + // This intentionally always returns CHECK_REPORT_STATUS_OK even if + // ~/.ssh/config does not include the VNet generated SSH config. It is + // not mandatory to configure SSH and returning an error status would + // cause an alert and notification in Connect. + Status: diagv1.CheckReportStatus_CHECK_REPORT_STATUS_OK, + Report: &diagv1.CheckReport_SshConfigurationReport{ + SshConfigurationReport: report, + }, + }, nil +} + +func (d *SSHDiag) run(ctx context.Context) (*diagv1.SSHConfigurationReport, error) { + _, err := os.Stat(d.userOpenSSHConfigPath) + userOpenSSHConfigExists := err == nil + if !userOpenSSHConfigExists { + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.userOpenSSHConfigPath, + VnetSshConfigPath: d.vnetSSHConfigPath, + }, nil + } + + userOpenSSHConfigFile, err := os.Open(d.userOpenSSHConfigPath) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "opening %s for reading", d.userOpenSSHConfigPath) + } + defer userOpenSSHConfigFile.Close() + + userOpenSSHConfigContents, err := io.ReadAll(io.LimitReader(userOpenSSHConfigFile, maxOpenSSHConfigFileSize)) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "reading %s", d.userOpenSSHConfigPath) + } + if len(userOpenSSHConfigContents) == maxOpenSSHConfigFileSize { + return nil, trace.Errorf("%s is too large to (max size %s)", + d.userOpenSSHConfigPath, humanize.Bytes(maxOpenSSHConfigFileSize)) + } + if !utf8.Valid(userOpenSSHConfigContents) { + return nil, trace.Errorf("%s is not valid UTF-8", d.userOpenSSHConfigPath) + } + + included, err := d.openSSHConfigIncludesVNetSSHConfig(bytes.NewReader(userOpenSSHConfigContents)) + if err != nil { + return nil, trace.Wrap(err, "checking if the default user OpenSSH config includes VNet's SSH configuration") + } + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.userOpenSSHConfigPath, + VnetSshConfigPath: d.vnetSSHConfigPath, + UserOpensshConfigIncludesVnetSshConfig: included, + UserOpensshConfigExists: true, + UserOpensshConfigContents: string(userOpenSSHConfigContents), + }, nil +} + +func (d *SSHDiag) openSSHConfigIncludesVNetSSHConfig(r io.Reader) (bool, error) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + if d.openSSHConfigLineIncludesPath(scanner.Text(), d.vnetSSHConfigPath) { + return true, nil + } + } + return false, trace.Wrap(scanner.Err()) +} + +// openSSHConfigLineIncludesPath returns true if the given line of an OpenSSH +// configuration file is an include statement for the given path. +func (d *SSHDiag) openSSHConfigLineIncludesPath(line, wantPath string) bool { + wantPath = d.normalizePath(wantPath) + line = strings.TrimSpace(line) + + // Only consider lines that begin with "include" (case-insensitive). + i := strings.IndexFunc(line, isSpace) + if i == -1 { + return false + } + if strings.ToLower(line[:i]) != "include" { + return false + } + // Consider the rest of the line after "include". + line = line[i+1:] + + // Include lines may specify multiple pathnames and each pathname may + // contain glob wildcards, tokens, environment variables, ~, escaped + // characters and may or may not be quoted. This function does not support + // glob wildcards, tokens, or environment variables. It splits each argument + // at unescaped and unquoted whitespace and if the argument matches wantPath + // returns true. It does support ~ as an alias for the user's home + // directory. + var ( + // b is a running buffer holding the current argument as parsed up to + // the current point. + b strings.Builder + // quote holds the opening quote character if one has been found. + quote = byte(0) + ) +loop: + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case c == '\\' && i < len(line)-1 && canBeEscaped(line[i+1]): + // Skip the escape char and write the next char literally. + i++ + b.WriteByte(line[i]) + case quote == 0 && (c == '"' || c == '\''): + // Start of quote + quote = c + case quote != 0 && c == quote: + // End of quote + quote = 0 + case b.Len() == 0 && c == '~': + // Support ~ as an alias for the user's home directory. + b.WriteString(d.userHome) + case quote == 0 && c == '#': + // Found an unquoted comment in the middle of the line, ignore the rest. + break loop + case quote == 0 && isSpace(rune(c)): + // Reached the end of this argument, check if it matches wantPath. + if d.normalizePath(b.String()) == wantPath { + return true + } + b.Reset() + default: + // By default just append the current character to the current + // argument. + b.WriteByte(c) + } + } + if quote != 0 { + // Unmatched quote. + return false + } + // Handle an argument that ends at the end of the line. + return d.normalizePath(b.String()) == wantPath +} + +func (d *SSHDiag) normalizePath(path string) string { + if d.isWindows { + // Normalize all paths to use unix-style separators since OpenSSH + // supports / or \\ on Windows. + path = strings.ReplaceAll(path, `\`, `/`) + // Windows paths are case-insensitive. + path = strings.ToLower(path) + } + return filepath.Clean(path) +} + +func isSpace(r rune) bool { + switch r { + case ' ', '\t': + return true + } + return false +} + +func canBeEscaped(c byte) bool { + // https://github.com/openssh/openssh-portable/blob/5f761cdb2331a12318bde24db5ca84ee144a51d1/misc.c#L2089-L2099 + switch c { + case ' ', '\\', '\'', '"': + return true + } + return false +} diff --git a/lib/vnet/diag/ssh_test.go b/lib/vnet/diag/ssh_test.go new file mode 100644 index 0000000000000..2a1549a49c172 --- /dev/null +++ b/lib/vnet/diag/ssh_test.go @@ -0,0 +1,229 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package diag + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keypaths" + diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" +) + +// TestSSHDiag tests the SSH configuration diagnostic, specifically its ability +// to check whether an OpenSSH config file includes the VNet SSH config file. +func TestSSHDiag(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + desc string + profilePath string + userHome string + isWindows bool + input string + expect bool + }{ + { + desc: "empty", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + }, + { + desc: "macos tsh", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include /Users/user/.tsh/vnet_ssh_config`, + expect: true, + }, + { + desc: "macos tsh ~", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include ~/.tsh/vnet_ssh_config`, + expect: true, + }, + { + desc: "macos connect", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "macos connect ~", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include "~/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "macos tsh not match connect", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include "/Users/user/Application Support/Teleport Connect/tsh/vnet_ssh_config"`, + }, + { + desc: "macos connect not match tsh", + profilePath: `/Users/user/Application Support/Teleport Connect/tsh`, + userHome: `/Users/user`, + input: `Include /Users/user/.tsh/vnet_ssh_config`, + }, + { + desc: "windows tsh", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh unescaped", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\Users\User\.tsh\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh unix path", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:/Users/User/.tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh ~", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "~\\.tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect unescaped", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\Users\User\AppData\Roaming\Teleport Connect\tsh\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect unix path", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:/Users/User/AppData/Roaming/Teleport\ Connect/tsh/vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows connect ~", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "~\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + expect: true, + }, + { + desc: "windows tsh not match connect", + profilePath: `C:\Users\User\.tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\AppData\\Roaming\\Teleport\ Connect\\tsh\\vnet_ssh_config"`, + }, + { + desc: "windows connect not match tsh", + profilePath: `C:\Users\User\AppData\Roaming\Teleport Connect\tsh`, + userHome: `C:\Users\User`, + isWindows: true, + input: `Include "C:\\Users\\User\\.tsh\\vnet_ssh_config"`, + }, + { + desc: "some other file", + profilePath: `/Users/user/.tsh`, + input: `Include /Users/user/.tsh/ssh_config`, + }, + { + desc: "multiple includes", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: ` +Include ~/.ssh/include/* +Include /Users/user/ssh_config +Include /Users/user/.tsh/vnet_ssh_config +`, + expect: true, + }, + { + desc: "commented", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include #/Users/user/.tsh/vnet_ssh_config`, + }, + { + desc: "single quotes", + profilePath: `/Users/user/.tsh`, + userHome: `/Users/user`, + input: `Include '/Users/user/.tsh/vnet_ssh_config'`, + expect: true, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + diag, err := NewSSHDiag(&SSHConfig{ + ProfilePath: tc.profilePath, + }) + require.NoError(t, err) + userOpenSSHConfigPath := filepath.Join(t.TempDir(), "test_ssh_config") + + // Override isWindows and paths for the purpose of the test. + diag.isWindows = tc.isWindows + diag.userHome = tc.userHome + diag.userOpenSSHConfigPath = userOpenSSHConfigPath + + if len(tc.input) > 0 { + require.NoError(t, os.WriteFile(userOpenSSHConfigPath, []byte(tc.input), 0o600)) + } + + expectReport := &diagv1.CheckReport{ + Status: diagv1.CheckReportStatus_CHECK_REPORT_STATUS_OK, + Report: &diagv1.CheckReport_SshConfigurationReport{ + SshConfigurationReport: &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: userOpenSSHConfigPath, + VnetSshConfigPath: keypaths.VNetSSHConfigPath(tc.profilePath), + UserOpensshConfigIncludesVnetSshConfig: tc.expect, + UserOpensshConfigExists: len(tc.input) > 0, + UserOpensshConfigContents: tc.input, + }, + }, + } + + report, err := diag.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, expectReport, report) + }) + } +} diff --git a/proto/teleport/lib/vnet/diag/v1/diag.proto b/proto/teleport/lib/vnet/diag/v1/diag.proto index 6d96cf1958a78..ce2407ded2e03 100644 --- a/proto/teleport/lib/vnet/diag/v1/diag.proto +++ b/proto/teleport/lib/vnet/diag/v1/diag.proto @@ -107,6 +107,8 @@ message CheckReport { // route_conflict reports whether there are routes that might conflict with routes set up by // VNet. RouteConflictReport route_conflict_report = 2; + // ssh_configuration_report reports the status of the system's SSH configuration. + SSHConfigurationReport ssh_configuration_report = 3; } } @@ -158,3 +160,22 @@ message RouteConflict { // it's likely to be empty. string interface_app = 4; } + +// SSHConfigurationReport describes the state of the system's SSH configuration. +message SSHConfigurationReport { + // user_openssh_config_path is the full path to the user's default OpenSSH + // config file (~/.ssh/config). + string user_openssh_config_path = 1; + // vnet_ssh_config_path is the path to VNet's generated OpenSSH-compatible + // config file. + string vnet_ssh_config_path = 2; + // user_openssh_config_includes_vnet_ssh_config is true if the default + // OpenSSH user configuration file includes VNet's SSH config file. + bool user_openssh_config_includes_vnet_ssh_config = 3; + // user_openssh_config_exists is true if a file exists at + // user_openssh_config_path (~/.ssh/config). + bool user_openssh_config_exists = 4; + // user_openssh_config_contents contains the contents of the file at + // user_openssh_config_path if it exists. + string user_openssh_config_contents = 5; +} diff --git a/web/packages/teleterm/src/helpers.ts b/web/packages/teleterm/src/helpers.ts index 207bd43271f28..f21ec32989aad 100644 --- a/web/packages/teleterm/src/helpers.ts +++ b/web/packages/teleterm/src/helpers.ts @@ -26,6 +26,7 @@ import { WindowsDesktop } from 'gen-proto-ts/teleport/lib/teleterm/v1/windows_de import { CheckReport, RouteConflictReport, + SSHConfigurationReport, } from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; import { @@ -194,3 +195,12 @@ export function reportOneOfIsRouteConflictReport( } { return report.oneofKind === 'routeConflictReport'; } + +export function reportOneOfIsSSHConfigurationReport( + report: CheckReport['report'] +): report is { + oneofKind: 'sshConfigurationReport'; + sshConfigurationReport: SSHConfigurationReport; +} { + return report.oneofKind === 'sshConfigurationReport'; +} diff --git a/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap b/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap index 77776b66d752a..4d55bb3866f8a 100644 --- a/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap +++ b/web/packages/teleterm/src/services/vnet/__snapshots__/diag.test.ts.snap @@ -33,5 +33,21 @@ default link#25 UCSIg utun4 100.100.100.100 link#25 UHWIi utun4 \`\`\` + +--- +⚠️ VNet SSH is not configured. + + The user's default SSH configuration file does not include VNet's + generated configuration file and connections to VNet SSH hosts will + not work by default. + +| File description | Path | +| ------------------------ | ---- | +| User OpenSSH config file | ~/.ssh/config | +| VNet SSH config file | /Users/user/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config | + +~/.ssh/config does not exist + + " `; diff --git a/web/packages/teleterm/src/services/vnet/diag.test.ts b/web/packages/teleterm/src/services/vnet/diag.test.ts index 5d7d12ad1d3b8..937632b0386ac 100644 --- a/web/packages/teleterm/src/services/vnet/diag.test.ts +++ b/web/packages/teleterm/src/services/vnet/diag.test.ts @@ -29,10 +29,10 @@ import { describe('reportToText', () => { it('converts report correctly', () => { - const checkReport = makeCheckReport({ + const routeConflictReport = makeCheckReport({ status: diag.CheckReportStatus.ISSUES_FOUND, }); - checkReport.report = { + routeConflictReport.report = { oneofKind: 'routeConflictReport', routeConflictReport: { routeConflicts: [ @@ -51,12 +51,29 @@ describe('reportToText', () => { ], }, }; + const sshConfigReport = makeCheckReport({ + status: diag.CheckReportStatus.OK, + }); + sshConfigReport.report = { + oneofKind: 'sshConfigurationReport', + sshConfigurationReport: { + userOpensshConfigPath: '~/.ssh/config', + vnetSshConfigPath: + '/Users/user/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config', + userOpensshConfigIncludesVnetSshConfig: false, + userOpensshConfigExists: false, + userOpensshConfigContents: '', + }, + }; const report = makeReport({ checks: [ makeCheckAttempt({ - checkReport, + checkReport: routeConflictReport, commands: [makeCommandAttempt()], }), + makeCheckAttempt({ + checkReport: sshConfigReport, + }), ], }); diff --git a/web/packages/teleterm/src/services/vnet/diag.ts b/web/packages/teleterm/src/services/vnet/diag.ts index 6313c556c895f..98043f7293ecb 100644 --- a/web/packages/teleterm/src/services/vnet/diag.ts +++ b/web/packages/teleterm/src/services/vnet/diag.ts @@ -20,7 +20,10 @@ import { displayDateTime } from 'design/datetime'; import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb'; import * as diag from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; -import { reportOneOfIsRouteConflictReport } from 'teleterm/helpers'; +import { + reportOneOfIsRouteConflictReport, + reportOneOfIsSSHConfigurationReport, +} from 'teleterm/helpers'; export const hasReportFoundIssues = (report: diag.Report): boolean => report.checks.some( @@ -122,6 +125,10 @@ const reportOneofToDisplayDetails: Record< errorTitle: 'inspect network routes', reportToText: routeConflictReportToText, }, + sshConfigurationReport: { + errorTitle: 'inspect SSH configuration', + reportToText: sshConfigurationReportToText, + }, }; function routeConflictReportToText({ @@ -149,3 +156,43 @@ function routeConflictReportToText({ | ---------------- | ----------------------- | --------- | --------- | ${tableRows}`; } + +function sshConfigurationReportToText({ report }: diag.CheckReport): string { + if (!reportOneOfIsSSHConfigurationReport(report)) { + return null; + } + const { + userOpensshConfigPath, + vnetSshConfigPath, + userOpensshConfigIncludesVnetSshConfig, + userOpensshConfigExists, + userOpensshConfigContents, + } = report.sshConfigurationReport; + + const status = userOpensshConfigIncludesVnetSshConfig + ? '✅ VNet SSH is configured correctly.' + : `⚠️ VNet SSH is not configured. + + The user's default SSH configuration file does not include VNet's + generated configuration file and connections to VNet SSH hosts will + not work by default.`; + + const pathsTable = ` +| File description | Path | +| ------------------------ | ---- | +| User OpenSSH config file | ${userOpensshConfigPath} | +| VNet SSH config file | ${vnetSshConfigPath} |`; + + const currentContents = userOpensshConfigExists + ? `Current contents of ${userOpensshConfigPath}: + +\`\`\` +${userOpensshConfigContents} +\`\`\`` + : `${userOpensshConfigPath} does not exist`; + + return `${status} +${pathsTable} + +${currentContents}`; +} diff --git a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx index 3edf82f6683ff..e84383cdd321f 100644 --- a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx +++ b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.story.tsx @@ -26,6 +26,7 @@ import { CommandAttemptStatus, RouteConflict, RouteConflictReport, + SSHConfigurationReport, } from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; import { usePromiseRejectedOnUnmount } from 'shared/utils/wait'; @@ -48,6 +49,12 @@ import { ConnectionsContextProvider } from 'teleterm/ui/TopBar/Connections/conne import { DocumentVnetDiagReport as Component } from './DocumentVnetDiagReport'; import { useVnetContext, VnetContextProvider } from './vnetContext'; +const defaultUserSSHConfigContents = `Include "/Users/User/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config" + +Host github.com + IdentityFile ~/.ssh/id_ed25519 +`; + type StoryProps = { asText: boolean; networkStackAttempt: 'ok' | 'error'; @@ -56,6 +63,10 @@ type StoryProps = { routeConflictAttempt: 'ok' | 'issues-found' | 'error'; routeConflicts: RouteConflict[]; routeConflictCommandAttempt: 'ok' | 'error'; + sshConfigAttempt: 'ok' | 'error'; + sshConfigured: boolean; + userOpenSSHConfigExists: boolean; + userOpenSSHConfigContents: string; displayUnsupportedCheckAttempt: boolean; vnetRunning: boolean; reRunDiagnostics: 'success' | 'error' | 'processing'; @@ -91,6 +102,15 @@ const meta: Meta = { control: { type: 'inline-radio' }, options: ['ok', 'error'], }, + sshConfigAttempt: { + control: { type: 'inline-radio' }, + options: ['ok', 'error'], + }, + userOpenSSHConfigExists: {}, + userOpenSSHConfigContents: {}, + sshConfigured: { + control: { type: 'boolean' }, + }, displayUnsupportedCheckAttempt: { description: "Simulate the component receiving a report with a check attempt that's not supported in the current version", @@ -121,6 +141,10 @@ const meta: Meta = { }), ], routeConflictCommandAttempt: 'ok', + sshConfigAttempt: 'ok', + sshConfigured: false, + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: defaultUserSSHConfigContents, displayUnsupportedCheckAttempt: false, vnetRunning: true, reRunDiagnostics: 'success', @@ -208,6 +232,31 @@ export function DocumentVnetDiagReport(props: StoryProps) { } report.checks.push(routeConflictCheckAttempt); + const sshConfigReport: SSHConfigurationReport = { + userOpensshConfigIncludesVnetSshConfig: props.sshConfigured, + userOpensshConfigPath: '/Users/User/.ssh/config', + vnetSshConfigPath: + '/Users/User/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config', + userOpensshConfigExists: props.userOpenSSHConfigExists, + userOpensshConfigContents: props.userOpenSSHConfigContents, + }; + const sshConfigCheckAttempt = makeCheckAttempt({ + status: + props.sshConfigAttempt === 'ok' + ? CheckAttemptStatus.OK + : CheckAttemptStatus.ERROR, + error: + props.sshConfigAttempt === 'error' ? 'something went wrong' : undefined, + checkReport: makeCheckReport({ + status: CheckReportStatus.OK, + report: { + oneofKind: 'sshConfigurationReport', + sshConfigurationReport: sshConfigReport, + }, + }), + }); + report.checks.push(sshConfigCheckAttempt); + if (props.displayUnsupportedCheckAttempt) { report.checks.push({ status: CheckAttemptStatus.OK, diff --git a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx index a74ed7feeb4c4..b90f34f0dfd40 100644 --- a/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx +++ b/web/packages/teleterm/src/ui/Vnet/DocumentVnetDiagReport.tsx @@ -21,7 +21,7 @@ import styled from 'styled-components'; import { Button, Alert as DesignAlert, Flex, H1, Link, Stack } from 'design'; import { AlertProps } from 'design/Alert/Alert'; -import Table, { TextCell } from 'design/DataTable'; +import Table, { Cell, TextCell } from 'design/DataTable'; import { displayDateTime } from 'design/datetime'; import { Copy, @@ -35,10 +35,14 @@ import { HoverTooltip } from 'design/Tooltip'; import { copyToClipboard } from 'design/utils/copyToClipboard'; import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb'; import * as diag from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; +import { TextSelectCopy } from 'shared/components/TextSelectCopy'; import { CanceledError, useAsync } from 'shared/hooks/useAsync'; import { pluralize } from 'shared/utils/text'; -import { reportOneOfIsRouteConflictReport } from 'teleterm/helpers'; +import { + reportOneOfIsRouteConflictReport, + reportOneOfIsSSHConfigurationReport, +} from 'teleterm/helpers'; import { getReportFilename, reportToText } from 'teleterm/services/vnet/diag'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import Document from 'teleterm/ui/Document'; @@ -297,6 +301,10 @@ const reportOneofDisplayDetails: Record< errorTitle: 'inspect network routes', Component: CheckReportRouteConflict, }, + sshConfigurationReport: { + errorTitle: 'inspect SSH configuration', + Component: CheckReportSSHConfiguration, + }, }; /** @@ -367,6 +375,91 @@ function CheckReportRouteConflict({ ); } +function CheckReportSSHConfiguration({ + checkReport: { report }, +}: { + checkReport: diag.CheckReport; +}) { + if (!reportOneOfIsSSHConfigurationReport(report)) { + return null; + } + const { + userOpensshConfigPath, + vnetSshConfigPath, + userOpensshConfigIncludesVnetSshConfig, + userOpensshConfigExists, + userOpensshConfigContents, + } = report.sshConfigurationReport; + const pathsTable = ( + ( + + {row.path} + + ), + }, + ]} + /> + ); + if (userOpensshConfigIncludesVnetSshConfig) { + return ( + <> + + + VNet SSH is configured correctly. + + + The user's default SSH configuration file correctly includes VNet's + generated configuration file. + + + {pathsTable} + + ); + } + return ( + <> + + + VNet SSH is not configured. + + + The user's default SSH configuration file does not include VNet's + generated SSH configuration file. SSH clients will not be able to make + connections to VNet SSH addresses by default. Add the following line + to {userOpensshConfigPath} to configure + OpenSSH-compatible clients for VNet: + + + + {userOpensshConfigExists ? ( +
+ + Current contents of {userOpensshConfigPath} + +
{userOpensshConfigContents}
+
+ ) : null} + + ); +} + const Summary = styled.summary` cursor: pointer; `;