[vnet] feat: add SSH configuration diagnostic (#55594)

* [vnet] feat: add SSH configuration diagnostic

This commit adds a VNet diagnostic that reports whether the default user
OpenSSH config file (`~/.ssh/config`) includes VNet's generated SSH
config file.

* fix lint

* fix license

* Render paths as code, use TextSelectCopy for snippet

* add code stying for path

* add ssh report to diag story

* handle ~/.ssh/config not existing

* handle unescaped windows path separators and single quotes

* fix typo in comment

* Diag report story: Include error when sshConfigAttempt is set to "error"

* read ~/.ssh/config as part of the diag instead of abusing cat

* fix lint

---------

Co-authored-by: Rafał Cieślak <[email protected]>
This commit is contained in:
Nic Klaassen
2025-06-18 22:20:50 +00:00
committed by GitHub
co-authored by Rafał Cieślak
parent 57c909dab1
commit a61a4874c7
15 changed files with 1060 additions and 47 deletions
+18 -11
View File
@@ -317,21 +317,28 @@ 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 = os.TempDir()
if u, err := user.Current(); err == nil && u.HomeDir != "" {
home = u.HomeDir
home, ok := UserHomeDir()
if !ok {
home = os.TempDir()
}
return filepath.Join(home, profileDir)
}
// 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 != "" {
return u.HomeDir, true
}
return "", false
}
// FromDir reads the user profile from a given directory. If dir is empty,
// this function defaults to the default tsh profile directory. If name is empty,
// this function defaults to loading the currently active profile (if any).
+3 -3
View File
@@ -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.
+139 -25
View File
@@ -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,
},
+140 -1
View File
@@ -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<CheckReport> {
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>): CheckReport {
@@ -624,6 +675,12 @@ class CheckReport$Type extends MessageType<CheckReport> {
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<CheckReport> {
/* 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<RouteConflict> {
* @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<SSHConfigurationReport> {
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>): SSHConfigurationReport {
const message = globalThis.Object.create((this.messagePrototype!));
message.userOpensshConfigPath = "";
message.vnetSshConfigPath = "";
message.userOpensshConfigIncludesVnetSshConfig = false;
message.userOpensshConfigExists = false;
message.userOpensshConfigContents = "";
if (value !== undefined)
reflectionMergePartial<SSHConfigurationReport>(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();
+8
View File
@@ -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
}
+11 -1
View File
@@ -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)
+253
View File
@@ -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 <http://www.gnu.org/licenses/>.
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
}
+229
View File
@@ -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 <http://www.gnu.org/licenses/>.
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)
})
}
}
@@ -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;
}
+10
View File
@@ -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';
}
@@ -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
"
`;
@@ -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,
}),
],
});
@@ -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}`;
}
@@ -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<StoryProps> = {
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<StoryProps> = {
}),
],
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,
@@ -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 = (
<Table
emptyText=""
data={[
{
desc: 'User OpenSSH config file',
path: userOpensshConfigPath,
},
{
desc: 'VNet SSH config file',
path: vnetSshConfigPath,
},
]}
columns={[
{ key: 'desc', headerText: 'File description' },
{
key: 'path',
headerText: 'Path',
render: row => (
<Cell>
<code>{row.path}</code>
</Cell>
),
},
]}
/>
);
if (userOpensshConfigIncludesVnetSshConfig) {
return (
<>
<Stack>
<P1>
<Success /> VNet SSH is configured correctly.
</P1>
<P2>
The user's default SSH configuration file correctly includes VNet's
generated configuration file.
</P2>
</Stack>
{pathsTable}
</>
);
}
return (
<>
<Stack>
<P1>
<Warning /> VNet SSH is not configured.
</P1>
<P2 m={0}>
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 <code>{userOpensshConfigPath}</code> to configure
OpenSSH-compatible clients for VNet:
</P2>
<TextSelectCopy text={`Include "${vnetSshConfigPath}"`} bash={false} />
</Stack>
{userOpensshConfigExists ? (
<details>
<Summary>
Current contents of <code>{userOpensshConfigPath}</code>
</Summary>
<Pre>{userOpensshConfigContents}</Pre>
</details>
) : null}
</>
);
}
const Summary = styled.summary`
cursor: pointer;
`;