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 6e0bc36d1e3c7..ff619e49ebd8a 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -73,6 +73,18 @@ const ( profileFileExt = ".yaml" // oracleWalletDirSuffix is the suffix of the oracle wallet database directory. oracleWalletDirSuffix = "-wallet" + // VNetClientSSHKey is the file name of the SSH key used by third-party SSH + // clients to connect to VNet SSH. + VNetClientSSHKey = "id_vnet" + // VNetClientSSHKeyPub is the file name of the SSH public key matching + // VNetClientSSHKey. + VNetClientSSHKeyPub = VNetClientSSHKey + fileExtPub + // 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 + // file to be used by third-party SSH clients connecting to VNet SSH. + VNetSSHConfig = "vnet_ssh_config" ) // Here's the file layout of all these keypaths. @@ -81,6 +93,10 @@ const ( // ├── one.example.com.yaml --> file containing profile details for proxy "one.example.com" // ├── two.example.com.yaml --> file containing profile details for proxy "two.example.com" // ├── known_hosts --> trusted certificate authorities (their keys) in a format similar to known_hosts +// ├── id_vnet --> SSH Private Key for third-party clients of VNet SSH +// ├── id_vnet.pub --> SSH Public Key for third-party clients of VNet SSH +// ├── vnet_known_hosts --> trusted certificate authorities (their keys) for third-party clients of VNet SSH +// ├── vnet_ssh_config --> OpenSSH-compatible config file for third-party clients of VNet SSH // └── keys --> session keys directory // ├── one.example.com --> Proxy hostname // │ ├── certs.pem --> TLS CA certs for the Teleport CA @@ -429,6 +445,27 @@ func IdentitySSHCertPath(path string) string { return path + fileExtSSHCert } +// VNetClientSSHKeyPath returns the path to the VNet client SSH private key. +func VNetClientSSHKeyPath(baseDir string) string { + return filepath.Join(baseDir, VNetClientSSHKey) +} + +// VNetClientSSHKeyPubPath returns the path to the VNet client SSH public key. +func VNetClientSSHKeyPubPath(baseDir string) string { + return filepath.Join(baseDir, VNetClientSSHKeyPub) +} + +// VNetKnownHostsPath returns the path to the VNet known_hosts file. +func VNetKnownHostsPath(baseDir string) string { + return filepath.Join(baseDir, vnetKnownHosts) +} + +// VNetSSHConfigPath returns the path to VNet's generated OpenSSH-compatible +// config file. +func VNetSSHConfigPath(baseDir string) string { + return filepath.Join(baseDir, VNetSSHConfig) +} + // TrimKeyPathSuffix returns the given path with any key suffix/extension trimmed off. func TrimKeyPathSuffix(path string) string { return strings.TrimSuffix(path, fileExtTLSKey) diff --git a/docs/img/vnet/configure-ssh-clients.png b/docs/img/vnet/configure-ssh-clients.png new file mode 100644 index 0000000000000..522856f80bfbb Binary files /dev/null and b/docs/img/vnet/configure-ssh-clients.png differ diff --git a/docs/img/vnet/how-it-works.svg b/docs/img/vnet/how-it-works.svg index e37da9d95d0e8..c3f47d4d8d486 100644 --- a/docs/img/vnet/how-it-works.svg +++ b/docs/img/vnet/how-it-works.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/img/vnet/ssh-connect.png b/docs/img/vnet/ssh-connect.png new file mode 100644 index 0000000000000..2baeee20e737f Binary files /dev/null and b/docs/img/vnet/ssh-connect.png differ diff --git a/docs/img/vnet/start-vnet.png b/docs/img/vnet/start-vnet.png new file mode 100644 index 0000000000000..81d03e073624b Binary files /dev/null and b/docs/img/vnet/start-vnet.png differ diff --git a/docs/pages/connect-your-client/teleport-connect.mdx b/docs/pages/connect-your-client/teleport-connect.mdx index bd666ab4ba784..94df94e6d9d22 100644 --- a/docs/pages/connect-your-client/teleport-connect.mdx +++ b/docs/pages/connect-your-client/teleport-connect.mdx @@ -87,6 +87,9 @@ A new tab will open with a shell session on the chosen server. Alternatively, you can look for the server in the search bar and press `Enter` to connect to it. +If you'd prefer to connect to SSH servers with a third-party SSH client or your +editor's Remote Development feature, read the [VNet guide](./vnet.mdx) to learn how. + ## Opening a local terminal To open a terminal with a local shell session, either select "Open new terminal" from the additional diff --git a/docs/pages/connect-your-client/vnet.mdx b/docs/pages/connect-your-client/vnet.mdx index 08ae51f386047..eea8f885c40df 100644 --- a/docs/pages/connect-your-client/vnet.mdx +++ b/docs/pages/connect-your-client/vnet.mdx @@ -3,23 +3,31 @@ title: Using VNet description: Using VNet --- -This guide explains how to use VNet to connect to TCP applications available through Teleport. +This guide explains how to use VNet to connect to TCP applications and SSH +servers available through Teleport. ## How it works -VNet automatically proxies connections from your computer to TCP apps available -through Teleport. -A program on your device can securely connect to internal applications protected +VNet automatically proxies connections from your computer to TCP apps and SSH +servers available through Teleport. +A program on your device can securely connect to resources protected by Teleport without having to know about Teleport authentication details. Underneath, VNet authenticates the connection with your Teleport credentials and -securely tunnels the TCP connection to your application. +securely tunnels the connection. This is all done client-side – VNet sets up a local DNS name server that -intercepts DNS requests for your internal apps and responds with a virtual IP -address managed by VNet that will forward the connection to your application. +intercepts DNS requests for your Teleport resources and responds with a virtual IP +address managed by VNet that will handle the connection. + +VNet's SSH support enables third-party SSH clients to connect to Teleport SSH +servers with minimal configuration required, while still offering Teleport +access controls and features like [Per-session MFA](../admin-guides/access-controls/guides/per-session-mfa.mdx) +and [Hardware Key Support](../admin-guides/access-controls/guides/hardware-key-support.mdx). ![Diagram showing VNet architecture](../../img/vnet/how-it-works.svg) -VNet delivers an experience like a VPN for your TCP applications through this local virtual network, while maintaining all of Teleport's identity verification and zero trust features that traditional VPNs cannot provide. +VNet delivers an experience like a VPN through this local virtual network, +while maintaining all of Teleport's identity verification and zero trust +features that traditional VPNs cannot provide. VNet is available on macOS and Windows in Teleport Connect and tsh, with plans for Linux support in a future version. @@ -56,17 +64,21 @@ following mitigations for DNS rebinding attacks: -## Step 1/3. Start Teleport Connect +## Step 1/3. Start VNet -Open Teleport Connect and log in to the cluster. Find the TCP app you want to connect to. TCP apps -have `tcp://` as the protocol in their addresses. +Open Teleport Connect and log in to your cluster. +See [Using Teleport Connect](./teleport-connect.mdx) if you haven't used the +Teleport Connect app before. -![Resource list in Teleport Connect with a TCP hovered over](../../img/use-teleport/vnet-resources-list@2x.png) +Open the **connection list** in the top left and click the icon to start VNet. +Or, skip this step and VNet will start automatically when you click "Connect" +on a TCP app or "Connect with VNet" on an SSH server. -## Step 2/3. Start VNet +![VNet shown in connection list](../../img/vnet/start-vnet.png) -Click "Connect" next to the TCP app. This starts VNet if it's not already running. Alternatively, -you can start VNet through the connection list in the top left. +After VNet has been started once it will automatically start every time +Teleport Connect is opened, unless you stop VNet before closing Teleport +Connect.
First launch on macOS @@ -78,15 +90,28 @@ tsh.app under "Allow in the Background". ![VNet starting up](../../img/use-teleport/vnet-starting@2x.png)
-## Step 3/3. Connect +## Step 2/3. Connect to a TCP app + +Find the TCP app you want to connect to. +TCP apps have `tcp://` as the protocol in their address. -Once VNet is running, you can connect to the application using the application client you would +![Resource list in Teleport Connect with a TCP app hovered over](../../img/use-teleport/vnet-resources-list@2x.png) + +Click "Connect" next to the TCP app. +This will start VNet if it's not already running, and then copy the app's +address to your clipboard. +You can now connect to the application using the application client you would normally use to connect to it. ```code $ psql postgres://postgres@tcp-app.teleport.example.com/postgres ``` +As long as VNet is running in the background, clicking "Connect" next to each +app is not necessary. +You can directly connect to all of your TCP apps without any actions in +Teleport Connect. + Unless the application specifies [multiple ports](../enroll-resources/application-access/guides/tcp.mdx#configuring-access-to-multiple-ports), @@ -98,19 +123,52 @@ If [per-session MFA](../admin-guides/access-controls/guides/per-session-mfa.mdx) first connection over each port triggers an MFA check. -VNet is going to automatically start on the next Teleport Connect launch, unless you stop VNet -before closing Teleport Connect. +## Step 3/3. Connect to an SSH server + +Find the SSH server you want to connect to, open the menu next to the "Connect" +dropdown, and click "Connect with VNet". +This will start VNet if it's not already running, and then copy the VNet +address for the server to your clipboard. + +![SSH server in Teleport Connect with "Connect with VNet" menu open](../../img/vnet/ssh-connect.png) + +There is a one-time configuration step required before SSH clients will be able +to connect to Teleport SSH servers through VNet. +When you click "Connect with VNet" on an SSH server, Teleport Connect will +automatically check if this configuration is present and walk you through it if +necessary. + +![SSH client configuration modal in Teleport Connect](../../img/vnet/configure-ssh-clients.png) + +Once the configuration step is complete, any OpenSSH-compatible client that +reads configuration options from `~/.ssh/config` should be able to connect to +Teleport SSH servers. +Try connecting with the standard `ssh` client or the Remote Development feature +in editors like Visual Studio Code or Zed. + +```code +$ ssh @. +``` + +As long as VNet is running in the background, clicking "Connect with VNet" next +to each SSH server is not necessary, you can directly connect to all of your +Teleport SSH servers without any actions in Teleport Connect. ## `tsh` support -VNet is available in `tsh` as well. Using it involves logging into the cluster and executing the -command `tsh vnet`. +VNet is also available in `tsh` without running Teleport Connect. +To use it, log in and then run `tsh vnet`. ```code $ tsh login --proxy=teleport.example.com $ tsh vnet ``` +While `tsh` support is available, Teleport Connect is the preferred application +for running VNet. +Teleport Connect offers better visibility for MFA prompts and cluster logins, and +automatically runs diagnostics that are useful for troubleshooting. + ## Troubleshooting ### Conflicting IPv4 ranges @@ -295,3 +353,4 @@ Before version 18.0.0, VNet logs were saved in `C:\Program Files\Teleport Connec - Read our VNet configuration [guide](../enroll-resources/application-access/guides/vnet.mdx) to learn how to configure VNet access to your applications. - Read [RFD 163](https://github.com/gravitational/teleport/blob/master/rfd/0163-vnet.md) to learn how VNet works on a technical level. +- Read [RFD 207](https://github.com/gravitational/teleport/blob/master/rfd/0207-vnet-ssh.md) to learn how VNet SSH access works. diff --git a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go index 39c2f91caecc4..e87f534e7c998 100644 --- a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service.pb.go @@ -248,27 +248,27 @@ func (*StopResponse) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{3} } -// Request for ListDNSZones. -type ListDNSZonesRequest struct { +// Request for GetServiceInfo. +type GetServiceInfoRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListDNSZonesRequest) Reset() { - *x = ListDNSZonesRequest{} +func (x *GetServiceInfoRequest) Reset() { + *x = GetServiceInfoRequest{} mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListDNSZonesRequest) String() string { +func (x *GetServiceInfoRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListDNSZonesRequest) ProtoMessage() {} +func (*GetServiceInfoRequest) ProtoMessage() {} -func (x *ListDNSZonesRequest) ProtoReflect() protoreflect.Message { +func (x *GetServiceInfoRequest) ProtoReflect() protoreflect.Message { mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -280,34 +280,43 @@ func (x *ListDNSZonesRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListDNSZonesRequest.ProtoReflect.Descriptor instead. -func (*ListDNSZonesRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use GetServiceInfoRequest.ProtoReflect.Descriptor instead. +func (*GetServiceInfoRequest) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{4} } -// Response for ListDNSZones. -type ListDNSZonesResponse struct { +// GetServiceInfoResponse contains the status of the running VNet service. +type GetServiceInfoResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // dns_zones is a deduplicated list of DNS zones. - DnsZones []string `protobuf:"bytes,1,rep,name=dns_zones,json=dnsZones,proto3" json:"dns_zones,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ListDNSZonesResponse) Reset() { - *x = ListDNSZonesResponse{} + // app_dns_zones is a deduplicated list of all DNS zones valid as DNS + // suffixes for connections to TCP apps. + AppDnsZones []string `protobuf:"bytes,1,rep,name=app_dns_zones,json=appDnsZones,proto3" json:"app_dns_zones,omitempty"` + // clusters is a list of cluster names valid as DNS suffixes for SSH hosts. + Clusters []string `protobuf:"bytes,2,rep,name=clusters,proto3" json:"clusters,omitempty"` + // ssh_configured is true if the user's SSH config file includes VNet's + // generated SSH config necessary for SSH access. + SshConfigured bool `protobuf:"varint,3,opt,name=ssh_configured,json=sshConfigured,proto3" json:"ssh_configured,omitempty"` + // vnet_ssh_config_path is the path of VNet's generated OpenSSH-compatible + // config file. + VnetSshConfigPath string `protobuf:"bytes,4,opt,name=vnet_ssh_config_path,json=vnetSshConfigPath,proto3" json:"vnet_ssh_config_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetServiceInfoResponse) Reset() { + *x = GetServiceInfoResponse{} mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListDNSZonesResponse) String() string { +func (x *GetServiceInfoResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListDNSZonesResponse) ProtoMessage() {} +func (*GetServiceInfoResponse) ProtoMessage() {} -func (x *ListDNSZonesResponse) ProtoReflect() protoreflect.Message { +func (x *GetServiceInfoResponse) ProtoReflect() protoreflect.Message { mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -319,18 +328,39 @@ func (x *ListDNSZonesResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListDNSZonesResponse.ProtoReflect.Descriptor instead. -func (*ListDNSZonesResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use GetServiceInfoResponse.ProtoReflect.Descriptor instead. +func (*GetServiceInfoResponse) Descriptor() ([]byte, []int) { return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{5} } -func (x *ListDNSZonesResponse) GetDnsZones() []string { +func (x *GetServiceInfoResponse) GetAppDnsZones() []string { + if x != nil { + return x.AppDnsZones + } + return nil +} + +func (x *GetServiceInfoResponse) GetClusters() []string { if x != nil { - return x.DnsZones + return x.Clusters } return nil } +func (x *GetServiceInfoResponse) GetSshConfigured() bool { + if x != nil { + return x.SshConfigured + } + return false +} + +func (x *GetServiceInfoResponse) GetVnetSshConfigPath() string { + if x != nil { + return x.VnetSshConfigPath + } + return "" +} + // Request for GetBackgroundItemStatus. type GetBackgroundItemStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -495,6 +525,80 @@ func (x *RunDiagnosticsResponse) GetReport() *v1.Report { return nil } +// Request for AutoConfigureSSH. +type AutoConfigureSSHRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AutoConfigureSSHRequest) Reset() { + *x = AutoConfigureSSHRequest{} + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AutoConfigureSSHRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AutoConfigureSSHRequest) ProtoMessage() {} + +func (x *AutoConfigureSSHRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AutoConfigureSSHRequest.ProtoReflect.Descriptor instead. +func (*AutoConfigureSSHRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{10} +} + +// Response for AutoConfigureSSH. +type AutoConfigureSSHResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AutoConfigureSSHResponse) Reset() { + *x = AutoConfigureSSHResponse{} + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AutoConfigureSSHResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AutoConfigureSSHResponse) ProtoMessage() {} + +func (x *AutoConfigureSSHResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AutoConfigureSSHResponse.ProtoReflect.Descriptor instead. +func (*AutoConfigureSSHResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP(), []int{11} +} + var File_teleport_lib_teleterm_vnet_v1_vnet_service_proto protoreflect.FileDescriptor const file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = "" + @@ -503,29 +607,35 @@ const file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc = "" + "\fStartRequest\"\x0f\n" + "\rStartResponse\"\r\n" + "\vStopRequest\"\x0e\n" + - "\fStopResponse\"\x15\n" + - "\x13ListDNSZonesRequest\"3\n" + - "\x14ListDNSZonesResponse\x12\x1b\n" + - "\tdns_zones\x18\x01 \x03(\tR\bdnsZones\" \n" + + "\fStopResponse\"\x17\n" + + "\x15GetServiceInfoRequest\"\xb0\x01\n" + + "\x16GetServiceInfoResponse\x12\"\n" + + "\rapp_dns_zones\x18\x01 \x03(\tR\vappDnsZones\x12\x1a\n" + + "\bclusters\x18\x02 \x03(\tR\bclusters\x12%\n" + + "\x0essh_configured\x18\x03 \x01(\bR\rsshConfigured\x12/\n" + + "\x14vnet_ssh_config_path\x18\x04 \x01(\tR\x11vnetSshConfigPath\" \n" + "\x1eGetBackgroundItemStatusRequest\"n\n" + "\x1fGetBackgroundItemStatusResponse\x12K\n" + "\x06status\x18\x01 \x01(\x0e23.teleport.lib.teleterm.vnet.v1.BackgroundItemStatusR\x06status\"\x17\n" + "\x15RunDiagnosticsRequest\"S\n" + "\x16RunDiagnosticsResponse\x129\n" + - "\x06report\x18\x01 \x01(\v2!.teleport.lib.vnet.diag.v1.ReportR\x06report*\x8b\x02\n" + + "\x06report\x18\x01 \x01(\v2!.teleport.lib.vnet.diag.v1.ReportR\x06report\"\x19\n" + + "\x17AutoConfigureSSHRequest\"\x1a\n" + + "\x18AutoConfigureSSHResponse*\x8b\x02\n" + "\x14BackgroundItemStatus\x12&\n" + "\"BACKGROUND_ITEM_STATUS_UNSPECIFIED\x10\x00\x12)\n" + "%BACKGROUND_ITEM_STATUS_NOT_REGISTERED\x10\x01\x12\"\n" + "\x1eBACKGROUND_ITEM_STATUS_ENABLED\x10\x02\x12,\n" + "(BACKGROUND_ITEM_STATUS_REQUIRES_APPROVAL\x10\x03\x12$\n" + " BACKGROUND_ITEM_STATUS_NOT_FOUND\x10\x04\x12(\n" + - "$BACKGROUND_ITEM_STATUS_NOT_SUPPORTED\x10\x052\xe5\x04\n" + + "$BACKGROUND_ITEM_STATUS_NOT_SUPPORTED\x10\x052\xf1\x05\n" + "\vVnetService\x12b\n" + "\x05Start\x12+.teleport.lib.teleterm.vnet.v1.StartRequest\x1a,.teleport.lib.teleterm.vnet.v1.StartResponse\x12_\n" + - "\x04Stop\x12*.teleport.lib.teleterm.vnet.v1.StopRequest\x1a+.teleport.lib.teleterm.vnet.v1.StopResponse\x12w\n" + - "\fListDNSZones\x122.teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest\x1a3.teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse\x12\x98\x01\n" + + "\x04Stop\x12*.teleport.lib.teleterm.vnet.v1.StopRequest\x1a+.teleport.lib.teleterm.vnet.v1.StopResponse\x12}\n" + + "\x0eGetServiceInfo\x124.teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest\x1a5.teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse\x12\x98\x01\n" + "\x17GetBackgroundItemStatus\x12=.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest\x1a>.teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse\x12}\n" + - "\x0eRunDiagnostics\x124.teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest\x1a5.teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponseBUZSgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1;vnetv1b\x06proto3" + "\x0eRunDiagnostics\x124.teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest\x1a5.teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse\x12\x83\x01\n" + + "\x10AutoConfigureSSH\x126.teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest\x1a7.teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponseBUZSgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1;vnetv1b\x06proto3" var ( file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescOnce sync.Once @@ -540,36 +650,40 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDescGZIP() []byte } var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_goTypes = []any{ (BackgroundItemStatus)(0), // 0: teleport.lib.teleterm.vnet.v1.BackgroundItemStatus (*StartRequest)(nil), // 1: teleport.lib.teleterm.vnet.v1.StartRequest (*StartResponse)(nil), // 2: teleport.lib.teleterm.vnet.v1.StartResponse (*StopRequest)(nil), // 3: teleport.lib.teleterm.vnet.v1.StopRequest (*StopResponse)(nil), // 4: teleport.lib.teleterm.vnet.v1.StopResponse - (*ListDNSZonesRequest)(nil), // 5: teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest - (*ListDNSZonesResponse)(nil), // 6: teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + (*GetServiceInfoRequest)(nil), // 5: teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest + (*GetServiceInfoResponse)(nil), // 6: teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse (*GetBackgroundItemStatusRequest)(nil), // 7: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest (*GetBackgroundItemStatusResponse)(nil), // 8: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse (*RunDiagnosticsRequest)(nil), // 9: teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest (*RunDiagnosticsResponse)(nil), // 10: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse - (*v1.Report)(nil), // 11: teleport.lib.vnet.diag.v1.Report + (*AutoConfigureSSHRequest)(nil), // 11: teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + (*AutoConfigureSSHResponse)(nil), // 12: teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + (*v1.Report)(nil), // 13: teleport.lib.vnet.diag.v1.Report } var file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_depIdxs = []int32{ 0, // 0: teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse.status:type_name -> teleport.lib.teleterm.vnet.v1.BackgroundItemStatus - 11, // 1: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse.report:type_name -> teleport.lib.vnet.diag.v1.Report + 13, // 1: teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse.report:type_name -> teleport.lib.vnet.diag.v1.Report 1, // 2: teleport.lib.teleterm.vnet.v1.VnetService.Start:input_type -> teleport.lib.teleterm.vnet.v1.StartRequest 3, // 3: teleport.lib.teleterm.vnet.v1.VnetService.Stop:input_type -> teleport.lib.teleterm.vnet.v1.StopRequest - 5, // 4: teleport.lib.teleterm.vnet.v1.VnetService.ListDNSZones:input_type -> teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + 5, // 4: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:input_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest 7, // 5: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:input_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusRequest 9, // 6: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:input_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest - 2, // 7: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse - 4, // 8: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse - 6, // 9: teleport.lib.teleterm.vnet.v1.VnetService.ListDNSZones:output_type -> teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse - 8, // 10: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:output_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse - 10, // 11: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:output_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse - 7, // [7:12] is the sub-list for method output_type - 2, // [2:7] is the sub-list for method input_type + 11, // 7: teleport.lib.teleterm.vnet.v1.VnetService.AutoConfigureSSH:input_type -> teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + 2, // 8: teleport.lib.teleterm.vnet.v1.VnetService.Start:output_type -> teleport.lib.teleterm.vnet.v1.StartResponse + 4, // 9: teleport.lib.teleterm.vnet.v1.VnetService.Stop:output_type -> teleport.lib.teleterm.vnet.v1.StopResponse + 6, // 10: teleport.lib.teleterm.vnet.v1.VnetService.GetServiceInfo:output_type -> teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse + 8, // 11: teleport.lib.teleterm.vnet.v1.VnetService.GetBackgroundItemStatus:output_type -> teleport.lib.teleterm.vnet.v1.GetBackgroundItemStatusResponse + 10, // 12: teleport.lib.teleterm.vnet.v1.VnetService.RunDiagnostics:output_type -> teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse + 12, // 13: teleport.lib.teleterm.vnet.v1.VnetService.AutoConfigureSSH:output_type -> teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + 8, // [8:14] is the sub-list for method output_type + 2, // [2:8] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name @@ -586,7 +700,7 @@ func file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc), len(file_teleport_lib_teleterm_vnet_v1_vnet_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go index d14f95a833ae4..074693d5c6c11 100644 --- a/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/vnet/v1/vnet_service_grpc.pb.go @@ -37,9 +37,10 @@ const _ = grpc.SupportPackageIsVersion9 const ( VnetService_Start_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Start" VnetService_Stop_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/Stop" - VnetService_ListDNSZones_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/ListDNSZones" + VnetService_GetServiceInfo_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetServiceInfo" VnetService_GetBackgroundItemStatus_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/GetBackgroundItemStatus" VnetService_RunDiagnostics_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/RunDiagnostics" + VnetService_AutoConfigureSSH_FullMethodName = "/teleport.lib.teleterm.vnet.v1.VnetService/AutoConfigureSSH" ) // VnetServiceClient is the client API for VnetService service. @@ -52,22 +53,17 @@ type VnetServiceClient interface { Start(ctx context.Context, in *StartRequest, opts ...grpc.CallOption) (*StartResponse, error) // Stop stops VNet. Stop(ctx context.Context, in *StopRequest, opts ...grpc.CallOption) (*StopResponse, error) - // ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - // includes the proxy service hostnames and custom DNS zones configured in vnet_config. - // - // This is fetched independently of what the Electron app thinks the current state of the cluster - // looks like, since the VNet admin process also fetches this data independently of the Electron - // app. - // - // Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - // be fetched (due to e.g., a network error or an expired cert). - ListDNSZones(ctx context.Context, in *ListDNSZonesRequest, opts ...grpc.CallOption) (*ListDNSZonesResponse, error) + // GetServiceInfo returns info about the running VNet service. + GetServiceInfo(ctx context.Context, in *GetServiceInfoRequest, opts ...grpc.CallOption) (*GetServiceInfoResponse, error) // GetBackgroundItemStatus returns the status of the background item responsible for launching // VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. GetBackgroundItemStatus(ctx context.Context, in *GetBackgroundItemStatusRequest, opts ...grpc.CallOption) (*GetBackgroundItemStatusResponse, error) // RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that // is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. RunDiagnostics(ctx context.Context, in *RunDiagnosticsRequest, opts ...grpc.CallOption) (*RunDiagnosticsResponse, error) + // AutoConfigureSSH automatically configures OpenSSH-compatible clients for + // connections to Teleport SSH hosts. + AutoConfigureSSH(ctx context.Context, in *AutoConfigureSSHRequest, opts ...grpc.CallOption) (*AutoConfigureSSHResponse, error) } type vnetServiceClient struct { @@ -98,10 +94,10 @@ func (c *vnetServiceClient) Stop(ctx context.Context, in *StopRequest, opts ...g return out, nil } -func (c *vnetServiceClient) ListDNSZones(ctx context.Context, in *ListDNSZonesRequest, opts ...grpc.CallOption) (*ListDNSZonesResponse, error) { +func (c *vnetServiceClient) GetServiceInfo(ctx context.Context, in *GetServiceInfoRequest, opts ...grpc.CallOption) (*GetServiceInfoResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ListDNSZonesResponse) - err := c.cc.Invoke(ctx, VnetService_ListDNSZones_FullMethodName, in, out, cOpts...) + out := new(GetServiceInfoResponse) + err := c.cc.Invoke(ctx, VnetService_GetServiceInfo_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -128,6 +124,16 @@ func (c *vnetServiceClient) RunDiagnostics(ctx context.Context, in *RunDiagnosti return out, nil } +func (c *vnetServiceClient) AutoConfigureSSH(ctx context.Context, in *AutoConfigureSSHRequest, opts ...grpc.CallOption) (*AutoConfigureSSHResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AutoConfigureSSHResponse) + err := c.cc.Invoke(ctx, VnetService_AutoConfigureSSH_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // VnetServiceServer is the server API for VnetService service. // All implementations must embed UnimplementedVnetServiceServer // for forward compatibility. @@ -138,22 +144,17 @@ type VnetServiceServer interface { Start(context.Context, *StartRequest) (*StartResponse, error) // Stop stops VNet. Stop(context.Context, *StopRequest) (*StopResponse, error) - // ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - // includes the proxy service hostnames and custom DNS zones configured in vnet_config. - // - // This is fetched independently of what the Electron app thinks the current state of the cluster - // looks like, since the VNet admin process also fetches this data independently of the Electron - // app. - // - // Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - // be fetched (due to e.g., a network error or an expired cert). - ListDNSZones(context.Context, *ListDNSZonesRequest) (*ListDNSZonesResponse, error) + // GetServiceInfo returns info about the running VNet service. + GetServiceInfo(context.Context, *GetServiceInfoRequest) (*GetServiceInfoResponse, error) // GetBackgroundItemStatus returns the status of the background item responsible for launching // VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. GetBackgroundItemStatus(context.Context, *GetBackgroundItemStatusRequest) (*GetBackgroundItemStatusResponse, error) // RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that // is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. RunDiagnostics(context.Context, *RunDiagnosticsRequest) (*RunDiagnosticsResponse, error) + // AutoConfigureSSH automatically configures OpenSSH-compatible clients for + // connections to Teleport SSH hosts. + AutoConfigureSSH(context.Context, *AutoConfigureSSHRequest) (*AutoConfigureSSHResponse, error) mustEmbedUnimplementedVnetServiceServer() } @@ -170,8 +171,8 @@ func (UnimplementedVnetServiceServer) Start(context.Context, *StartRequest) (*St func (UnimplementedVnetServiceServer) Stop(context.Context, *StopRequest) (*StopResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Stop not implemented") } -func (UnimplementedVnetServiceServer) ListDNSZones(context.Context, *ListDNSZonesRequest) (*ListDNSZonesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListDNSZones not implemented") +func (UnimplementedVnetServiceServer) GetServiceInfo(context.Context, *GetServiceInfoRequest) (*GetServiceInfoResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetServiceInfo not implemented") } func (UnimplementedVnetServiceServer) GetBackgroundItemStatus(context.Context, *GetBackgroundItemStatusRequest) (*GetBackgroundItemStatusResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetBackgroundItemStatus not implemented") @@ -179,6 +180,9 @@ func (UnimplementedVnetServiceServer) GetBackgroundItemStatus(context.Context, * func (UnimplementedVnetServiceServer) RunDiagnostics(context.Context, *RunDiagnosticsRequest) (*RunDiagnosticsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method RunDiagnostics not implemented") } +func (UnimplementedVnetServiceServer) AutoConfigureSSH(context.Context, *AutoConfigureSSHRequest) (*AutoConfigureSSHResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AutoConfigureSSH not implemented") +} func (UnimplementedVnetServiceServer) mustEmbedUnimplementedVnetServiceServer() {} func (UnimplementedVnetServiceServer) testEmbeddedByValue() {} @@ -236,20 +240,20 @@ func _VnetService_Stop_Handler(srv interface{}, ctx context.Context, dec func(in return interceptor(ctx, in, info, handler) } -func _VnetService_ListDNSZones_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ListDNSZonesRequest) +func _VnetService_GetServiceInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetServiceInfoRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(VnetServiceServer).ListDNSZones(ctx, in) + return srv.(VnetServiceServer).GetServiceInfo(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: VnetService_ListDNSZones_FullMethodName, + FullMethod: VnetService_GetServiceInfo_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(VnetServiceServer).ListDNSZones(ctx, req.(*ListDNSZonesRequest)) + return srv.(VnetServiceServer).GetServiceInfo(ctx, req.(*GetServiceInfoRequest)) } return interceptor(ctx, in, info, handler) } @@ -290,6 +294,24 @@ func _VnetService_RunDiagnostics_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _VnetService_AutoConfigureSSH_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AutoConfigureSSHRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VnetServiceServer).AutoConfigureSSH(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VnetService_AutoConfigureSSH_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VnetServiceServer).AutoConfigureSSH(ctx, req.(*AutoConfigureSSHRequest)) + } + return interceptor(ctx, in, info, handler) +} + // VnetService_ServiceDesc is the grpc.ServiceDesc for VnetService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -306,8 +328,8 @@ var VnetService_ServiceDesc = grpc.ServiceDesc{ Handler: _VnetService_Stop_Handler, }, { - MethodName: "ListDNSZones", - Handler: _VnetService_ListDNSZones_Handler, + MethodName: "GetServiceInfo", + Handler: _VnetService_GetServiceInfo_Handler, }, { MethodName: "GetBackgroundItemStatus", @@ -317,6 +339,10 @@ var VnetService_ServiceDesc = grpc.ServiceDesc{ MethodName: "RunDiagnostics", Handler: _VnetService_RunDiagnostics_Handler, }, + { + MethodName: "AutoConfigureSSH", + Handler: _VnetService_AutoConfigureSSH_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "teleport/lib/teleterm/vnet/v1/vnet_service.proto", 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/go/teleport/lib/vnet/v1/client_application_service.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go index 685a31f2b4358..6e007a0134f22 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service.pb.go @@ -649,7 +649,15 @@ type MatchedCluster struct { Ipv4CidrRange string `protobuf:"bytes,1,opt,name=ipv4_cidr_range,json=ipv4CidrRange,proto3" json:"ipv4_cidr_range,omitempty"` // WebProxyAddr is the web proxy address of the root cluster that matched the // query. - WebProxyAddr string `protobuf:"bytes,2,opt,name=web_proxy_addr,json=webProxyAddr,proto3" json:"web_proxy_addr,omitempty"` + WebProxyAddr string `protobuf:"bytes,2,opt,name=web_proxy_addr,json=webProxyAddr,proto3" json:"web_proxy_addr,omitempty"` + // Profile is the profile the matched cluster was found in. + Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"` + // RootCluster will always be set to the name of the root cluster that matched + // the query. + RootCluster string `protobuf:"bytes,4,opt,name=root_cluster,json=rootCluster,proto3" json:"root_cluster,omitempty"` + // LeafCluster will be set only when the query matched a leaf cluster of + // RootCluster, or else it will be empty. + LeafCluster string `protobuf:"bytes,5,opt,name=leaf_cluster,json=leafCluster,proto3" json:"leaf_cluster,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -698,6 +706,27 @@ func (x *MatchedCluster) GetWebProxyAddr() string { return "" } +func (x *MatchedCluster) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *MatchedCluster) GetRootCluster() string { + if x != nil { + return x.RootCluster + } + return "" +} + +func (x *MatchedCluster) GetLeafCluster() string { + if x != nil { + return x.LeafCluster + } + return "" +} + // AppInfo holds all necessary info for making connections to VNet TCP apps. type AppInfo struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -860,8 +889,10 @@ type DialOptions struct { Sni string `protobuf:"bytes,3,opt,name=sni,proto3" json:"sni,omitempty"` // InsecureSkipVerify turns off verification for x509 upstream ALPN proxy service certificate. InsecureSkipVerify bool `protobuf:"varint,4,opt,name=insecure_skip_verify,json=insecureSkipVerify,proto3" json:"insecure_skip_verify,omitempty"` - // RootClusterCaCertPool overrides the x509 certificate pool used to verify the server. - // It is a PEM-encoded X509 certificate pool. + // RootClusterCaCertPool is the host CA TLS certificate pool for the root + // cluster. It is a PEM-encoded X509 certificate pool. It should be used when + // dialing the proxy and AlpnConnUpgradeRequired is true or when dialing the + // transport service. RootClusterCaCertPool []byte `protobuf:"bytes,5,opt,name=root_cluster_ca_cert_pool,json=rootClusterCaCertPool,proto3" json:"root_cluster_ca_cert_pool,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -1048,13 +1079,8 @@ type SignForAppRequest struct { // TargetPort of a previous successful call to ReissueAppCert for an app // matching AppKey. TargetPort uint32 `protobuf:"varint,2,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"` - // Digest is the bytes to sign. - Digest []byte `protobuf:"bytes,3,opt,name=digest,proto3" json:"digest,omitempty"` - // Hash is the hash function used to compute digest. - Hash Hash `protobuf:"varint,4,opt,name=hash,proto3,enum=teleport.lib.vnet.v1.Hash" json:"hash,omitempty"` - // PssSaltLength specifies the length of the salt added to the digest before a - // signature. Only used and required for RSA PSS signatures. - PssSaltLength *int32 `protobuf:"varint,5,opt,name=pss_salt_length,json=pssSaltLength,proto3,oneof" json:"pss_salt_length,omitempty"` + // Sign holds signature request details. + Sign *SignRequest `protobuf:"bytes,6,opt,name=sign,proto3" json:"sign,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1103,21 +1129,72 @@ func (x *SignForAppRequest) GetTargetPort() uint32 { return 0 } -func (x *SignForAppRequest) GetDigest() []byte { +func (x *SignForAppRequest) GetSign() *SignRequest { + if x != nil { + return x.Sign + } + return nil +} + +// SignRequest holds signature request details. +type SignRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Digest is the bytes to sign. + Digest []byte `protobuf:"bytes,1,opt,name=digest,proto3" json:"digest,omitempty"` + // Hash is the hash function used to compute digest. + Hash Hash `protobuf:"varint,2,opt,name=hash,proto3,enum=teleport.lib.vnet.v1.Hash" json:"hash,omitempty"` + // PssSaltLength specifies the length of the salt added to the digest before a + // signature. Only used and required for RSA PSS signatures. + PssSaltLength *int32 `protobuf:"varint,3,opt,name=pss_salt_length,json=pssSaltLength,proto3,oneof" json:"pss_salt_length,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignRequest) Reset() { + *x = SignRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignRequest) ProtoMessage() {} + +func (x *SignRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + 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 SignRequest.ProtoReflect.Descriptor instead. +func (*SignRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{18} +} + +func (x *SignRequest) GetDigest() []byte { if x != nil { return x.Digest } return nil } -func (x *SignForAppRequest) GetHash() Hash { +func (x *SignRequest) GetHash() Hash { if x != nil { return x.Hash } return Hash_HASH_UNSPECIFIED } -func (x *SignForAppRequest) GetPssSaltLength() int32 { +func (x *SignRequest) GetPssSaltLength() int32 { if x != nil && x.PssSaltLength != nil { return *x.PssSaltLength } @@ -1135,7 +1212,7 @@ type SignForAppResponse struct { func (x *SignForAppResponse) Reset() { *x = SignForAppResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1147,7 +1224,7 @@ func (x *SignForAppResponse) String() string { func (*SignForAppResponse) ProtoMessage() {} func (x *SignForAppResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1160,7 +1237,7 @@ func (x *SignForAppResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SignForAppResponse.ProtoReflect.Descriptor instead. func (*SignForAppResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{18} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{19} } func (x *SignForAppResponse) GetSignature() []byte { @@ -1170,8 +1247,8 @@ func (x *SignForAppResponse) GetSignature() []byte { return nil } -// OnNewConnectionRequest is a request for OnNewConnection. -type OnNewConnectionRequest struct { +// OnNewAppConnectionRequest is a request for OnNewAppConnection. +type OnNewAppConnectionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // AppKey identifies the app the connection is being made for. AppKey *AppKey `protobuf:"bytes,1,opt,name=app_key,json=appKey,proto3" json:"app_key,omitempty"` @@ -1179,21 +1256,21 @@ type OnNewConnectionRequest struct { sizeCache protoimpl.SizeCache } -func (x *OnNewConnectionRequest) Reset() { - *x = OnNewConnectionRequest{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] +func (x *OnNewAppConnectionRequest) Reset() { + *x = OnNewAppConnectionRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *OnNewConnectionRequest) String() string { +func (x *OnNewAppConnectionRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OnNewConnectionRequest) ProtoMessage() {} +func (*OnNewAppConnectionRequest) ProtoMessage() {} -func (x *OnNewConnectionRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[19] +func (x *OnNewAppConnectionRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1204,40 +1281,40 @@ func (x *OnNewConnectionRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OnNewConnectionRequest.ProtoReflect.Descriptor instead. -func (*OnNewConnectionRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{19} +// Deprecated: Use OnNewAppConnectionRequest.ProtoReflect.Descriptor instead. +func (*OnNewAppConnectionRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{20} } -func (x *OnNewConnectionRequest) GetAppKey() *AppKey { +func (x *OnNewAppConnectionRequest) GetAppKey() *AppKey { if x != nil { return x.AppKey } return nil } -// OnNewConnectionRequest is a response for OnNewConnection. -type OnNewConnectionResponse struct { +// OnNewAppConnectionResponse is a response for OnNewAppConnection. +type OnNewAppConnectionResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *OnNewConnectionResponse) Reset() { - *x = OnNewConnectionResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] +func (x *OnNewAppConnectionResponse) Reset() { + *x = OnNewAppConnectionResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *OnNewConnectionResponse) String() string { +func (x *OnNewAppConnectionResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OnNewConnectionResponse) ProtoMessage() {} +func (*OnNewAppConnectionResponse) ProtoMessage() {} -func (x *OnNewConnectionResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[20] +func (x *OnNewAppConnectionResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1248,9 +1325,9 @@ func (x *OnNewConnectionResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OnNewConnectionResponse.ProtoReflect.Descriptor instead. -func (*OnNewConnectionResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{20} +// Deprecated: Use OnNewAppConnectionResponse.ProtoReflect.Descriptor instead. +func (*OnNewAppConnectionResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{21} } // OnInvalidLocalPortRequest is a request for OnInvalidLocalPort. @@ -1269,7 +1346,7 @@ type OnInvalidLocalPortRequest struct { func (x *OnInvalidLocalPortRequest) Reset() { *x = OnInvalidLocalPortRequest{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1281,7 +1358,7 @@ func (x *OnInvalidLocalPortRequest) String() string { func (*OnInvalidLocalPortRequest) ProtoMessage() {} func (x *OnInvalidLocalPortRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[21] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1294,7 +1371,7 @@ func (x *OnInvalidLocalPortRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use OnInvalidLocalPortRequest.ProtoReflect.Descriptor instead. func (*OnInvalidLocalPortRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{21} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{22} } func (x *OnInvalidLocalPortRequest) GetAppInfo() *AppInfo { @@ -1320,7 +1397,7 @@ type OnInvalidLocalPortResponse struct { func (x *OnInvalidLocalPortResponse) Reset() { *x = OnInvalidLocalPortResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1332,7 +1409,7 @@ func (x *OnInvalidLocalPortResponse) String() string { func (*OnInvalidLocalPortResponse) ProtoMessage() {} func (x *OnInvalidLocalPortResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[22] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1345,7 +1422,7 @@ func (x *OnInvalidLocalPortResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use OnInvalidLocalPortResponse.ProtoReflect.Descriptor instead. func (*OnInvalidLocalPortResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{22} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{23} } // GetTargetOSConfigurationRequest is a request for the target host OS configuration. @@ -1357,7 +1434,7 @@ type GetTargetOSConfigurationRequest struct { func (x *GetTargetOSConfigurationRequest) Reset() { *x = GetTargetOSConfigurationRequest{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1369,7 +1446,7 @@ func (x *GetTargetOSConfigurationRequest) String() string { func (*GetTargetOSConfigurationRequest) ProtoMessage() {} func (x *GetTargetOSConfigurationRequest) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[23] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1382,7 +1459,7 @@ func (x *GetTargetOSConfigurationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTargetOSConfigurationRequest.ProtoReflect.Descriptor instead. func (*GetTargetOSConfigurationRequest) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{23} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{24} } // GetTargetOSConfigurationResponse is a response including the target host OS configuration. @@ -1396,7 +1473,7 @@ type GetTargetOSConfigurationResponse struct { func (x *GetTargetOSConfigurationResponse) Reset() { *x = GetTargetOSConfigurationResponse{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1408,7 +1485,7 @@ func (x *GetTargetOSConfigurationResponse) String() string { func (*GetTargetOSConfigurationResponse) ProtoMessage() {} func (x *GetTargetOSConfigurationResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[24] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1421,7 +1498,7 @@ func (x *GetTargetOSConfigurationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetTargetOSConfigurationResponse.ProtoReflect.Descriptor instead. func (*GetTargetOSConfigurationResponse) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{24} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{25} } func (x *GetTargetOSConfigurationResponse) GetTargetOsConfiguration() *TargetOSConfiguration { @@ -1451,7 +1528,7 @@ type TargetOSConfiguration struct { func (x *TargetOSConfiguration) Reset() { *x = TargetOSConfiguration{} - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1463,7 +1540,7 @@ func (x *TargetOSConfiguration) String() string { func (*TargetOSConfiguration) ProtoMessage() {} func (x *TargetOSConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[25] + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1476,7 +1553,7 @@ func (x *TargetOSConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use TargetOSConfiguration.ProtoReflect.Descriptor instead. func (*TargetOSConfiguration) Descriptor() ([]byte, []int) { - return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{25} + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{26} } func (x *TargetOSConfiguration) GetDnsZones() []string { @@ -1493,6 +1570,555 @@ func (x *TargetOSConfiguration) GetIpv4CidrRanges() []string { return nil } +// UserTLSCertRequest is a request for UserTLSCert. +type UserTLSCertRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Profile is the profile to retrieve the certificate for. + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserTLSCertRequest) Reset() { + *x = UserTLSCertRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserTLSCertRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserTLSCertRequest) ProtoMessage() {} + +func (x *UserTLSCertRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[27] + 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 UserTLSCertRequest.ProtoReflect.Descriptor instead. +func (*UserTLSCertRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{27} +} + +func (x *UserTLSCertRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +// UserTLSCertResponse is a response for UserTLSCert. +type UserTLSCertResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Cert is the user TLS certificate in X.509 ASN.1 DER format. + Cert []byte `protobuf:"bytes,1,opt,name=cert,proto3" json:"cert,omitempty"` + // DialOptions holds options that should be used when dialing the root cluster + // proxy. + DialOptions *DialOptions `protobuf:"bytes,2,opt,name=dial_options,json=dialOptions,proto3" json:"dial_options,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserTLSCertResponse) Reset() { + *x = UserTLSCertResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserTLSCertResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserTLSCertResponse) ProtoMessage() {} + +func (x *UserTLSCertResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[28] + 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 UserTLSCertResponse.ProtoReflect.Descriptor instead. +func (*UserTLSCertResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{28} +} + +func (x *UserTLSCertResponse) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +func (x *UserTLSCertResponse) GetDialOptions() *DialOptions { + if x != nil { + return x.DialOptions + } + return nil +} + +// SignForUserTLSRequest is a request for SignForUserTLS. +type SignForUserTLSRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Profile is the user profile to sign for. + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // Sign holds signature request details. + Sign *SignRequest `protobuf:"bytes,2,opt,name=sign,proto3" json:"sign,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForUserTLSRequest) Reset() { + *x = SignForUserTLSRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForUserTLSRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForUserTLSRequest) ProtoMessage() {} + +func (x *SignForUserTLSRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[29] + 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 SignForUserTLSRequest.ProtoReflect.Descriptor instead. +func (*SignForUserTLSRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{29} +} + +func (x *SignForUserTLSRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SignForUserTLSRequest) GetSign() *SignRequest { + if x != nil { + return x.Sign + } + return nil +} + +// SignForUserTLSResponse is a response for SignForUserTLS. +type SignForUserTLSResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Signature is the signature. + Signature []byte `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForUserTLSResponse) Reset() { + *x = SignForUserTLSResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForUserTLSResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForUserTLSResponse) ProtoMessage() {} + +func (x *SignForUserTLSResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[30] + 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 SignForUserTLSResponse.ProtoReflect.Descriptor instead. +func (*SignForUserTLSResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{30} +} + +func (x *SignForUserTLSResponse) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +// SessionSSHConfigRequest is a request for SessionSSHConfig. +type SessionSSHConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Profile is the profile in which the SSH server is found. + Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"` + // RootCluster is the cluster in which the SSH server is found. + RootCluster string `protobuf:"bytes,2,opt,name=root_cluster,json=rootCluster,proto3" json:"root_cluster,omitempty"` + // LeafCluster is the leaf cluster in which the SSH server is found. + // If empty, the SSH server is in the root cluster. + LeafCluster string `protobuf:"bytes,3,opt,name=leaf_cluster,json=leafCluster,proto3" json:"leaf_cluster,omitempty"` + // Address is the address of the SSH server. + Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"` + // User is the SSH user the session is for. + User string `protobuf:"bytes,5,opt,name=user,proto3" json:"user,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionSSHConfigRequest) Reset() { + *x = SessionSSHConfigRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionSSHConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionSSHConfigRequest) ProtoMessage() {} + +func (x *SessionSSHConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[31] + 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 SessionSSHConfigRequest.ProtoReflect.Descriptor instead. +func (*SessionSSHConfigRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{31} +} + +func (x *SessionSSHConfigRequest) GetProfile() string { + if x != nil { + return x.Profile + } + return "" +} + +func (x *SessionSSHConfigRequest) GetRootCluster() string { + if x != nil { + return x.RootCluster + } + return "" +} + +func (x *SessionSSHConfigRequest) GetLeafCluster() string { + if x != nil { + return x.LeafCluster + } + return "" +} + +func (x *SessionSSHConfigRequest) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +func (x *SessionSSHConfigRequest) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +// SessionSSHConfigResponse is a response for SessionSSHConfig. +type SessionSSHConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // SessionId is an opaque identifier for the session, it should be passed to + // SignForSSHSession to issue signatures with the private key associated with + // the session. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Cert is the session SSH certificate in SSH wire format. + Cert []byte `protobuf:"bytes,2,opt,name=cert,proto3" json:"cert,omitempty"` + // TrustedCas is a list of trusted SSH certificate authorities in SSH wire + // format. + TrustedCas [][]byte `protobuf:"bytes,3,rep,name=trusted_cas,json=trustedCas,proto3" json:"trusted_cas,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionSSHConfigResponse) Reset() { + *x = SessionSSHConfigResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionSSHConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionSSHConfigResponse) ProtoMessage() {} + +func (x *SessionSSHConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[32] + 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 SessionSSHConfigResponse.ProtoReflect.Descriptor instead. +func (*SessionSSHConfigResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{32} +} + +func (x *SessionSSHConfigResponse) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SessionSSHConfigResponse) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +func (x *SessionSSHConfigResponse) GetTrustedCas() [][]byte { + if x != nil { + return x.TrustedCas + } + return nil +} + +// SignForSSHSessionRequest is a request for SignForSSHSession. +type SignForSSHSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // SessionId is an opaque identifier for the session returned from a previous + // call to SessionSSHConfig. + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // Sign holds signature request details. + Sign *SignRequest `protobuf:"bytes,2,opt,name=sign,proto3" json:"sign,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForSSHSessionRequest) Reset() { + *x = SignForSSHSessionRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForSSHSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForSSHSessionRequest) ProtoMessage() {} + +func (x *SignForSSHSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[33] + 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 SignForSSHSessionRequest.ProtoReflect.Descriptor instead. +func (*SignForSSHSessionRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{33} +} + +func (x *SignForSSHSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SignForSSHSessionRequest) GetSign() *SignRequest { + if x != nil { + return x.Sign + } + return nil +} + +// SignForSSHSessionResponse is a response for SignForSSHSession. +type SignForSSHSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Signature is the signature. + Signature []byte `protobuf:"bytes,1,opt,name=signature,proto3" json:"signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignForSSHSessionResponse) Reset() { + *x = SignForSSHSessionResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignForSSHSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignForSSHSessionResponse) ProtoMessage() {} + +func (x *SignForSSHSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[34] + 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 SignForSSHSessionResponse.ProtoReflect.Descriptor instead. +func (*SignForSSHSessionResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{34} +} + +func (x *SignForSSHSessionResponse) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + +// ExchangeSSHKeysRequest is a request to exchange SSH keys for VNet SSH. +type ExchangeSSHKeysRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // HostPublicKey is the host key that should be trusted by clients connecting + // to VNet SSH addresses. It is encoded in OpenSSH wire format. + HostPublicKey []byte `protobuf:"bytes,1,opt,name=host_public_key,json=hostPublicKey,proto3" json:"host_public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeSSHKeysRequest) Reset() { + *x = ExchangeSSHKeysRequest{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeSSHKeysRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeSSHKeysRequest) ProtoMessage() {} + +func (x *ExchangeSSHKeysRequest) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[35] + 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 ExchangeSSHKeysRequest.ProtoReflect.Descriptor instead. +func (*ExchangeSSHKeysRequest) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{35} +} + +func (x *ExchangeSSHKeysRequest) GetHostPublicKey() []byte { + if x != nil { + return x.HostPublicKey + } + return nil +} + +// ExchangeSSHKeysResponse is a response for ExchangeSSHKeys. +type ExchangeSSHKeysResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // UserPublicKey is the user key that should be trusted by VNet for incoming + // connections from SSH clients. It is encoded in OpenSSH wire format. + UserPublicKey []byte `protobuf:"bytes,1,opt,name=user_public_key,json=userPublicKey,proto3" json:"user_public_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExchangeSSHKeysResponse) Reset() { + *x = ExchangeSSHKeysResponse{} + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExchangeSSHKeysResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExchangeSSHKeysResponse) ProtoMessage() {} + +func (x *ExchangeSSHKeysResponse) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[36] + 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 ExchangeSSHKeysResponse.ProtoReflect.Descriptor instead. +func (*ExchangeSSHKeysResponse) Descriptor() ([]byte, []int) { + return file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP(), []int{36} +} + +func (x *ExchangeSSHKeysResponse) GetUserPublicKey() []byte { + if x != nil { + return x.UserPublicKey + } + return nil +} + var File_teleport_lib_vnet_v1_client_application_service_proto protoreflect.FileDescriptor const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + @@ -1521,10 +2147,13 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\x05match\"I\n" + "\rMatchedTCPApp\x128\n" + "\bapp_info\x18\x01 \x01(\v2\x1d.teleport.lib.vnet.v1.AppInfoR\aappInfo\"\x0f\n" + - "\rMatchedWebApp\"^\n" + + "\rMatchedWebApp\"\xbe\x01\n" + "\x0eMatchedCluster\x12&\n" + "\x0fipv4_cidr_range\x18\x01 \x01(\tR\ripv4CidrRange\x12$\n" + - "\x0eweb_proxy_addr\x18\x02 \x01(\tR\fwebProxyAddr\"\xe8\x01\n" + + "\x0eweb_proxy_addr\x18\x02 \x01(\tR\fwebProxyAddr\x12\x18\n" + + "\aprofile\x18\x03 \x01(\tR\aprofile\x12!\n" + + "\froot_cluster\x18\x04 \x01(\tR\vrootCluster\x12!\n" + + "\fleaf_cluster\x18\x05 \x01(\tR\vleafCluster\"\xe8\x01\n" + "\aAppInfo\x125\n" + "\aapp_key\x18\x01 \x01(\v2\x1c.teleport.lib.vnet.v1.AppKeyR\x06appKey\x12\x18\n" + "\acluster\x18\x02 \x01(\tR\acluster\x12\x1e\n" + @@ -1546,20 +2175,22 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\vtarget_port\x18\x02 \x01(\rR\n" + "targetPort\",\n" + "\x16ReissueAppCertResponse\x12\x12\n" + - "\x04cert\x18\x01 \x01(\fR\x04cert\"\xf4\x01\n" + + "\x04cert\x18\x01 \x01(\fR\x04cert\"\xd3\x01\n" + "\x11SignForAppRequest\x125\n" + "\aapp_key\x18\x01 \x01(\v2\x1c.teleport.lib.vnet.v1.AppKeyR\x06appKey\x12\x1f\n" + "\vtarget_port\x18\x02 \x01(\rR\n" + - "targetPort\x12\x16\n" + - "\x06digest\x18\x03 \x01(\fR\x06digest\x12.\n" + - "\x04hash\x18\x04 \x01(\x0e2\x1a.teleport.lib.vnet.v1.HashR\x04hash\x12+\n" + - "\x0fpss_salt_length\x18\x05 \x01(\x05H\x00R\rpssSaltLength\x88\x01\x01B\x12\n" + + "targetPort\x125\n" + + "\x04sign\x18\x06 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04signJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x06digestR\x04hashR\x0fpss_salt_length\"\x96\x01\n" + + "\vSignRequest\x12\x16\n" + + "\x06digest\x18\x01 \x01(\fR\x06digest\x12.\n" + + "\x04hash\x18\x02 \x01(\x0e2\x1a.teleport.lib.vnet.v1.HashR\x04hash\x12+\n" + + "\x0fpss_salt_length\x18\x03 \x01(\x05H\x00R\rpssSaltLength\x88\x01\x01B\x12\n" + "\x10_pss_salt_length\"2\n" + "\x12SignForAppResponse\x12\x1c\n" + - "\tsignature\x18\x01 \x01(\fR\tsignature\"O\n" + - "\x16OnNewConnectionRequest\x125\n" + - "\aapp_key\x18\x01 \x01(\v2\x1c.teleport.lib.vnet.v1.AppKeyR\x06appKey\"\x19\n" + - "\x17OnNewConnectionResponse\"v\n" + + "\tsignature\x18\x01 \x01(\fR\tsignature\"R\n" + + "\x19OnNewAppConnectionRequest\x125\n" + + "\aapp_key\x18\x01 \x01(\v2\x1c.teleport.lib.vnet.v1.AppKeyR\x06appKey\"\x1c\n" + + "\x1aOnNewAppConnectionResponse\"v\n" + "\x19OnInvalidLocalPortRequest\x128\n" + "\bapp_info\x18\x01 \x01(\v2\x1d.teleport.lib.vnet.v1.AppInfoR\aappInfo\x12\x1f\n" + "\vtarget_port\x18\x02 \x01(\rR\n" + @@ -1570,11 +2201,43 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\x17target_os_configuration\x18\x01 \x01(\v2+.teleport.lib.vnet.v1.TargetOSConfigurationR\x15targetOsConfiguration\"^\n" + "\x15TargetOSConfiguration\x12\x1b\n" + "\tdns_zones\x18\x01 \x03(\tR\bdnsZones\x12(\n" + - "\x10ipv4_cidr_ranges\x18\x02 \x03(\tR\x0eipv4CidrRanges*<\n" + + "\x10ipv4_cidr_ranges\x18\x02 \x03(\tR\x0eipv4CidrRanges\".\n" + + "\x12UserTLSCertRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\"o\n" + + "\x13UserTLSCertResponse\x12\x12\n" + + "\x04cert\x18\x01 \x01(\fR\x04cert\x12D\n" + + "\fdial_options\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.DialOptionsR\vdialOptions\"h\n" + + "\x15SignForUserTLSRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\x125\n" + + "\x04sign\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04sign\"6\n" + + "\x16SignForUserTLSResponse\x12\x1c\n" + + "\tsignature\x18\x01 \x01(\fR\tsignature\"\xa7\x01\n" + + "\x17SessionSSHConfigRequest\x12\x18\n" + + "\aprofile\x18\x01 \x01(\tR\aprofile\x12!\n" + + "\froot_cluster\x18\x02 \x01(\tR\vrootCluster\x12!\n" + + "\fleaf_cluster\x18\x03 \x01(\tR\vleafCluster\x12\x18\n" + + "\aaddress\x18\x04 \x01(\tR\aaddress\x12\x12\n" + + "\x04user\x18\x05 \x01(\tR\x04user\"n\n" + + "\x18SessionSSHConfigResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x12\n" + + "\x04cert\x18\x02 \x01(\fR\x04cert\x12\x1f\n" + + "\vtrusted_cas\x18\x03 \x03(\fR\n" + + "trustedCas\"p\n" + + "\x18SignForSSHSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x125\n" + + "\x04sign\x18\x02 \x01(\v2!.teleport.lib.vnet.v1.SignRequestR\x04sign\"9\n" + + "\x19SignForSSHSessionResponse\x12\x1c\n" + + "\tsignature\x18\x01 \x01(\fR\tsignature\"@\n" + + "\x16ExchangeSSHKeysRequest\x12&\n" + + "\x0fhost_public_key\x18\x01 \x01(\fR\rhostPublicKey\"A\n" + + "\x17ExchangeSSHKeysResponse\x12&\n" + + "\x0fuser_public_key\x18\x01 \x01(\fR\ruserPublicKey*<\n" + "\x04Hash\x12\x14\n" + "\x10HASH_UNSPECIFIED\x10\x00\x12\r\n" + "\tHASH_NONE\x10\x01\x12\x0f\n" + - "\vHASH_SHA256\x10\x022\x92\b\n" + + "\vHASH_SHA256\x10\x022\xc5\f\n" + "\x18ClientApplicationService\x12z\n" + "\x13AuthenticateProcess\x120.teleport.lib.vnet.v1.AuthenticateProcessRequest\x1a1.teleport.lib.vnet.v1.AuthenticateProcessResponse\x12\x83\x01\n" + "\x16ReportNetworkStackInfo\x123.teleport.lib.vnet.v1.ReportNetworkStackInfoRequest\x1a4.teleport.lib.vnet.v1.ReportNetworkStackInfoResponse\x12M\n" + @@ -1582,10 +2245,15 @@ const file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc = "" + "\vResolveFQDN\x12(.teleport.lib.vnet.v1.ResolveFQDNRequest\x1a).teleport.lib.vnet.v1.ResolveFQDNResponse\x12k\n" + "\x0eReissueAppCert\x12+.teleport.lib.vnet.v1.ReissueAppCertRequest\x1a,.teleport.lib.vnet.v1.ReissueAppCertResponse\x12_\n" + "\n" + - "SignForApp\x12'.teleport.lib.vnet.v1.SignForAppRequest\x1a(.teleport.lib.vnet.v1.SignForAppResponse\x12n\n" + - "\x0fOnNewConnection\x12,.teleport.lib.vnet.v1.OnNewConnectionRequest\x1a-.teleport.lib.vnet.v1.OnNewConnectionResponse\x12w\n" + + "SignForApp\x12'.teleport.lib.vnet.v1.SignForAppRequest\x1a(.teleport.lib.vnet.v1.SignForAppResponse\x12w\n" + + "\x12OnNewAppConnection\x12/.teleport.lib.vnet.v1.OnNewAppConnectionRequest\x1a0.teleport.lib.vnet.v1.OnNewAppConnectionResponse\x12w\n" + "\x12OnInvalidLocalPort\x12/.teleport.lib.vnet.v1.OnInvalidLocalPortRequest\x1a0.teleport.lib.vnet.v1.OnInvalidLocalPortResponse\x12\x89\x01\n" + - "\x18GetTargetOSConfiguration\x125.teleport.lib.vnet.v1.GetTargetOSConfigurationRequest\x1a6.teleport.lib.vnet.v1.GetTargetOSConfigurationResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" + "\x18GetTargetOSConfiguration\x125.teleport.lib.vnet.v1.GetTargetOSConfigurationRequest\x1a6.teleport.lib.vnet.v1.GetTargetOSConfigurationResponse\x12b\n" + + "\vUserTLSCert\x12(.teleport.lib.vnet.v1.UserTLSCertRequest\x1a).teleport.lib.vnet.v1.UserTLSCertResponse\x12k\n" + + "\x0eSignForUserTLS\x12+.teleport.lib.vnet.v1.SignForUserTLSRequest\x1a,.teleport.lib.vnet.v1.SignForUserTLSResponse\x12q\n" + + "\x10SessionSSHConfig\x12-.teleport.lib.vnet.v1.SessionSSHConfigRequest\x1a..teleport.lib.vnet.v1.SessionSSHConfigResponse\x12t\n" + + "\x11SignForSSHSession\x12..teleport.lib.vnet.v1.SignForSSHSessionRequest\x1a/.teleport.lib.vnet.v1.SignForSSHSessionResponse\x12n\n" + + "\x0fExchangeSSHKeys\x12,.teleport.lib.vnet.v1.ExchangeSSHKeysRequest\x1a-.teleport.lib.vnet.v1.ExchangeSSHKeysResponseBLZJgithub.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1;vnetv1b\x06proto3" var ( file_teleport_lib_vnet_v1_client_application_service_proto_rawDescOnce sync.Once @@ -1600,7 +2268,7 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_rawDescGZIP() [] } var file_teleport_lib_vnet_v1_client_application_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes = make([]protoimpl.MessageInfo, 37) var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (Hash)(0), // 0: teleport.lib.vnet.v1.Hash (*AuthenticateProcessRequest)(nil), // 1: teleport.lib.vnet.v1.AuthenticateProcessRequest @@ -1621,15 +2289,26 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_goTypes = []any{ (*ReissueAppCertRequest)(nil), // 16: teleport.lib.vnet.v1.ReissueAppCertRequest (*ReissueAppCertResponse)(nil), // 17: teleport.lib.vnet.v1.ReissueAppCertResponse (*SignForAppRequest)(nil), // 18: teleport.lib.vnet.v1.SignForAppRequest - (*SignForAppResponse)(nil), // 19: teleport.lib.vnet.v1.SignForAppResponse - (*OnNewConnectionRequest)(nil), // 20: teleport.lib.vnet.v1.OnNewConnectionRequest - (*OnNewConnectionResponse)(nil), // 21: teleport.lib.vnet.v1.OnNewConnectionResponse - (*OnInvalidLocalPortRequest)(nil), // 22: teleport.lib.vnet.v1.OnInvalidLocalPortRequest - (*OnInvalidLocalPortResponse)(nil), // 23: teleport.lib.vnet.v1.OnInvalidLocalPortResponse - (*GetTargetOSConfigurationRequest)(nil), // 24: teleport.lib.vnet.v1.GetTargetOSConfigurationRequest - (*GetTargetOSConfigurationResponse)(nil), // 25: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse - (*TargetOSConfiguration)(nil), // 26: teleport.lib.vnet.v1.TargetOSConfiguration - (*types.AppV3)(nil), // 27: types.AppV3 + (*SignRequest)(nil), // 19: teleport.lib.vnet.v1.SignRequest + (*SignForAppResponse)(nil), // 20: teleport.lib.vnet.v1.SignForAppResponse + (*OnNewAppConnectionRequest)(nil), // 21: teleport.lib.vnet.v1.OnNewAppConnectionRequest + (*OnNewAppConnectionResponse)(nil), // 22: teleport.lib.vnet.v1.OnNewAppConnectionResponse + (*OnInvalidLocalPortRequest)(nil), // 23: teleport.lib.vnet.v1.OnInvalidLocalPortRequest + (*OnInvalidLocalPortResponse)(nil), // 24: teleport.lib.vnet.v1.OnInvalidLocalPortResponse + (*GetTargetOSConfigurationRequest)(nil), // 25: teleport.lib.vnet.v1.GetTargetOSConfigurationRequest + (*GetTargetOSConfigurationResponse)(nil), // 26: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse + (*TargetOSConfiguration)(nil), // 27: teleport.lib.vnet.v1.TargetOSConfiguration + (*UserTLSCertRequest)(nil), // 28: teleport.lib.vnet.v1.UserTLSCertRequest + (*UserTLSCertResponse)(nil), // 29: teleport.lib.vnet.v1.UserTLSCertResponse + (*SignForUserTLSRequest)(nil), // 30: teleport.lib.vnet.v1.SignForUserTLSRequest + (*SignForUserTLSResponse)(nil), // 31: teleport.lib.vnet.v1.SignForUserTLSResponse + (*SessionSSHConfigRequest)(nil), // 32: teleport.lib.vnet.v1.SessionSSHConfigRequest + (*SessionSSHConfigResponse)(nil), // 33: teleport.lib.vnet.v1.SessionSSHConfigResponse + (*SignForSSHSessionRequest)(nil), // 34: teleport.lib.vnet.v1.SignForSSHSessionRequest + (*SignForSSHSessionResponse)(nil), // 35: teleport.lib.vnet.v1.SignForSSHSessionResponse + (*ExchangeSSHKeysRequest)(nil), // 36: teleport.lib.vnet.v1.ExchangeSSHKeysRequest + (*ExchangeSSHKeysResponse)(nil), // 37: teleport.lib.vnet.v1.ExchangeSSHKeysResponse + (*types.AppV3)(nil), // 38: types.AppV3 } var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32{ 4, // 0: teleport.lib.vnet.v1.ReportNetworkStackInfoRequest.network_stack_info:type_name -> teleport.lib.vnet.v1.NetworkStackInfo @@ -1638,37 +2317,51 @@ var file_teleport_lib_vnet_v1_client_application_service_proto_depIdxs = []int32 12, // 3: teleport.lib.vnet.v1.ResolveFQDNResponse.matched_cluster:type_name -> teleport.lib.vnet.v1.MatchedCluster 13, // 4: teleport.lib.vnet.v1.MatchedTCPApp.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 5: teleport.lib.vnet.v1.AppInfo.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 27, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 + 38, // 6: teleport.lib.vnet.v1.AppInfo.app:type_name -> types.AppV3 15, // 7: teleport.lib.vnet.v1.AppInfo.dial_options:type_name -> teleport.lib.vnet.v1.DialOptions 13, // 8: teleport.lib.vnet.v1.ReissueAppCertRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo 14, // 9: teleport.lib.vnet.v1.SignForAppRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 0, // 10: teleport.lib.vnet.v1.SignForAppRequest.hash:type_name -> teleport.lib.vnet.v1.Hash - 14, // 11: teleport.lib.vnet.v1.OnNewConnectionRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey - 13, // 12: teleport.lib.vnet.v1.OnInvalidLocalPortRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo - 26, // 13: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse.target_os_configuration:type_name -> teleport.lib.vnet.v1.TargetOSConfiguration - 1, // 14: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:input_type -> teleport.lib.vnet.v1.AuthenticateProcessRequest - 3, // 15: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:input_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoRequest - 6, // 16: teleport.lib.vnet.v1.ClientApplicationService.Ping:input_type -> teleport.lib.vnet.v1.PingRequest - 8, // 17: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:input_type -> teleport.lib.vnet.v1.ResolveFQDNRequest - 16, // 18: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:input_type -> teleport.lib.vnet.v1.ReissueAppCertRequest - 18, // 19: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:input_type -> teleport.lib.vnet.v1.SignForAppRequest - 20, // 20: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:input_type -> teleport.lib.vnet.v1.OnNewConnectionRequest - 22, // 21: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:input_type -> teleport.lib.vnet.v1.OnInvalidLocalPortRequest - 24, // 22: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:input_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationRequest - 2, // 23: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse - 5, // 24: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse - 7, // 25: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse - 9, // 26: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse - 17, // 27: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse - 19, // 28: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse - 21, // 29: teleport.lib.vnet.v1.ClientApplicationService.OnNewConnection:output_type -> teleport.lib.vnet.v1.OnNewConnectionResponse - 23, // 30: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse - 25, // 31: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse - 23, // [23:32] is the sub-list for method output_type - 14, // [14:23] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 19, // 10: teleport.lib.vnet.v1.SignForAppRequest.sign:type_name -> teleport.lib.vnet.v1.SignRequest + 0, // 11: teleport.lib.vnet.v1.SignRequest.hash:type_name -> teleport.lib.vnet.v1.Hash + 14, // 12: teleport.lib.vnet.v1.OnNewAppConnectionRequest.app_key:type_name -> teleport.lib.vnet.v1.AppKey + 13, // 13: teleport.lib.vnet.v1.OnInvalidLocalPortRequest.app_info:type_name -> teleport.lib.vnet.v1.AppInfo + 27, // 14: teleport.lib.vnet.v1.GetTargetOSConfigurationResponse.target_os_configuration:type_name -> teleport.lib.vnet.v1.TargetOSConfiguration + 15, // 15: teleport.lib.vnet.v1.UserTLSCertResponse.dial_options:type_name -> teleport.lib.vnet.v1.DialOptions + 19, // 16: teleport.lib.vnet.v1.SignForUserTLSRequest.sign:type_name -> teleport.lib.vnet.v1.SignRequest + 19, // 17: teleport.lib.vnet.v1.SignForSSHSessionRequest.sign:type_name -> teleport.lib.vnet.v1.SignRequest + 1, // 18: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:input_type -> teleport.lib.vnet.v1.AuthenticateProcessRequest + 3, // 19: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:input_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoRequest + 6, // 20: teleport.lib.vnet.v1.ClientApplicationService.Ping:input_type -> teleport.lib.vnet.v1.PingRequest + 8, // 21: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:input_type -> teleport.lib.vnet.v1.ResolveFQDNRequest + 16, // 22: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:input_type -> teleport.lib.vnet.v1.ReissueAppCertRequest + 18, // 23: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:input_type -> teleport.lib.vnet.v1.SignForAppRequest + 21, // 24: teleport.lib.vnet.v1.ClientApplicationService.OnNewAppConnection:input_type -> teleport.lib.vnet.v1.OnNewAppConnectionRequest + 23, // 25: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:input_type -> teleport.lib.vnet.v1.OnInvalidLocalPortRequest + 25, // 26: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:input_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationRequest + 28, // 27: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:input_type -> teleport.lib.vnet.v1.UserTLSCertRequest + 30, // 28: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:input_type -> teleport.lib.vnet.v1.SignForUserTLSRequest + 32, // 29: teleport.lib.vnet.v1.ClientApplicationService.SessionSSHConfig:input_type -> teleport.lib.vnet.v1.SessionSSHConfigRequest + 34, // 30: teleport.lib.vnet.v1.ClientApplicationService.SignForSSHSession:input_type -> teleport.lib.vnet.v1.SignForSSHSessionRequest + 36, // 31: teleport.lib.vnet.v1.ClientApplicationService.ExchangeSSHKeys:input_type -> teleport.lib.vnet.v1.ExchangeSSHKeysRequest + 2, // 32: teleport.lib.vnet.v1.ClientApplicationService.AuthenticateProcess:output_type -> teleport.lib.vnet.v1.AuthenticateProcessResponse + 5, // 33: teleport.lib.vnet.v1.ClientApplicationService.ReportNetworkStackInfo:output_type -> teleport.lib.vnet.v1.ReportNetworkStackInfoResponse + 7, // 34: teleport.lib.vnet.v1.ClientApplicationService.Ping:output_type -> teleport.lib.vnet.v1.PingResponse + 9, // 35: teleport.lib.vnet.v1.ClientApplicationService.ResolveFQDN:output_type -> teleport.lib.vnet.v1.ResolveFQDNResponse + 17, // 36: teleport.lib.vnet.v1.ClientApplicationService.ReissueAppCert:output_type -> teleport.lib.vnet.v1.ReissueAppCertResponse + 20, // 37: teleport.lib.vnet.v1.ClientApplicationService.SignForApp:output_type -> teleport.lib.vnet.v1.SignForAppResponse + 22, // 38: teleport.lib.vnet.v1.ClientApplicationService.OnNewAppConnection:output_type -> teleport.lib.vnet.v1.OnNewAppConnectionResponse + 24, // 39: teleport.lib.vnet.v1.ClientApplicationService.OnInvalidLocalPort:output_type -> teleport.lib.vnet.v1.OnInvalidLocalPortResponse + 26, // 40: teleport.lib.vnet.v1.ClientApplicationService.GetTargetOSConfiguration:output_type -> teleport.lib.vnet.v1.GetTargetOSConfigurationResponse + 29, // 41: teleport.lib.vnet.v1.ClientApplicationService.UserTLSCert:output_type -> teleport.lib.vnet.v1.UserTLSCertResponse + 31, // 42: teleport.lib.vnet.v1.ClientApplicationService.SignForUserTLS:output_type -> teleport.lib.vnet.v1.SignForUserTLSResponse + 33, // 43: teleport.lib.vnet.v1.ClientApplicationService.SessionSSHConfig:output_type -> teleport.lib.vnet.v1.SessionSSHConfigResponse + 35, // 44: teleport.lib.vnet.v1.ClientApplicationService.SignForSSHSession:output_type -> teleport.lib.vnet.v1.SignForSSHSessionResponse + 37, // 45: teleport.lib.vnet.v1.ClientApplicationService.ExchangeSSHKeys:output_type -> teleport.lib.vnet.v1.ExchangeSSHKeysResponse + 32, // [32:46] is the sub-list for method output_type + 18, // [18:32] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_teleport_lib_vnet_v1_client_application_service_proto_init() } @@ -1681,14 +2374,14 @@ func file_teleport_lib_vnet_v1_client_application_service_proto_init() { (*ResolveFQDNResponse_MatchedWebApp)(nil), (*ResolveFQDNResponse_MatchedCluster)(nil), } - file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[17].OneofWrappers = []any{} + file_teleport_lib_vnet_v1_client_application_service_proto_msgTypes[18].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc), len(file_teleport_lib_vnet_v1_client_application_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 26, + NumMessages: 37, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go index 57af12d216082..1be07457d7424 100644 --- a/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go +++ b/gen/proto/go/teleport/lib/vnet/v1/client_application_service_grpc.pb.go @@ -41,9 +41,14 @@ const ( ClientApplicationService_ResolveFQDN_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/ResolveFQDN" ClientApplicationService_ReissueAppCert_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/ReissueAppCert" ClientApplicationService_SignForApp_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForApp" - ClientApplicationService_OnNewConnection_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/OnNewConnection" + ClientApplicationService_OnNewAppConnection_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/OnNewAppConnection" ClientApplicationService_OnInvalidLocalPort_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/OnInvalidLocalPort" ClientApplicationService_GetTargetOSConfiguration_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/GetTargetOSConfiguration" + ClientApplicationService_UserTLSCert_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/UserTLSCert" + ClientApplicationService_SignForUserTLS_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForUserTLS" + ClientApplicationService_SessionSSHConfig_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SessionSSHConfig" + ClientApplicationService_SignForSSHSession_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/SignForSSHSession" + ClientApplicationService_ExchangeSSHKeys_FullMethodName = "/teleport.lib.vnet.v1.ClientApplicationService/ExchangeSSHKeys" ) // ClientApplicationServiceClient is the client API for ClientApplicationService service. @@ -72,15 +77,27 @@ type ClientApplicationServiceClient interface { // SignForApp issues a signature with the private key associated with an x509 // certificate previously issued for a requested app. SignForApp(ctx context.Context, in *SignForAppRequest, opts ...grpc.CallOption) (*SignForAppResponse, error) - // OnNewConnection gets called whenever a new connection is about to be + // OnNewAppConnection gets called whenever a new app connection is about to be // established through VNet for observability. - OnNewConnection(ctx context.Context, in *OnNewConnectionRequest, opts ...grpc.CallOption) (*OnNewConnectionResponse, error) + OnNewAppConnection(ctx context.Context, in *OnNewAppConnectionRequest, opts ...grpc.CallOption) (*OnNewAppConnectionResponse, error) // OnInvalidLocalPort gets called before VNet refuses to handle a connection // to a multi-port TCP app because the provided port does not match any of the // TCP ports in the app spec. OnInvalidLocalPort(ctx context.Context, in *OnInvalidLocalPortRequest, opts ...grpc.CallOption) (*OnInvalidLocalPortResponse, error) // GetTargetOSConfiguration gets the target OS configuration. GetTargetOSConfiguration(ctx context.Context, in *GetTargetOSConfigurationRequest, opts ...grpc.CallOption) (*GetTargetOSConfigurationResponse, error) + // UserTLSCert returns the user TLS certificate for a specific profile. + UserTLSCert(ctx context.Context, in *UserTLSCertRequest, opts ...grpc.CallOption) (*UserTLSCertResponse, error) + // SignForUserTLS signs a digest with the user TLS private key. + SignForUserTLS(ctx context.Context, in *SignForUserTLSRequest, opts ...grpc.CallOption) (*SignForUserTLSResponse, error) + // SessionSSHConfig returns the user SSH configuration for an SSH session. + SessionSSHConfig(ctx context.Context, in *SessionSSHConfigRequest, opts ...grpc.CallOption) (*SessionSSHConfigResponse, error) + // SignForSSHSession signs a digest with the SSH private key associated with the + // session from a previous call to SessionSSHConfig. + SignForSSHSession(ctx context.Context, in *SignForSSHSessionRequest, opts ...grpc.CallOption) (*SignForSSHSessionResponse, error) + // ExchangeSSHKeys sends VNet's SSH host CA key to the client application and + // returns the user public key. + ExchangeSSHKeys(ctx context.Context, in *ExchangeSSHKeysRequest, opts ...grpc.CallOption) (*ExchangeSSHKeysResponse, error) } type clientApplicationServiceClient struct { @@ -151,10 +168,10 @@ func (c *clientApplicationServiceClient) SignForApp(ctx context.Context, in *Sig return out, nil } -func (c *clientApplicationServiceClient) OnNewConnection(ctx context.Context, in *OnNewConnectionRequest, opts ...grpc.CallOption) (*OnNewConnectionResponse, error) { +func (c *clientApplicationServiceClient) OnNewAppConnection(ctx context.Context, in *OnNewAppConnectionRequest, opts ...grpc.CallOption) (*OnNewAppConnectionResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(OnNewConnectionResponse) - err := c.cc.Invoke(ctx, ClientApplicationService_OnNewConnection_FullMethodName, in, out, cOpts...) + out := new(OnNewAppConnectionResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_OnNewAppConnection_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -181,6 +198,56 @@ func (c *clientApplicationServiceClient) GetTargetOSConfiguration(ctx context.Co return out, nil } +func (c *clientApplicationServiceClient) UserTLSCert(ctx context.Context, in *UserTLSCertRequest, opts ...grpc.CallOption) (*UserTLSCertResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UserTLSCertResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_UserTLSCert_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *clientApplicationServiceClient) SignForUserTLS(ctx context.Context, in *SignForUserTLSRequest, opts ...grpc.CallOption) (*SignForUserTLSResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SignForUserTLSResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_SignForUserTLS_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *clientApplicationServiceClient) SessionSSHConfig(ctx context.Context, in *SessionSSHConfigRequest, opts ...grpc.CallOption) (*SessionSSHConfigResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SessionSSHConfigResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_SessionSSHConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *clientApplicationServiceClient) SignForSSHSession(ctx context.Context, in *SignForSSHSessionRequest, opts ...grpc.CallOption) (*SignForSSHSessionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SignForSSHSessionResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_SignForSSHSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *clientApplicationServiceClient) ExchangeSSHKeys(ctx context.Context, in *ExchangeSSHKeysRequest, opts ...grpc.CallOption) (*ExchangeSSHKeysResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExchangeSSHKeysResponse) + err := c.cc.Invoke(ctx, ClientApplicationService_ExchangeSSHKeys_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ClientApplicationServiceServer is the server API for ClientApplicationService service. // All implementations must embed UnimplementedClientApplicationServiceServer // for forward compatibility. @@ -207,15 +274,27 @@ type ClientApplicationServiceServer interface { // SignForApp issues a signature with the private key associated with an x509 // certificate previously issued for a requested app. SignForApp(context.Context, *SignForAppRequest) (*SignForAppResponse, error) - // OnNewConnection gets called whenever a new connection is about to be + // OnNewAppConnection gets called whenever a new app connection is about to be // established through VNet for observability. - OnNewConnection(context.Context, *OnNewConnectionRequest) (*OnNewConnectionResponse, error) + OnNewAppConnection(context.Context, *OnNewAppConnectionRequest) (*OnNewAppConnectionResponse, error) // OnInvalidLocalPort gets called before VNet refuses to handle a connection // to a multi-port TCP app because the provided port does not match any of the // TCP ports in the app spec. OnInvalidLocalPort(context.Context, *OnInvalidLocalPortRequest) (*OnInvalidLocalPortResponse, error) // GetTargetOSConfiguration gets the target OS configuration. GetTargetOSConfiguration(context.Context, *GetTargetOSConfigurationRequest) (*GetTargetOSConfigurationResponse, error) + // UserTLSCert returns the user TLS certificate for a specific profile. + UserTLSCert(context.Context, *UserTLSCertRequest) (*UserTLSCertResponse, error) + // SignForUserTLS signs a digest with the user TLS private key. + SignForUserTLS(context.Context, *SignForUserTLSRequest) (*SignForUserTLSResponse, error) + // SessionSSHConfig returns the user SSH configuration for an SSH session. + SessionSSHConfig(context.Context, *SessionSSHConfigRequest) (*SessionSSHConfigResponse, error) + // SignForSSHSession signs a digest with the SSH private key associated with the + // session from a previous call to SessionSSHConfig. + SignForSSHSession(context.Context, *SignForSSHSessionRequest) (*SignForSSHSessionResponse, error) + // ExchangeSSHKeys sends VNet's SSH host CA key to the client application and + // returns the user public key. + ExchangeSSHKeys(context.Context, *ExchangeSSHKeysRequest) (*ExchangeSSHKeysResponse, error) mustEmbedUnimplementedClientApplicationServiceServer() } @@ -244,8 +323,8 @@ func (UnimplementedClientApplicationServiceServer) ReissueAppCert(context.Contex func (UnimplementedClientApplicationServiceServer) SignForApp(context.Context, *SignForAppRequest) (*SignForAppResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SignForApp not implemented") } -func (UnimplementedClientApplicationServiceServer) OnNewConnection(context.Context, *OnNewConnectionRequest) (*OnNewConnectionResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method OnNewConnection not implemented") +func (UnimplementedClientApplicationServiceServer) OnNewAppConnection(context.Context, *OnNewAppConnectionRequest) (*OnNewAppConnectionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method OnNewAppConnection not implemented") } func (UnimplementedClientApplicationServiceServer) OnInvalidLocalPort(context.Context, *OnInvalidLocalPortRequest) (*OnInvalidLocalPortResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method OnInvalidLocalPort not implemented") @@ -253,6 +332,21 @@ func (UnimplementedClientApplicationServiceServer) OnInvalidLocalPort(context.Co func (UnimplementedClientApplicationServiceServer) GetTargetOSConfiguration(context.Context, *GetTargetOSConfigurationRequest) (*GetTargetOSConfigurationResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetTargetOSConfiguration not implemented") } +func (UnimplementedClientApplicationServiceServer) UserTLSCert(context.Context, *UserTLSCertRequest) (*UserTLSCertResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UserTLSCert not implemented") +} +func (UnimplementedClientApplicationServiceServer) SignForUserTLS(context.Context, *SignForUserTLSRequest) (*SignForUserTLSResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SignForUserTLS not implemented") +} +func (UnimplementedClientApplicationServiceServer) SessionSSHConfig(context.Context, *SessionSSHConfigRequest) (*SessionSSHConfigResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SessionSSHConfig not implemented") +} +func (UnimplementedClientApplicationServiceServer) SignForSSHSession(context.Context, *SignForSSHSessionRequest) (*SignForSSHSessionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SignForSSHSession not implemented") +} +func (UnimplementedClientApplicationServiceServer) ExchangeSSHKeys(context.Context, *ExchangeSSHKeysRequest) (*ExchangeSSHKeysResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ExchangeSSHKeys not implemented") +} func (UnimplementedClientApplicationServiceServer) mustEmbedUnimplementedClientApplicationServiceServer() { } func (UnimplementedClientApplicationServiceServer) testEmbeddedByValue() {} @@ -383,20 +477,20 @@ func _ClientApplicationService_SignForApp_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } -func _ClientApplicationService_OnNewConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(OnNewConnectionRequest) +func _ClientApplicationService_OnNewAppConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OnNewAppConnectionRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(ClientApplicationServiceServer).OnNewConnection(ctx, in) + return srv.(ClientApplicationServiceServer).OnNewAppConnection(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ClientApplicationService_OnNewConnection_FullMethodName, + FullMethod: ClientApplicationService_OnNewAppConnection_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ClientApplicationServiceServer).OnNewConnection(ctx, req.(*OnNewConnectionRequest)) + return srv.(ClientApplicationServiceServer).OnNewAppConnection(ctx, req.(*OnNewAppConnectionRequest)) } return interceptor(ctx, in, info, handler) } @@ -437,6 +531,96 @@ func _ClientApplicationService_GetTargetOSConfiguration_Handler(srv interface{}, return interceptor(ctx, in, info, handler) } +func _ClientApplicationService_UserTLSCert_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UserTLSCertRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).UserTLSCert(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_UserTLSCert_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).UserTLSCert(ctx, req.(*UserTLSCertRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClientApplicationService_SignForUserTLS_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SignForUserTLSRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).SignForUserTLS(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_SignForUserTLS_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).SignForUserTLS(ctx, req.(*SignForUserTLSRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClientApplicationService_SessionSSHConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SessionSSHConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).SessionSSHConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_SessionSSHConfig_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).SessionSSHConfig(ctx, req.(*SessionSSHConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClientApplicationService_SignForSSHSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SignForSSHSessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).SignForSSHSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_SignForSSHSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).SignForSSHSession(ctx, req.(*SignForSSHSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClientApplicationService_ExchangeSSHKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExchangeSSHKeysRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClientApplicationServiceServer).ExchangeSSHKeys(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClientApplicationService_ExchangeSSHKeys_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClientApplicationServiceServer).ExchangeSSHKeys(ctx, req.(*ExchangeSSHKeysRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ClientApplicationService_ServiceDesc is the grpc.ServiceDesc for ClientApplicationService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -469,8 +653,8 @@ var ClientApplicationService_ServiceDesc = grpc.ServiceDesc{ Handler: _ClientApplicationService_SignForApp_Handler, }, { - MethodName: "OnNewConnection", - Handler: _ClientApplicationService_OnNewConnection_Handler, + MethodName: "OnNewAppConnection", + Handler: _ClientApplicationService_OnNewAppConnection_Handler, }, { MethodName: "OnInvalidLocalPort", @@ -480,6 +664,26 @@ var ClientApplicationService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetTargetOSConfiguration", Handler: _ClientApplicationService_GetTargetOSConfiguration_Handler, }, + { + MethodName: "UserTLSCert", + Handler: _ClientApplicationService_UserTLSCert_Handler, + }, + { + MethodName: "SignForUserTLS", + Handler: _ClientApplicationService_SignForUserTLS_Handler, + }, + { + MethodName: "SessionSSHConfig", + Handler: _ClientApplicationService_SessionSSHConfig_Handler, + }, + { + MethodName: "SignForSSHSession", + Handler: _ClientApplicationService_SignForSSHSession_Handler, + }, + { + MethodName: "ExchangeSSHKeys", + Handler: _ClientApplicationService_ExchangeSSHKeys_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "teleport/lib/vnet/v1/client_application_service.proto", diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts index f0240cdf36c14..6787923ba9de6 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.client.ts @@ -23,12 +23,14 @@ import type { RpcTransport } from "@protobuf-ts/runtime-rpc"; import type { ServiceInfo } from "@protobuf-ts/runtime-rpc"; import { VnetService } from "./vnet_service_pb"; +import type { AutoConfigureSSHResponse } from "./vnet_service_pb"; +import type { AutoConfigureSSHRequest } from "./vnet_service_pb"; import type { RunDiagnosticsResponse } from "./vnet_service_pb"; import type { RunDiagnosticsRequest } from "./vnet_service_pb"; import type { GetBackgroundItemStatusResponse } from "./vnet_service_pb"; import type { GetBackgroundItemStatusRequest } from "./vnet_service_pb"; -import type { ListDNSZonesResponse } from "./vnet_service_pb"; -import type { ListDNSZonesRequest } from "./vnet_service_pb"; +import type { GetServiceInfoResponse } from "./vnet_service_pb"; +import type { GetServiceInfoRequest } from "./vnet_service_pb"; import type { StopResponse } from "./vnet_service_pb"; import type { StopRequest } from "./vnet_service_pb"; import { stackIntercept } from "@protobuf-ts/runtime-rpc"; @@ -55,19 +57,11 @@ export interface IVnetServiceClient { */ stop(input: StopRequest, options?: RpcOptions): UnaryCall; /** - * ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - * includes the proxy service hostnames and custom DNS zones configured in vnet_config. + * GetServiceInfo returns info about the running VNet service. * - * This is fetched independently of what the Electron app thinks the current state of the cluster - * looks like, since the VNet admin process also fetches this data independently of the Electron - * app. - * - * Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - * be fetched (due to e.g., a network error or an expired cert). - * - * @generated from protobuf rpc: ListDNSZones(teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest) returns (teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse); + * @generated from protobuf rpc: GetServiceInfo(teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest) returns (teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse); */ - listDNSZones(input: ListDNSZonesRequest, options?: RpcOptions): UnaryCall; + getServiceInfo(input: GetServiceInfoRequest, options?: RpcOptions): UnaryCall; /** * GetBackgroundItemStatus returns the status of the background item responsible for launching * VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. @@ -82,6 +76,13 @@ export interface IVnetServiceClient { * @generated from protobuf rpc: RunDiagnostics(teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest) returns (teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse); */ runDiagnostics(input: RunDiagnosticsRequest, options?: RpcOptions): UnaryCall; + /** + * AutoConfigureSSH automatically configures OpenSSH-compatible clients for + * connections to Teleport SSH hosts. + * + * @generated from protobuf rpc: AutoConfigureSSH(teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest) returns (teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse); + */ + autoConfigureSSH(input: AutoConfigureSSHRequest, options?: RpcOptions): UnaryCall; } /** * VnetService provides methods to manage a VNet instance. @@ -113,21 +114,13 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { return stackIntercept("unary", this._transport, method, opt, input); } /** - * ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - * includes the proxy service hostnames and custom DNS zones configured in vnet_config. - * - * This is fetched independently of what the Electron app thinks the current state of the cluster - * looks like, since the VNet admin process also fetches this data independently of the Electron - * app. + * GetServiceInfo returns info about the running VNet service. * - * Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - * be fetched (due to e.g., a network error or an expired cert). - * - * @generated from protobuf rpc: ListDNSZones(teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest) returns (teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse); + * @generated from protobuf rpc: GetServiceInfo(teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest) returns (teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse); */ - listDNSZones(input: ListDNSZonesRequest, options?: RpcOptions): UnaryCall { + getServiceInfo(input: GetServiceInfoRequest, options?: RpcOptions): UnaryCall { const method = this.methods[2], opt = this._transport.mergeOptions(options); - return stackIntercept("unary", this._transport, method, opt, input); + return stackIntercept("unary", this._transport, method, opt, input); } /** * GetBackgroundItemStatus returns the status of the background item responsible for launching @@ -149,4 +142,14 @@ export class VnetServiceClient implements IVnetServiceClient, ServiceInfo { const method = this.methods[4], opt = this._transport.mergeOptions(options); return stackIntercept("unary", this._transport, method, opt, input); } + /** + * AutoConfigureSSH automatically configures OpenSSH-compatible clients for + * connections to Teleport SSH hosts. + * + * @generated from protobuf rpc: AutoConfigureSSH(teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest) returns (teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse); + */ + autoConfigureSSH(input: AutoConfigureSSHRequest, options?: RpcOptions): UnaryCall { + const method = this.methods[5], opt = this._transport.mergeOptions(options); + return stackIntercept("unary", this._transport, method, opt, input); + } } diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts index ffc239038e5f1..15198d3f54bdb 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.grpc-server.ts @@ -20,12 +20,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // +import { AutoConfigureSSHResponse } from "./vnet_service_pb"; +import { AutoConfigureSSHRequest } from "./vnet_service_pb"; import { RunDiagnosticsResponse } from "./vnet_service_pb"; import { RunDiagnosticsRequest } from "./vnet_service_pb"; import { GetBackgroundItemStatusResponse } from "./vnet_service_pb"; import { GetBackgroundItemStatusRequest } from "./vnet_service_pb"; -import { ListDNSZonesResponse } from "./vnet_service_pb"; -import { ListDNSZonesRequest } from "./vnet_service_pb"; +import { GetServiceInfoResponse } from "./vnet_service_pb"; +import { GetServiceInfoRequest } from "./vnet_service_pb"; import { StopResponse } from "./vnet_service_pb"; import { StopRequest } from "./vnet_service_pb"; import { StartResponse } from "./vnet_service_pb"; @@ -50,19 +52,11 @@ export interface IVnetService extends grpc.UntypedServiceImplementation { */ stop: grpc.handleUnaryCall; /** - * ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - * includes the proxy service hostnames and custom DNS zones configured in vnet_config. + * GetServiceInfo returns info about the running VNet service. * - * This is fetched independently of what the Electron app thinks the current state of the cluster - * looks like, since the VNet admin process also fetches this data independently of the Electron - * app. - * - * Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - * be fetched (due to e.g., a network error or an expired cert). - * - * @generated from protobuf rpc: ListDNSZones(teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest) returns (teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse); + * @generated from protobuf rpc: GetServiceInfo(teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest) returns (teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse); */ - listDNSZones: grpc.handleUnaryCall; + getServiceInfo: grpc.handleUnaryCall; /** * GetBackgroundItemStatus returns the status of the background item responsible for launching * VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. @@ -77,6 +71,13 @@ export interface IVnetService extends grpc.UntypedServiceImplementation { * @generated from protobuf rpc: RunDiagnostics(teleport.lib.teleterm.vnet.v1.RunDiagnosticsRequest) returns (teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse); */ runDiagnostics: grpc.handleUnaryCall; + /** + * AutoConfigureSSH automatically configures OpenSSH-compatible clients for + * connections to Teleport SSH hosts. + * + * @generated from protobuf rpc: AutoConfigureSSH(teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest) returns (teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse); + */ + autoConfigureSSH: grpc.handleUnaryCall; } /** * @grpc/grpc-js definition for the protobuf service teleport.lib.teleterm.vnet.v1.VnetService. @@ -110,15 +111,15 @@ export const vnetServiceDefinition: grpc.ServiceDefinition = { responseSerialize: value => Buffer.from(StopResponse.toBinary(value)), requestSerialize: value => Buffer.from(StopRequest.toBinary(value)) }, - listDNSZones: { - path: "/teleport.lib.teleterm.vnet.v1.VnetService/ListDNSZones", - originalName: "ListDNSZones", + getServiceInfo: { + path: "/teleport.lib.teleterm.vnet.v1.VnetService/GetServiceInfo", + originalName: "GetServiceInfo", requestStream: false, responseStream: false, - responseDeserialize: bytes => ListDNSZonesResponse.fromBinary(bytes), - requestDeserialize: bytes => ListDNSZonesRequest.fromBinary(bytes), - responseSerialize: value => Buffer.from(ListDNSZonesResponse.toBinary(value)), - requestSerialize: value => Buffer.from(ListDNSZonesRequest.toBinary(value)) + responseDeserialize: bytes => GetServiceInfoResponse.fromBinary(bytes), + requestDeserialize: bytes => GetServiceInfoRequest.fromBinary(bytes), + responseSerialize: value => Buffer.from(GetServiceInfoResponse.toBinary(value)), + requestSerialize: value => Buffer.from(GetServiceInfoRequest.toBinary(value)) }, getBackgroundItemStatus: { path: "/teleport.lib.teleterm.vnet.v1.VnetService/GetBackgroundItemStatus", @@ -139,5 +140,15 @@ export const vnetServiceDefinition: grpc.ServiceDefinition = { requestDeserialize: bytes => RunDiagnosticsRequest.fromBinary(bytes), responseSerialize: value => Buffer.from(RunDiagnosticsResponse.toBinary(value)), requestSerialize: value => Buffer.from(RunDiagnosticsRequest.toBinary(value)) + }, + autoConfigureSSH: { + path: "/teleport.lib.teleterm.vnet.v1.VnetService/AutoConfigureSSH", + originalName: "AutoConfigureSSH", + requestStream: false, + responseStream: false, + responseDeserialize: bytes => AutoConfigureSSHResponse.fromBinary(bytes), + requestDeserialize: bytes => AutoConfigureSSHRequest.fromBinary(bytes), + responseSerialize: value => Buffer.from(AutoConfigureSSHResponse.toBinary(value)), + requestSerialize: value => Buffer.from(AutoConfigureSSHRequest.toBinary(value)) } }; diff --git a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts index 08f7483ad36b1..500e0d2931bf4 100644 --- a/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts +++ b/gen/proto/ts/teleport/lib/teleterm/vnet/v1/vnet_service_pb.ts @@ -60,24 +60,45 @@ export interface StopRequest { export interface StopResponse { } /** - * Request for ListDNSZones. + * Request for GetServiceInfo. * - * @generated from protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest */ -export interface ListDNSZonesRequest { +export interface GetServiceInfoRequest { } /** - * Response for ListDNSZones. + * GetServiceInfoResponse contains the status of the running VNet service. * - * @generated from protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse */ -export interface ListDNSZonesResponse { +export interface GetServiceInfoResponse { /** - * dns_zones is a deduplicated list of DNS zones. + * app_dns_zones is a deduplicated list of all DNS zones valid as DNS + * suffixes for connections to TCP apps. * - * @generated from protobuf field: repeated string dns_zones = 1; + * @generated from protobuf field: repeated string app_dns_zones = 1; */ - dnsZones: string[]; + appDnsZones: string[]; + /** + * clusters is a list of cluster names valid as DNS suffixes for SSH hosts. + * + * @generated from protobuf field: repeated string clusters = 2; + */ + clusters: string[]; + /** + * ssh_configured is true if the user's SSH config file includes VNet's + * generated SSH config necessary for SSH access. + * + * @generated from protobuf field: bool ssh_configured = 3; + */ + sshConfigured: boolean; + /** + * vnet_ssh_config_path is the path of VNet's generated OpenSSH-compatible + * config file. + * + * @generated from protobuf field: string vnet_ssh_config_path = 4; + */ + vnetSshConfigPath: string; } /** * Request for GetBackgroundItemStatus. @@ -115,6 +136,20 @@ export interface RunDiagnosticsResponse { */ report?: Report; } +/** + * Request for AutoConfigureSSH. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + */ +export interface AutoConfigureSSHRequest { +} +/** + * Response for AutoConfigureSSH. + * + * @generated from protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + */ +export interface AutoConfigureSSHResponse { +} /** * BackgroundItemStatus maps to SMAppServiceStatus of the Service Management framework in macOS. * https://developer.apple.com/documentation/servicemanagement/smappservice/status-swift.enum?language=objc @@ -251,20 +286,20 @@ class StopResponse$Type extends MessageType { */ export const StopResponse = new StopResponse$Type(); // @generated message type with reflection information, may provide speed optimized methods -class ListDNSZonesRequest$Type extends MessageType { +class GetServiceInfoRequest$Type extends MessageType { constructor() { - super("teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest", []); + super("teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest", []); } - create(value?: PartialMessage): ListDNSZonesRequest { + create(value?: PartialMessage): GetServiceInfoRequest { const message = globalThis.Object.create((this.messagePrototype!)); if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListDNSZonesRequest): ListDNSZonesRequest { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetServiceInfoRequest): GetServiceInfoRequest { return target ?? this.create(); } - internalBinaryWrite(message: ListDNSZonesRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + internalBinaryWrite(message: GetServiceInfoRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -272,30 +307,45 @@ class ListDNSZonesRequest$Type extends MessageType { } } /** - * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesRequest + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoRequest */ -export const ListDNSZonesRequest = new ListDNSZonesRequest$Type(); +export const GetServiceInfoRequest = new GetServiceInfoRequest$Type(); // @generated message type with reflection information, may provide speed optimized methods -class ListDNSZonesResponse$Type extends MessageType { +class GetServiceInfoResponse$Type extends MessageType { constructor() { - super("teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse", [ - { no: 1, name: "dns_zones", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + super("teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse", [ + { no: 1, name: "app_dns_zones", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "clusters", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "ssh_configured", kind: "scalar", T: 8 /*ScalarType.BOOL*/ }, + { no: 4, name: "vnet_ssh_config_path", kind: "scalar", T: 9 /*ScalarType.STRING*/ } ]); } - create(value?: PartialMessage): ListDNSZonesResponse { + create(value?: PartialMessage): GetServiceInfoResponse { const message = globalThis.Object.create((this.messagePrototype!)); - message.dnsZones = []; + message.appDnsZones = []; + message.clusters = []; + message.sshConfigured = false; + message.vnetSshConfigPath = ""; if (value !== undefined) - reflectionMergePartial(this, message, value); + reflectionMergePartial(this, message, value); return message; } - internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: ListDNSZonesResponse): ListDNSZonesResponse { + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: GetServiceInfoResponse): GetServiceInfoResponse { let message = target ?? this.create(), end = reader.pos + length; while (reader.pos < end) { let [fieldNo, wireType] = reader.tag(); switch (fieldNo) { - case /* repeated string dns_zones */ 1: - message.dnsZones.push(reader.string()); + case /* repeated string app_dns_zones */ 1: + message.appDnsZones.push(reader.string()); + break; + case /* repeated string clusters */ 2: + message.clusters.push(reader.string()); + break; + case /* bool ssh_configured */ 3: + message.sshConfigured = reader.bool(); + break; + case /* string vnet_ssh_config_path */ 4: + message.vnetSshConfigPath = reader.string(); break; default: let u = options.readUnknownField; @@ -308,10 +358,19 @@ class ListDNSZonesResponse$Type extends MessageType { } return message; } - internalBinaryWrite(message: ListDNSZonesResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { - /* repeated string dns_zones = 1; */ - for (let i = 0; i < message.dnsZones.length; i++) - writer.tag(1, WireType.LengthDelimited).string(message.dnsZones[i]); + internalBinaryWrite(message: GetServiceInfoResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* repeated string app_dns_zones = 1; */ + for (let i = 0; i < message.appDnsZones.length; i++) + writer.tag(1, WireType.LengthDelimited).string(message.appDnsZones[i]); + /* repeated string clusters = 2; */ + for (let i = 0; i < message.clusters.length; i++) + writer.tag(2, WireType.LengthDelimited).string(message.clusters[i]); + /* bool ssh_configured = 3; */ + if (message.sshConfigured !== false) + writer.tag(3, WireType.Varint).bool(message.sshConfigured); + /* string vnet_ssh_config_path = 4; */ + if (message.vnetSshConfigPath !== "") + writer.tag(4, WireType.LengthDelimited).string(message.vnetSshConfigPath); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -319,9 +378,9 @@ class ListDNSZonesResponse$Type extends MessageType { } } /** - * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.ListDNSZonesResponse + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.GetServiceInfoResponse */ -export const ListDNSZonesResponse = new ListDNSZonesResponse$Type(); +export const GetServiceInfoResponse = new GetServiceInfoResponse$Type(); // @generated message type with reflection information, may provide speed optimized methods class GetBackgroundItemStatusRequest$Type extends MessageType { constructor() { @@ -465,13 +524,64 @@ class RunDiagnosticsResponse$Type extends MessageType { * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.RunDiagnosticsResponse */ export const RunDiagnosticsResponse = new RunDiagnosticsResponse$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class AutoConfigureSSHRequest$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest", []); + } + create(value?: PartialMessage): AutoConfigureSSHRequest { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AutoConfigureSSHRequest): AutoConfigureSSHRequest { + return target ?? this.create(); + } + internalBinaryWrite(message: AutoConfigureSSHRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHRequest + */ +export const AutoConfigureSSHRequest = new AutoConfigureSSHRequest$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class AutoConfigureSSHResponse$Type extends MessageType { + constructor() { + super("teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse", []); + } + create(value?: PartialMessage): AutoConfigureSSHResponse { + const message = globalThis.Object.create((this.messagePrototype!)); + if (value !== undefined) + reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: AutoConfigureSSHResponse): AutoConfigureSSHResponse { + return target ?? this.create(); + } + internalBinaryWrite(message: AutoConfigureSSHResponse, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + let u = options.writeUnknownFields; + if (u !== false) + (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message teleport.lib.teleterm.vnet.v1.AutoConfigureSSHResponse + */ +export const AutoConfigureSSHResponse = new AutoConfigureSSHResponse$Type(); /** * @generated ServiceType for protobuf service teleport.lib.teleterm.vnet.v1.VnetService */ export const VnetService = new ServiceType("teleport.lib.teleterm.vnet.v1.VnetService", [ { name: "Start", options: {}, I: StartRequest, O: StartResponse }, { name: "Stop", options: {}, I: StopRequest, O: StopResponse }, - { name: "ListDNSZones", options: {}, I: ListDNSZonesRequest, O: ListDNSZonesResponse }, + { name: "GetServiceInfo", options: {}, I: GetServiceInfoRequest, O: GetServiceInfoResponse }, { name: "GetBackgroundItemStatus", options: {}, I: GetBackgroundItemStatusRequest, O: GetBackgroundItemStatusResponse }, - { name: "RunDiagnostics", options: {}, I: RunDiagnosticsRequest, O: RunDiagnosticsResponse } + { name: "RunDiagnostics", options: {}, I: RunDiagnosticsRequest, O: RunDiagnosticsResponse }, + { name: "AutoConfigureSSH", options: {}, I: AutoConfigureSSHRequest, O: AutoConfigureSSHResponse } ]); 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/client/cluster_client.go b/lib/client/cluster_client.go index f636c5d19d32b..40962d53ccc74 100644 --- a/lib/client/cluster_client.go +++ b/lib/client/cluster_client.go @@ -282,28 +282,66 @@ func (c *ClusterClient) SessionSSHConfig(ctx context.Context, user string, targe return sshConfig, nil } - keyRing, err := c.tc.localAgent.GetKeyRing(target.Cluster, WithAllCerts...) + newKeyRing, completedMFA, err := c.SessionSSHKeyRing(ctx, user, target) if err != nil { - return nil, trace.Wrap(MFARequiredUnknown(err)) + return nil, trace.Wrap(err) + } + if !completedMFA { + // The caller relies on this function returning an error if + // target.MFACheck is nil and session MFA was not actually required. + return nil, trace.Wrap(services.ErrSessionMFANotRequired) + } + + am, err := newKeyRing.AsAuthMethod() + if err != nil { + return nil, trace.Wrap(ceremonyFailedErr{err}) + } + + sshConfig.Auth = []ssh.AuthMethod{am} + return sshConfig, nil +} + +// SessionSSHKeyRing returns a KeyRing valid for an SSH session to the target. +// If per session MFA is required to establish the connection, then the MFA +// ceremony will be performed. If per session MFA is not required, the user's +// base KeyRing for the cluster will be returned. +func (c *ClusterClient) SessionSSHKeyRing(ctx context.Context, user string, target NodeDetails) (keyRing *KeyRing, completedMFA bool, err error) { + ctx, span := c.Tracer.Start( + ctx, + "clusterClient/SessionSSHKeyRing", + oteltrace.WithSpanKind(oteltrace.SpanKindClient), + oteltrace.WithAttributes( + attribute.String("cluster", c.tc.SiteName), + ), + ) + defer span.End() + + baseKeyRing, err := c.tc.localAgent.GetKeyRing(target.Cluster, WithSSHCerts{}) + if err != nil { + return nil, false, trace.Wrap(MFARequiredUnknown(err)) + } + + if target.MFACheck != nil && !target.MFACheck.Required { + return baseKeyRing, false, nil } // Always connect to root for getting new credentials, but attempt to reuse // the existing client if possible. - rootClusterName, err := keyRing.RootClusterName() + rootClusterName, err := baseKeyRing.RootClusterName() if err != nil { - return nil, trace.Wrap(MFARequiredUnknown(err)) + return nil, false, trace.Wrap(MFARequiredUnknown(err)) } mfaClt := c if target.Cluster != rootClusterName { cfg, err := c.ProxyClient.ClientConfig(ctx, rootClusterName) if err != nil { - return nil, trace.Wrap(err) + return nil, false, trace.Wrap(err) } authClient, err := authclient.NewClient(cfg) if err != nil { - return nil, trace.Wrap(MFARequiredUnknown(err)) + return nil, false, trace.Wrap(MFARequiredUnknown(err)) } mfaClt = &ClusterClient{ @@ -326,20 +364,19 @@ func (c *ClusterClient) SessionSSHConfig(ctx context.Context, user string, targe RouteToCluster: target.Cluster, MFACheck: target.MFACheck, }, - keyRing, + baseKeyRing.Copy(), ) if err != nil { - return nil, trace.Wrap(err) + if errors.Is(err, services.ErrSessionMFANotRequired) { + log.DebugContext(ctx, "Session MFA was not required, returning original KeyRing") + return baseKeyRing, false, nil + } + log.DebugContext(ctx, "Error performing session MFA ceremony", "error", err) + return nil, false, trace.Wrap(err) } log.DebugContext(ctx, "Issued single-use user certificate after an MFA check") - am, err := result.KeyRing.AsAuthMethod() - if err != nil { - return nil, trace.Wrap(ceremonyFailedErr{err}) - } - - sshConfig.Auth = []ssh.AuthMethod{am} - return sshConfig, nil + return result.KeyRing, true, nil } // prepareUserCertsRequest creates a [proto.UserCertsRequest] with the fields diff --git a/lib/client/keystore.go b/lib/client/keystore.go index d7823b330ee75..06f930286705b 100644 --- a/lib/client/keystore.go +++ b/lib/client/keystore.go @@ -53,9 +53,9 @@ const ( // under ~/.tsh keyFilePerms os.FileMode = 0600 - // tshConfigFileName is the name of the directory containing the + // tshConfigDirName is the name of the directory containing the // tsh config file. - tshConfigFileName = "config" + tshConfigDirName = "config" // tshAzureDirName is the name of the directory containing the // az cli app-specific profiles. @@ -474,19 +474,21 @@ func (fs *FSKeyStore) DeleteKeys() error { if err != nil { return trace.ConvertSystemError(err) } - ignoreDirs := map[string]struct{}{tshConfigFileName: {}, tshAzureDirName: {}, tshBin: {}} for _, file := range files { - // Don't delete 'config', 'azure' and 'bin' directories. - // TODO: this is hackish and really shouldn't be needed, but fs.KeyDir is `~/.tsh` while it probably should be `~/.tsh/keys` instead. - if _, ok := ignoreDirs[file.Name()]; ok && file.IsDir() { - continue - } if file.IsDir() { - err := utils.RemoveAllSecure(filepath.Join(fs.KeyDir, file.Name())) - if err != nil { - return trace.ConvertSystemError(err) + switch file.Name() { + case tshConfigDirName, tshAzureDirName, tshBin: + // Don't delete 'config', 'azure' and 'bin' directories. + // TODO: this is hackish and really shouldn't be needed, but fs.KeyDir is `~/.tsh` while it probably should be `~/.tsh/keys` instead. + continue + } + } else { + switch file.Name() { + case keypaths.VNetClientSSHKey, keypaths.VNetClientSSHKeyPub: + // Don't delete VNet client SSH keys on logout in case a user wants to + // set these to their own key compatible with their third-party SSH client. + continue } - continue } err := utils.RemoveAllSecure(filepath.Join(fs.KeyDir, file.Name())) if err != nil { diff --git a/lib/teleterm/vnet/service.go b/lib/teleterm/vnet/service.go index 0233a2c6da798..12f18e80a6ad8 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,9 +30,12 @@ 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" + diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/teleterm/api/uri" @@ -40,6 +44,7 @@ import ( "github.com/gravitational/teleport/lib/teleterm/daemon" logutils "github.com/gravitational/teleport/lib/utils/log" "github.com/gravitational/teleport/lib/vnet" + "github.com/gravitational/teleport/lib/vnet/diag" ) var log = logutils.NewPackageLogger(teleport.ComponentKey, "term:vnet") @@ -90,6 +95,7 @@ type Config struct { // reporting. InstallationID string Clock clockwork.Clock + profilePath string } // CheckAndSetDefaults checks and sets the defaults @@ -110,6 +116,10 @@ func (c *Config) CheckAndSetDefaults() error { c.Clock = clockwork.NewRealClock() } + if c.profilePath == "" { + c.profilePath = profile.FullProfilePath(os.Getenv(types.HomeEnvVar)) + } + return nil } @@ -211,13 +221,8 @@ func (s *Service) Stop(ctx context.Context, req *api.StopRequest) (*api.StopResp return &api.StopResponse{}, nil } -// ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This -// includes the proxy service hostnames and custom DNS zones configured in vnet_config. -// -// This is fetched exactly the same way the VNet process fetches the DNS zones -// but may be slightly out of sync with the OS configuration if the admin -// process hasn't configured a recent change yet. -func (s *Service) ListDNSZones(ctx context.Context, req *api.ListDNSZonesRequest) (*api.ListDNSZonesResponse, error) { +// GetServiceInfo returns info about the running VNet service. +func (s *Service) GetServiceInfo(ctx context.Context, _ *api.GetServiceInfoRequest) (*api.GetServiceInfoResponse, error) { // Acquire the lock just to check the status of the service. We don't want the actual process of // listing DNS zones to block the user from performing other operations. s.mu.Lock() @@ -225,18 +230,97 @@ func (s *Service) ListDNSZones(ctx context.Context, req *api.ListDNSZonesRequest s.mu.Unlock() return nil, trace.CompareFailed("VNet is not running") } - osConfigProvider := s.vnetProcess.GetOSConfigProvider() + unifiedClusterConfigProvider := s.vnetProcess.GetUnifiedClusterConfigProvider() s.mu.Unlock() - targetOSConfig, err := osConfigProvider.GetTargetOSConfiguration(ctx) + unifiedClusterConfig, err := unifiedClusterConfigProvider.GetUnifiedClusterConfig(ctx) if err != nil { return nil, trace.Wrap(err) } - return &api.ListDNSZonesResponse{ - DnsZones: targetOSConfig.GetDnsZones(), + + sshConfigChecker, err := diag.NewSSHConfigChecker(s.cfg.profilePath) + if err != nil { + return nil, trace.Wrap(err, "building SSH config checker") + } + _, sshConfigured, err := sshConfigChecker.OpenSSHConfigIncludesVNetSSHConfig() + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err, "checking SSH configuration") + } + + return &api.GetServiceInfoResponse{ + AppDnsZones: unifiedClusterConfig.AppDNSZones(), + Clusters: unifiedClusterConfig.ClusterNames, + SshConfigured: sshConfigured, + VnetSshConfigPath: sshConfigChecker.VNetSSHConfigPath, }, nil } +// RunDiagnostics runs a set of heuristics to determine if VNet actually works +// on the device. It requires VNet to be started. +func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsRequest) (*api.RunDiagnosticsResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.status != statusRunning { + return nil, trace.CompareFailed("VNet is not running") + } + + if s.networkStackInfo.InterfaceName == "" { + return nil, trace.BadParameter("no interface name, this is a bug") + } + + if s.networkStackInfo.Ipv6Prefix == "" { + return nil, trace.BadParameter("no IPv6 prefix, this is a bug") + } + + nsa := &diagv1.NetworkStackAttempt{} + if ns, err := s.getNetworkStack(ctx); err != nil { + nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_ERROR + nsa.Error = err.Error() + } else { + nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_OK + nsa.NetworkStack = ns + } + + diagChecks, err := s.platformDiagChecks(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ + Clock: s.cfg.Clock, + NetworkStackAttempt: nsa, + DiagChecks: diagChecks, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return &api.RunDiagnosticsResponse{ + Report: report, + }, nil +} + +func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, error) { + unifiedClusterConfig, err := s.vnetProcess.GetUnifiedClusterConfigProvider().GetUnifiedClusterConfig(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + return &diagv1.NetworkStack{ + InterfaceName: s.networkStackInfo.InterfaceName, + Ipv6Prefix: s.networkStackInfo.Ipv6Prefix, + Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, + DnsZones: unifiedClusterConfig.AllDNSZones(), + }, nil +} + +// AutoConfigureSSH automatically configures OpenSSH-compatible clients for +// connections to Teleport SSH servers through VNet. +func (s *Service) AutoConfigureSSH(ctx context.Context, _ *api.AutoConfigureSSHRequest) (*api.AutoConfigureSSHResponse, error) { + err := vnet.AutoConfigureOpenSSH(ctx, s.cfg.profilePath) + return nil, trace.Wrap(err) +} + func (s *Service) stopLocked() error { if s.status == statusClosed { return trace.CompareFailed("VNet service has been closed") @@ -390,6 +474,43 @@ func (p *clientApplication) ReissueAppCert(ctx context.Context, appInfo *vnetv1. return cert, nil } +// UserTLSCert returns the user TLS certificate for the given profile. +func (p *clientApplication) UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) { + // We don't have easy access to the user TLS cert from here, the only way + // I've found is to reach through the ProxyClient as this does below. + clusterClient, err := p.getCachedClient(ctx, profileName, "") + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + clientConfig, err := clusterClient.ProxyClient.ClientConfig(ctx, "") + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "getting user client config") + } + if len(clientConfig.Credentials) < 1 { + return tls.Certificate{}, trace.Errorf("user client config has no credentials") + } + cred := clientConfig.Credentials[0] + tlsConfig, err := cred.TLSConfig() + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "getting user TLS config") + } + switch { + case len(tlsConfig.Certificates) > 0: + return tlsConfig.Certificates[0], nil + case tlsConfig.GetClientCertificate != nil: + // This is the actual path we currently take at the time of writing, + // api/client.configureTLS always sets tlsConfig.GetClientCertificate + // and unsets tlsConfig.Certificates. + tlsCert, err := tlsConfig.GetClientCertificate(nil) + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "getting client TLS certificate") + } + return *tlsCert, nil + default: + return tls.Certificate{}, trace.Errorf("user TLS config has no certificates") + } +} + // GetDialOptions returns ALPN dial options for the profile. func (p *clientApplication) GetDialOptions(ctx context.Context, profileName string) (*vnetv1.DialOptions, error) { cluster, tc, err := p.daemonService.ResolveClusterURI(uri.NewClusterURI(profileName)) @@ -402,20 +523,32 @@ func (p *clientApplication) GetDialOptions(ctx context.Context, profileName stri AlpnConnUpgradeRequired: tc.TLSRoutingConnUpgradeRequired, InsecureSkipVerify: p.insecureSkipVerify, } - if dialOpts.AlpnConnUpgradeRequired { - dialOpts.RootClusterCaCertPool, err = tc.RootClusterCACertPoolPEM(ctx) - if err != nil { - return nil, trace.Wrap(err, "loading root cluster CA cert pool") - } + dialOpts.RootClusterCaCertPool, err = tc.RootClusterCACertPoolPEM(ctx) + if err != nil { + return nil, trace.Wrap(err, "loading root cluster CA cert pool") } return dialOpts, nil } -// OnNewConnection submits a usage event once per clientApplication lifetime. -// That is, if a user makes multiple connections to a single app, OnNewConnection submits a single +// OnNewSSHSession submits a usage event for a new SSH session. +func (p *clientApplication) OnNewSSHSession(ctx context.Context, profileName, targetClusterName string) { + // Enqueue the event from a separate goroutine since we don't care about errors anyway and we also + // don't want to slow down VNet connections. + go func() { + // Not passing ctx to ReportSSHSession since ctx is tied to the + // lifetime of a short-lived API call, inheriting the context could + // interrupt reporting. + if err := p.usageReporter.ReportSSHSession(profileName, targetClusterName); err != nil { + log.ErrorContext(ctx, "Failed to submit SSH usage event") + } + }() +} + +// OnNewAppConnection submits an app usage event once per clientApplication lifetime. +// That is, if a user makes multiple connections to a single app, OnNewAppConnection submits a single // event. This is to mimic how Connect submits events for its app gateways. This lets us compare // popularity of VNet and app gateways. -func (p *clientApplication) OnNewConnection(ctx context.Context, appKey *vnetv1.AppKey) error { +func (p *clientApplication) OnNewAppConnection(ctx context.Context, appKey *vnetv1.AppKey) error { // Enqueue the event from a separate goroutine since we don't care about errors anyway and we also // don't want to slow down VNet connections. go func() { @@ -423,9 +556,8 @@ func (p *clientApplication) OnNewConnection(ctx context.Context, appKey *vnetv1. // Not passing ctx to ReportApp since ctx is tied to the lifetime of the connection. // If it's a short-lived connection, inheriting its context would interrupt reporting. - err := p.usageReporter.ReportApp(uri) - if err != nil { - log.ErrorContext(ctx, "Failed to submit usage event", "app", uri, "error", err) + if err := p.usageReporter.ReportApp(uri); err != nil { + log.ErrorContext(ctx, "Failed to submit app usage event", "app", uri, "error", err) } }() @@ -487,6 +619,7 @@ func (p *clientApplication) OnInvalidLocalPort(ctx context.Context, appInfo *vne type usageReporter interface { ReportApp(uri.ResourceURI) error + ReportSSHSession(profileName, rootClusterName string) error Stop() } @@ -556,6 +689,51 @@ func newDaemonUsageReporter(cfg daemonUsageReporterConfig) (*daemonUsageReporter }, nil } +// ReportSSHSession adds an event for a new SSH session to the events queue. +// It reports a new event for each new SSH session, in contrast to ReportApp +// which only reports each unique app once, to align with how Connect reports +// usage events for SSH sessions. +func (r *daemonUsageReporter) ReportSSHSession(profileName, rootClusterName string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.closed.Load() { + return trace.CompareFailed("usage reporter has been stopped") + } + + rootClusterURI := uri.NewClusterURI(profileName) + _, tc, err := r.cfg.ClientCache.ResolveClusterURI(rootClusterURI) + if err != nil { + return trace.Wrap(err) + } + + clusterID, ok := r.cfg.ClusterIDCache.Load(rootClusterURI) + if !ok { + return trace.NotFound("cluster ID for %q not found", rootClusterURI) + } + + log.DebugContext(context.Background(), "Reporting SSH usage event", "profile", profileName, "root_cluster", rootClusterName) + if err := r.cfg.EventConsumer.ReportUsageEvent(&apiteleterm.ReportUsageEventRequest{ + AuthClusterId: clusterID, + PrehogReq: &prehogv1alpha.SubmitConnectEventRequest{ + DistinctId: r.cfg.InstallationID, + Timestamp: timestamppb.Now(), + Event: &prehogv1alpha.SubmitConnectEventRequest_ProtocolUse{ + ProtocolUse: &prehogv1alpha.ConnectProtocolUseEvent{ + ClusterName: rootClusterName, + UserName: tc.Username, + Protocol: "ssh", + Origin: "vnet", + AccessThrough: "vnet", + }, + }, + }, + }); err != nil { + return trace.Wrap(err, "adding SSH usage event to queue") + } + return nil +} + // ReportApp adds an event related to the given app to the events queue, if the app wasn't reported // already. Only one invocation of ReportApp can be in flight at a time. func (r *daemonUsageReporter) ReportApp(appURI uri.ResourceURI) error { @@ -597,9 +775,9 @@ func (r *daemonUsageReporter) ReportApp(appURI uri.ResourceURI) error { return trace.NotFound("cluster ID for %q not found", rootClusterURI) } - log.DebugContext(ctx, "Reporting usage event", "app", appURI.String()) + log.DebugContext(ctx, "Reporting app usage event", "app", appURI.String()) - err = r.cfg.EventConsumer.ReportUsageEvent(&apiteleterm.ReportUsageEventRequest{ + if err := r.cfg.EventConsumer.ReportUsageEvent(&apiteleterm.ReportUsageEventRequest{ AuthClusterId: clusterID, PrehogReq: &prehogv1alpha.SubmitConnectEventRequest{ DistinctId: r.cfg.InstallationID, @@ -614,9 +792,8 @@ func (r *daemonUsageReporter) ReportApp(appURI uri.ResourceURI) error { }, }, }, - }) - if err != nil { - return trace.Wrap(err, "adding usage event to queue") + }); err != nil { + return trace.Wrap(err, "adding app usage event to queue") } r.reportedApps[appURI.String()] = struct{}{} @@ -643,7 +820,12 @@ func (r *daemonUsageReporter) Stop() { type disabledTelemetryUsageReporter struct{} func (r *disabledTelemetryUsageReporter) ReportApp(appURI uri.ResourceURI) error { - log.DebugContext(context.Background(), "Skipping usage event, usage reporting is turned off", "app", appURI.String()) + log.DebugContext(context.Background(), "Skipping app usage event, usage reporting is turned off", "app", appURI.String()) + return nil +} + +func (r *disabledTelemetryUsageReporter) ReportSSHSession(profileName, rootClusterName string) error { + log.DebugContext(context.Background(), "Skipping SSH usage event, usage reporting is turned off", "profile", profileName, "root_cluster", rootClusterName) return nil } diff --git a/lib/teleterm/vnet/service_darwin.go b/lib/teleterm/vnet/service_darwin.go index 707d26d70a43b..221350086ac5d 100644 --- a/lib/teleterm/vnet/service_darwin.go +++ b/lib/teleterm/vnet/service_darwin.go @@ -21,38 +21,10 @@ import ( "github.com/gravitational/trace" - api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/vnet/v1" - diagv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/diag/v1" "github.com/gravitational/teleport/lib/vnet/diag" ) -// RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that -// is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. -func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsRequest) (*api.RunDiagnosticsResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.status != statusRunning { - return nil, trace.CompareFailed("VNet is not running") - } - - if s.networkStackInfo.InterfaceName == "" { - return nil, trace.BadParameter("no interface name, this is a bug") - } - - if s.networkStackInfo.Ipv6Prefix == "" { - return nil, trace.BadParameter("no IPv6 prefix, this is a bug") - } - - nsa := &diagv1.NetworkStackAttempt{} - if ns, err := s.getNetworkStack(ctx); err != nil { - nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_ERROR - nsa.Error = err.Error() - } else { - nsa.Status = diagv1.CheckAttemptStatus_CHECK_ATTEMPT_STATUS_OK - nsa.NetworkStack = ns - } - +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { routeConflictDiag, err := diag.NewRouteConflictDiag(&diag.RouteConflictConfig{ VnetIfaceName: s.networkStackInfo.InterfaceName, Routing: &diag.DarwinRouting{}, @@ -62,29 +34,15 @@ func (s *Service) RunDiagnostics(ctx context.Context, req *api.RunDiagnosticsReq return nil, trace.Wrap(err) } - report, err := diag.GenerateReport(ctx, diag.ReportPrerequisites{ - Clock: s.cfg.Clock, - NetworkStackAttempt: nsa, - DiagChecks: []diag.DiagCheck{routeConflictDiag}, + sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ + ProfilePath: s.cfg.profilePath, }) if err != nil { return nil, trace.Wrap(err) } - return &api.RunDiagnosticsResponse{ - Report: report, - }, nil -} - -func (s *Service) getNetworkStack(ctx context.Context) (*diagv1.NetworkStack, error) { - targetOSConfig, err := s.vnetProcess.GetOSConfigProvider().GetTargetOSConfiguration(ctx) - if err != nil { - return nil, trace.Wrap(err) - } - return &diagv1.NetworkStack{ - InterfaceName: s.networkStackInfo.InterfaceName, - Ipv6Prefix: s.networkStackInfo.Ipv6Prefix, - Ipv4CidrRanges: targetOSConfig.GetIpv4CidrRanges(), - DnsZones: targetOSConfig.GetDnsZones(), + return []diag.DiagCheck{ + routeConflictDiag, + sshDiag, }, nil } diff --git a/lib/teleterm/vnet/service_other.go b/lib/teleterm/vnet/service_other.go new file mode 100644 index 0000000000000..e710809f91ffd --- /dev/null +++ b/lib/teleterm/vnet/service_other.go @@ -0,0 +1,29 @@ +// 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 . + +//go:build !darwin && !windows + +package vnet + +import ( + "context" + + "github.com/gravitational/teleport/lib/vnet/diag" +) + +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { + return nil, nil +} diff --git a/lib/teleterm/vnet/service_test.go b/lib/teleterm/vnet/service_test.go index 58fba75e2afe9..b97c73acdcfdf 100644 --- a/lib/teleterm/vnet/service_test.go +++ b/lib/teleterm/vnet/service_test.go @@ -77,6 +77,11 @@ func TestDaemonUsageReporter(t *testing.T) { err = usageReporter.ReportApp(clusterWithoutClusterID.AppendApp("bar")) require.ErrorIs(t, err, trace.NotFound("cluster ID for \"/clusters/no-cluster-id\" not found")) require.Equal(t, 1, eventConsumer.EventCount()) + + // Verify that reporting an SSH session works. + err = usageReporter.ReportSSHSession(validCluster.GetProfileName(), "foo") + require.NoError(t, err) + require.Equal(t, 2, eventConsumer.EventCount()) } func TestDaemonUsageReporter_Stop(t *testing.T) { diff --git a/lib/teleterm/vnet/service_windows.go b/lib/teleterm/vnet/service_windows.go new file mode 100644 index 0000000000000..d41fe2ee00f7d --- /dev/null +++ b/lib/teleterm/vnet/service_windows.go @@ -0,0 +1,48 @@ +// 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 vnet + +import ( + "context" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/vnet/diag" +) + +func (s *Service) platformDiagChecks(ctx context.Context) ([]diag.DiagCheck, error) { + routeConflictDiag, err := diag.NewRouteConflictDiag(&diag.RouteConflictConfig{ + VnetIfaceName: s.networkStackInfo.InterfaceName, + Routing: &diag.WindowsRouting{}, + Interfaces: &diag.NetInterfaces{}, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + sshDiag, err := diag.NewSSHDiag(&diag.SSHConfig{ + ProfilePath: s.cfg.profilePath, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return []diag.DiagCheck{ + routeConflictDiag, + sshDiag, + }, nil +} diff --git a/lib/vnet/admin_process_common.go b/lib/vnet/admin_process_common.go index bf4574aae01f1..7ec69dae0b07f 100644 --- a/lib/vnet/admin_process_common.go +++ b/lib/vnet/admin_process_common.go @@ -17,15 +17,26 @@ package vnet import ( + "context" + "github.com/gravitational/trace" "github.com/jonboulle/clockwork" ) -func newNetworkStackConfig(tun tunDevice, clt *clientApplicationServiceClient) (*networkStackConfig, error) { +func newNetworkStackConfig(ctx context.Context, tun tunDevice, clt *clientApplicationServiceClient) (*networkStackConfig, error) { + clock := clockwork.NewRealClock() + sshProvider, err := newSSHProvider(ctx, sshProviderConfig{ + clt: clt, + clock: clock, + }) + if err != nil { + return nil, trace.Wrap(err) + } tcpHandlerResolver := newTCPHandlerResolver(&tcpHandlerResolverConfig{ clt: clt, appProvider: newAppProvider(clt), - clock: clockwork.NewRealClock(), + sshProvider: sshProvider, + clock: clock, }) ipv6Prefix, err := newIPv6Prefix() if err != nil { diff --git a/lib/vnet/admin_process_darwin.go b/lib/vnet/admin_process_darwin.go index c9816d5fb237b..0465dd8e8aae5 100644 --- a/lib/vnet/admin_process_darwin.go +++ b/lib/vnet/admin_process_darwin.go @@ -59,7 +59,7 @@ func RunDarwinAdminProcess(ctx context.Context, config daemon.Config) error { } defer tun.Close() - networkStackConfig, err := newNetworkStackConfig(tun, clt) + networkStackConfig, err := newNetworkStackConfig(ctx, tun, clt) if err != nil { return trace.Wrap(err, "creating network stack config") } @@ -75,7 +75,7 @@ func RunDarwinAdminProcess(ctx context.Context, config daemon.Config) error { return trace.Wrap(err, "reporting network stack info to client application") } - osConfigProvider, err := newRemoteOSConfigProvider(remoteOSConfigProviderConfig{ + osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ clt: clt, tunName: tunName, ipv6Prefix: networkStackConfig.ipv6Prefix.String(), diff --git a/lib/vnet/admin_process_windows.go b/lib/vnet/admin_process_windows.go index 8a46e3f751fea..09afbf8619d48 100644 --- a/lib/vnet/admin_process_windows.go +++ b/lib/vnet/admin_process_windows.go @@ -100,7 +100,7 @@ func runWindowsAdminProcess(ctx context.Context, cfg *windowsAdminProcessConfig) } log.InfoContext(ctx, "Created TUN interface", "tun", tunName) - networkStackConfig, err := newNetworkStackConfig(device, clt) + networkStackConfig, err := newNetworkStackConfig(ctx, device, clt) if err != nil { return trace.Wrap(err, "creating network stack config") } @@ -116,7 +116,7 @@ func runWindowsAdminProcess(ctx context.Context, cfg *windowsAdminProcessConfig) return trace.Wrap(err, "reporting network stack info to client application") } - osConfigProvider, err := newRemoteOSConfigProvider(remoteOSConfigProviderConfig{ + osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ clt: clt, tunName: tunName, ipv6Prefix: networkStackConfig.ipv6Prefix.String(), diff --git a/lib/vnet/app_handler.go b/lib/vnet/app_handler.go index 09322d52bb264..aefa77fed33f1 100644 --- a/lib/vnet/app_handler.go +++ b/lib/vnet/app_handler.go @@ -49,6 +49,10 @@ type tcpAppHandlerConfig struct { appInfo *vnetv1.AppInfo appProvider *appProvider clock clockwork.Clock + // alwaysTrustRootClusterCA can be set in tests so that TLS dials to the + // proxy always trust the root cluster CA rather than the system cert pool, + // even when ALPN conn upgrades are not required. + alwaysTrustRootClusterCA bool } func newTCPAppHandler(cfg *tcpAppHandlerConfig) *tcpAppHandler { @@ -102,9 +106,13 @@ func (h *tcpAppHandler) getOrInitializeLocalProxy(ctx context.Context, localPort InsecureSkipVerify: dialOptions.GetInsecureSkipVerify(), Clock: h.cfg.clock, } - if certPoolPEM := dialOptions.GetRootClusterCaCertPool(); len(certPoolPEM) > 0 { + if dialOptions.GetAlpnConnUpgradeRequired() || h.cfg.alwaysTrustRootClusterCA { + certPoolPEM := dialOptions.GetRootClusterCaCertPool() + if len(certPoolPEM) == 0 { + return nil, trace.BadParameter("ALPN conn upgrade required but no root CA cert pool provided") + } caPool := x509.NewCertPool() - if !caPool.AppendCertsFromPEM(dialOptions.GetRootClusterCaCertPool()) { + if !caPool.AppendCertsFromPEM(certPoolPEM) { return nil, trace.Errorf("failed to parse root cluster CA certs") } localProxyConfig.RootCAs = caPool @@ -157,7 +165,7 @@ func (i *appCertIssuer) IssueCert(ctx context.Context) (tls.Certificate, error) } // localProxyMiddleware wraps around [client.CertChecker] and additionally makes it so that its -// OnNewConnection method calls the same method of [appProvider]. +// OnNewConnection method calls OnNewAppConnection on [appProvider]. type localProxyMiddleware struct { appKey *vnetv1.AppKey certChecker *client.CertChecker @@ -169,7 +177,7 @@ func (m *localProxyMiddleware) OnNewConnection(ctx context.Context, lp *alpnprox if err != nil { return trace.Wrap(err) } - return trace.Wrap(m.appProvider.OnNewConnection(ctx, m.appKey)) + return trace.Wrap(m.appProvider.OnNewAppConnection(ctx, m.appKey)) } func (m *localProxyMiddleware) OnStart(ctx context.Context, lp *alpnproxy.LocalProxy) error { diff --git a/lib/vnet/app_provider.go b/lib/vnet/app_provider.go index e4828d99ff38f..c8ec1e8d3e269 100644 --- a/lib/vnet/app_provider.go +++ b/lib/vnet/app_provider.go @@ -18,11 +18,8 @@ package vnet import ( "context" - "crypto" - "crypto/rsa" "crypto/tls" "crypto/x509" - "io" "github.com/gravitational/trace" @@ -59,64 +56,27 @@ func (p *appProvider) ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppInf return tlsCert, nil } -func (p *appProvider) newAppCertSigner(cert []byte, appKey *vnetv1.AppKey, targetPort uint16) (*rpcAppCertSigner, error) { +func (p *appProvider) newAppCertSigner(cert []byte, appKey *vnetv1.AppKey, targetPort uint16) (*rpcSigner, error) { x509Cert, err := x509.ParseCertificate(cert) if err != nil { return nil, trace.Wrap(err, "parsing x509 certificate") } - return &rpcAppCertSigner{ - clt: p.clt, - pub: x509Cert.PublicKey, - appKey: appKey, - targetPort: targetPort, + pub := x509Cert.PublicKey + return &rpcSigner{ + pub: pub, + sendRequest: func(req *vnetv1.SignRequest) ([]byte, error) { + return p.clt.SignForApp(context.TODO(), &vnetv1.SignForAppRequest{ + AppKey: appKey, + TargetPort: uint32(targetPort), + Sign: req, + }) + }, }, nil } -// rpcAppCertSigner implements [crypto.Signer] for app TLS signatures that are -// issued by the client application over gRPC. -type rpcAppCertSigner struct { - clt *clientApplicationServiceClient - pub crypto.PublicKey - appKey *vnetv1.AppKey - targetPort uint16 -} - -// Public implements [crypto.Signer.Public] and returns the public key -// associated with the signer. -func (s *rpcAppCertSigner) Public() crypto.PublicKey { - return s.pub -} - -// Sign implements [crypto.Signer.Sign] and issues a signature over digest for -// the associated app. -func (s *rpcAppCertSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { - req := &vnetv1.SignForAppRequest{ - AppKey: s.appKey, - TargetPort: uint32(s.targetPort), - Digest: digest, - } - switch opts.HashFunc() { - case 0: - req.Hash = vnetv1.Hash_HASH_NONE - case crypto.SHA256: - req.Hash = vnetv1.Hash_HASH_SHA256 - default: - return nil, trace.BadParameter("unsupported signature hash func %v", opts.HashFunc()) - } - if pssOpts, ok := opts.(*rsa.PSSOptions); ok { - saltLen := int32(pssOpts.SaltLength) - req.PssSaltLength = &saltLen - } - signature, err := s.clt.SignForApp(context.TODO(), req) - if err != nil { - return nil, trace.Wrap(err) - } - return signature, nil -} - -// OnNewConnection reports a new TCP connection to the target app. -func (p *appProvider) OnNewConnection(ctx context.Context, appKey *vnetv1.AppKey) error { - if err := p.clt.OnNewConnection(ctx, appKey); err != nil { +// OnNewAppConnection reports a new TCP connection to the target app. +func (p *appProvider) OnNewAppConnection(ctx context.Context, appKey *vnetv1.AppKey) error { + if err := p.clt.OnNewAppConnection(ctx, appKey); err != nil { return trace.Wrap(err) } return nil diff --git a/lib/vnet/client_application_service.go b/lib/vnet/client_application_service.go index 1f1b17a0e35d8..94f58a48c3baa 100644 --- a/lib/vnet/client_application_service.go +++ b/lib/vnet/client_application_service.go @@ -17,16 +17,24 @@ package vnet import ( + "cmp" "context" "crypto" "crypto/rand" "crypto/rsa" "sync" + "time" + "github.com/google/uuid" "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "golang.org/x/crypto/ssh" "github.com/gravitational/teleport/api" + "github.com/gravitational/teleport/api/client/proto" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/utils" ) // clientApplicationService implements the gRPC @@ -43,8 +51,8 @@ type clientApplicationService struct { // ReportNetworkStackInfo. networkStackInfo chan *vnetv1.NetworkStackInfo - // mu protects appSignerCache - mu sync.Mutex + // appSignerMu protects appSignerCache. + appSignerMu sync.Mutex // appSignerCache caches the crypto.Signer for each certificate issued by // ReissueAppCert so that SignForApp can later use that signer. // @@ -53,20 +61,36 @@ type clientApplicationService struct { // ReissueAppCert, which will overwrite the signer for the app with a new // one. appSignerCache map[appKey]crypto.Signer + + // sshSigners is a cache containing [crypto.Signer]s keyed by SSH session + // ID. This "session ID" is a concept only used here for retrieving a signer + // previously associated with the same session, it is not some Teleport + // session identifier. + sshSigners *utils.FnCache } type clientApplicationServiceConfig struct { - fqdnResolver *fqdnResolver - localOSConfigProvider *LocalOSConfigProvider - clientApplication ClientApplication + fqdnResolver *fqdnResolver + unifiedClusterConfigProvider *UnifiedClusterConfigProvider + clientApplication ClientApplication + homePath string + clock clockwork.Clock } -func newClientApplicationService(cfg *clientApplicationServiceConfig) *clientApplicationService { +func newClientApplicationService(cfg *clientApplicationServiceConfig) (*clientApplicationService, error) { + sshSigners, err := utils.NewFnCache(utils.FnCacheConfig{ + TTL: time.Minute, + Clock: cfg.clock, + }) + if err != nil { + return nil, trace.Wrap(err) + } return &clientApplicationService{ cfg: cfg, networkStackInfo: make(chan *vnetv1.NetworkStackInfo, 1), appSignerCache: make(map[appKey]crypto.Signer), - } + sshSigners: sshSigners, + }, nil } // AuthenticateProcess implements [vnetv1.ClientApplicationServiceServer.AuthenticateProcess]. @@ -132,29 +156,14 @@ func (s *clientApplicationService) ReissueAppCert(ctx context.Context, req *vnet // It uses a cached signer for the requested app, which must have previously // been issued a certificate via [clientApplicationService.ReissueAppCert]. func (s *clientApplicationService) SignForApp(ctx context.Context, req *vnetv1.SignForAppRequest) (*vnetv1.SignForAppResponse, error) { + signReq := req.GetSign() log.DebugContext(ctx, "Got SignForApp request", "app", req.GetAppKey(), - "hash", req.GetHash(), - "is_rsa_pss", req.PssSaltLength != nil, - "pss_salt_len", req.GetPssSaltLength(), - "digest_len", len(req.GetDigest()), + "hash", signReq.GetHash(), + "is_rsa_pss", signReq.PssSaltLength != nil, + "pss_salt_len", signReq.GetPssSaltLength(), + "digest_len", len(signReq.GetDigest()), ) - var hash crypto.Hash - switch req.GetHash() { - case vnetv1.Hash_HASH_NONE: - hash = crypto.Hash(0) - case vnetv1.Hash_HASH_SHA256: - hash = crypto.SHA256 - default: - return nil, trace.BadParameter("unsupported hash %v", req.GetHash()) - } - opts := crypto.SignerOpts(hash) - if req.PssSaltLength != nil { - opts = &rsa.PSSOptions{ - Hash: hash, - SaltLength: int(*req.PssSaltLength), - } - } appKey := req.GetAppKey() if err := checkAppKey(appKey); err != nil { @@ -165,7 +174,7 @@ func (s *clientApplicationService) SignForApp(ctx context.Context, req *vnetv1.S return nil, trace.BadParameter("no signer for app %v", appKey) } - signature, err := signer.Sign(rand.Reader, req.GetDigest(), opts) + signature, err := sign(signer, signReq) if err != nil { return nil, trace.Wrap(err, "signing for app %v", appKey) } @@ -174,26 +183,45 @@ func (s *clientApplicationService) SignForApp(ctx context.Context, req *vnetv1.S }, nil } +func sign(signer crypto.Signer, signReq *vnetv1.SignRequest) ([]byte, error) { + var hash crypto.Hash + switch signReq.GetHash() { + case vnetv1.Hash_HASH_NONE: + hash = crypto.Hash(0) + case vnetv1.Hash_HASH_SHA256: + hash = crypto.SHA256 + default: + return nil, trace.BadParameter("unsupported hash %v", signReq.GetHash()) + } + opts := crypto.SignerOpts(hash) + if signReq.PssSaltLength != nil { + opts = &rsa.PSSOptions{ + Hash: hash, + SaltLength: int(*signReq.PssSaltLength), + } + } + signature, err := signer.Sign(rand.Reader, signReq.GetDigest(), opts) + return signature, trace.Wrap(err) +} + func (s *clientApplicationService) setSignerForApp(appKey *vnetv1.AppKey, targetPort uint16, signer crypto.Signer) { - s.mu.Lock() - defer s.mu.Unlock() + s.appSignerMu.Lock() + defer s.appSignerMu.Unlock() s.appSignerCache[newAppKey(appKey, targetPort)] = signer } func (s *clientApplicationService) getSignerForApp(appKey *vnetv1.AppKey, targetPort uint16) (crypto.Signer, bool) { - s.mu.Lock() - defer s.mu.Unlock() + s.appSignerMu.Lock() + defer s.appSignerMu.Unlock() signer, ok := s.appSignerCache[newAppKey(appKey, targetPort)] return signer, ok } -// OnNewConnection gets called whenever a new connection is about to be +// OnNewAppConnection gets called whenever a new app connection is about to be // established through VNet for observability. -func (s *clientApplicationService) OnNewConnection(ctx context.Context, req *vnetv1.OnNewConnectionRequest) (*vnetv1.OnNewConnectionResponse, error) { - if err := s.cfg.clientApplication.OnNewConnection(ctx, req.GetAppKey()); err != nil { - return nil, trace.Wrap(err) - } - return &vnetv1.OnNewConnectionResponse{}, nil +func (s *clientApplicationService) OnNewAppConnection(ctx context.Context, req *vnetv1.OnNewAppConnectionRequest) (*vnetv1.OnNewAppConnectionResponse, error) { + s.cfg.clientApplication.OnNewAppConnection(ctx, req.GetAppKey()) + return &vnetv1.OnNewAppConnectionResponse{}, nil } // OnInvalidLocalPort gets called before VNet refuses to handle a connection @@ -225,12 +253,180 @@ func newAppKey(protoAppKey *vnetv1.AppKey, port uint16) appKey { // DNS nameserver and the IPv4 CIDR ranges that should be routed to the VNet TUN // interface. func (s *clientApplicationService) GetTargetOSConfiguration(ctx context.Context, _ *vnetv1.GetTargetOSConfigurationRequest) (*vnetv1.GetTargetOSConfigurationResponse, error) { - targetConfig, err := s.cfg.localOSConfigProvider.GetTargetOSConfiguration(ctx) + unifiedClusterConfig, err := s.cfg.unifiedClusterConfigProvider.GetUnifiedClusterConfig(ctx) if err != nil { return nil, trace.Wrap(err, "getting target OS configuration") } return &vnetv1.GetTargetOSConfigurationResponse{ - TargetOsConfiguration: targetConfig, + TargetOsConfiguration: &vnetv1.TargetOSConfiguration{ + DnsZones: unifiedClusterConfig.AllDNSZones(), + Ipv4CidrRanges: unifiedClusterConfig.IPv4CidrRanges, + }, + }, nil +} + +// UserTLSCert returns the user TLS certificate for a specific profile. +func (s *clientApplicationService) UserTLSCert(ctx context.Context, req *vnetv1.UserTLSCertRequest) (*vnetv1.UserTLSCertResponse, error) { + tlsCert, err := s.cfg.clientApplication.UserTLSCert(ctx, req.GetProfile()) + if err != nil { + return nil, trace.Wrap(err, "getting user TLS cert") + } + if len(tlsCert.Certificate) == 0 { + return nil, trace.Errorf("user TLS cert has no certificate") + } + dialOpts, err := s.cfg.clientApplication.GetDialOptions(ctx, req.GetProfile()) + if err != nil { + return nil, trace.Wrap(err, "getting TLS dial options") + } + return &vnetv1.UserTLSCertResponse{ + Cert: tlsCert.Certificate[0], + DialOptions: dialOpts, + }, nil +} + +// SignForUserTLS signs a digest with the user TLS private key. +func (s *clientApplicationService) SignForUserTLS(ctx context.Context, req *vnetv1.SignForUserTLSRequest) (*vnetv1.SignForUserTLSResponse, error) { + tlsCert, err := s.cfg.clientApplication.UserTLSCert(ctx, req.GetProfile()) + if err != nil { + return nil, trace.Wrap(err, "getting user TLS config") + } + signer, ok := tlsCert.PrivateKey.(crypto.Signer) + if !ok { + return nil, trace.Errorf("user TLS private key does not implement crypto.Signer") + } + signature, err := sign(signer, req.GetSign()) + if err != nil { + return nil, trace.Wrap(err, "signing for user TLS certificate") + } + return &vnetv1.SignForUserTLSResponse{ + Signature: signature, + }, nil +} + +// SessionSSHConfig returns user SSH configuration values for an SSH session. +func (s *clientApplicationService) SessionSSHConfig(ctx context.Context, req *vnetv1.SessionSSHConfigRequest) (*vnetv1.SessionSSHConfigResponse, error) { + clusterClient, err := s.cfg.clientApplication.GetCachedClient(ctx, req.GetProfile(), req.GetLeafCluster()) + if err != nil { + return nil, trace.Wrap(err) + } + // If req.LeafCluster is not empty the node is in the leaf cluster, else it + // is in the root cluster. + targetCluster := cmp.Or(req.GetLeafCluster(), req.GetRootCluster()) + target := client.NodeDetails{ + Addr: req.GetAddress(), + Cluster: targetCluster, + } + keyRing, completedMFA, err := clusterClient.SessionSSHKeyRing(ctx, req.GetUser(), target) + if err != nil { + return nil, trace.Wrap(err, "getting KeyRing for SSH session") + } + if !completedMFA && keyRing.Cert == nil && targetCluster == req.GetLeafCluster() { + // It's possible/likely the user doesn't have an SSH cert specifically + // for the leaf cluster. Luckily if MFA was not required, the root + // cluster cert should work. + log.DebugContext(ctx, "Leaf cluster KeyRing had no SSH cert, using root cluster KeyRing") + rootClusterClient, err := s.cfg.clientApplication.GetCachedClient(ctx, req.GetProfile(), "") + if err != nil { + return nil, trace.Wrap(err) + } + // Set the target cluster to the root cluster and disable the MFA check + // so that SessionSSHKeyRing will just return the base root cluster + // keyring. + target.Cluster = req.GetRootCluster() + target.MFACheck = &proto.IsMFARequiredResponse{ + Required: false, + MFARequired: proto.MFARequired_MFA_REQUIRED_NO, + } + keyRing, _, err = rootClusterClient.SessionSSHKeyRing(ctx, req.GetUser(), target) + if err != nil { + return nil, trace.Wrap(err, "getting root cluster KeyRing for SSH session") + } + } + if len(keyRing.Cert) == 0 { + return nil, trace.Errorf("user KeyRing has no SSH cert") + } + sshCert, _, _, _, err := ssh.ParseAuthorizedKey(keyRing.Cert) + if err != nil { + return nil, trace.Wrap(err, "parsing user SSH certificate") + } + var trustedCAs [][]byte + for _, trustedCert := range keyRing.TrustedCerts { + switch trustedCert.ClusterName { + case targetCluster, req.GetRootCluster(): + // Always trust the target cluster and the root cluster in case the + // root proxy will terminate the connection in proxy recording mode. + default: + // Don't trust CAs for other leaf clusters or unknown clusters. + continue + } + for _, authorizedKey := range trustedCert.AuthorizedKeys { + trustedCA, _, _, _, err := ssh.ParseAuthorizedKey(authorizedKey) + if err != nil { + return nil, trace.Wrap(err, "parsing CA public key") + } + trustedCAs = append(trustedCAs, trustedCA.Marshal()) + } + } + if len(trustedCAs) == 0 { + return nil, trace.Errorf("user KeyRing host no trusted SSH CAs for cluster %s", targetCluster) + } + sessionID := s.setSignerForSSHSession(keyRing.SSHPrivateKey) + + // Submit usage event. + s.cfg.clientApplication.OnNewSSHSession(ctx, req.GetProfile(), req.GetRootCluster()) + + return &vnetv1.SessionSSHConfigResponse{ + SessionId: sessionID, + Cert: sshCert.Marshal(), + TrustedCas: trustedCAs, + }, nil +} + +// SignForSSHSession signs a digest with the SSH private key associated with the +// session from a previous call to SessionSSHConfig. +func (s *clientApplicationService) SignForSSHSession(ctx context.Context, req *vnetv1.SignForSSHSessionRequest) (*vnetv1.SignForSSHSessionResponse, error) { + signer, err := s.getSignerForSSHSession(ctx, req.GetSessionId()) + if err != nil { + return nil, trace.Wrap(err) + } + signature, err := sign(signer, req.GetSign()) + if err != nil { + return nil, trace.Wrap(err) + } + return &vnetv1.SignForSSHSessionResponse{ + Signature: signature, + }, nil +} + +func (s *clientApplicationService) setSignerForSSHSession(signer crypto.Signer) string { + sessionID := uuid.NewString() + s.sshSigners.Set(sessionID, signer) + return sessionID +} + +func (s *clientApplicationService) getSignerForSSHSession(ctx context.Context, sessionID string) (crypto.Signer, error) { + signer, err := utils.FnCacheGet(ctx, s.sshSigners, sessionID, func(ctx context.Context) (crypto.Signer, error) { + return nil, trace.NotFound("session key expired") + }) + return signer, trace.Wrap(err) +} + +// ExchangeSSHKeys recevies the VNet service host CA public key and writes it to +// ${TELEPORT_HOME}/vnet_known_hosts so that third-party SSH clients can trust +// it. It then reads or generates ${TELEPORT_HOME}/id_vnet(.pub) which SSH +// clients should be configured to use for connections to VNet SSH. It returns +// id_vnet.pub so that VNet SSH can trust it for incoming connections. +func (s *clientApplicationService) ExchangeSSHKeys(ctx context.Context, req *vnetv1.ExchangeSSHKeysRequest) (*vnetv1.ExchangeSSHKeysResponse, error) { + hostPublicKey, err := ssh.ParsePublicKey(req.GetHostPublicKey()) + if err != nil { + return nil, trace.Wrap(err, "parsing host public key") + } + userPublicKey, err := writeSSHKeys(s.cfg.homePath, hostPublicKey) + if err != nil { + return nil, trace.Wrap(err, "writing SSH keys") + } + return &vnetv1.ExchangeSSHKeysResponse{ + UserPublicKey: userPublicKey.Marshal(), }, nil } diff --git a/lib/vnet/client_application_service_client.go b/lib/vnet/client_application_service_client.go index 06b8d109b8f9b..ffee0471ba349 100644 --- a/lib/vnet/client_application_service_client.go +++ b/lib/vnet/client_application_service_client.go @@ -18,8 +18,12 @@ package vnet import ( "context" + "crypto" + "crypto/rsa" + "io" "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" "google.golang.org/grpc" grpccredentials "google.golang.org/grpc/credentials" @@ -130,13 +134,13 @@ func (c *clientApplicationServiceClient) SignForApp(ctx context.Context, req *vn return resp.GetSignature(), nil } -// OnNewConnection reports a new TCP connection to the target app. -func (c *clientApplicationServiceClient) OnNewConnection(ctx context.Context, appKey *vnetv1.AppKey) error { - _, err := c.clt.OnNewConnection(ctx, &vnetv1.OnNewConnectionRequest{ +// OnNewAppConnection reports a new TCP connection to the target app. +func (c *clientApplicationServiceClient) OnNewAppConnection(ctx context.Context, appKey *vnetv1.AppKey) error { + _, err := c.clt.OnNewAppConnection(ctx, &vnetv1.OnNewAppConnectionRequest{ AppKey: appKey, }) if err != nil { - return trace.Wrap(err, "calling OnNewConnection rpc") + return trace.Wrap(err, "calling OnNewAppConnection rpc") } return nil } @@ -165,3 +169,97 @@ func (c *clientApplicationServiceClient) GetTargetOSConfiguration(ctx context.Co } return resp.GetTargetOsConfiguration(), nil } + +// UserTLSCert returns the user TLS certificate for the given profile. +func (c *clientApplicationServiceClient) UserTLSCert(ctx context.Context, profileName string) (*vnetv1.UserTLSCertResponse, error) { + resp, err := c.clt.UserTLSCert(ctx, &vnetv1.UserTLSCertRequest{ + Profile: profileName, + }) + return resp, trace.Wrap(err, "calling UserTLSCert rpc") +} + +// SignForUserTLS returns a cryptographic signature with the key associated with +// the user TLS key for the requested profile. +func (c *clientApplicationServiceClient) SignForUserTLS(ctx context.Context, req *vnetv1.SignForUserTLSRequest) ([]byte, error) { + resp, err := c.clt.SignForUserTLS(ctx, req) + if err != nil { + return nil, trace.Wrap(err, "calling SignForUserTLS rpc") + } + return resp.GetSignature(), nil +} + +// SessionSSHConfig returns user SSH configuration values for an SSH session. +func (c *clientApplicationServiceClient) SessionSSHConfig(ctx context.Context, target dialTarget, user string) (*vnetv1.SessionSSHConfigResponse, error) { + resp, err := c.clt.SessionSSHConfig(ctx, &vnetv1.SessionSSHConfigRequest{ + Profile: target.profile, + RootCluster: target.rootCluster, + LeafCluster: target.leafCluster, + Address: target.addr, + User: user, + }) + return resp, trace.Wrap(err, "calling SessionSSHConfig rpc") +} + +// SignForSSHSession signs a digest with the SSH private key associated with the +// session from a previous call to SessionSSHConfig. +func (c *clientApplicationServiceClient) SignForSSHSession(ctx context.Context, sessionID string, sign *vnetv1.SignRequest) ([]byte, error) { + resp, err := c.clt.SignForSSHSession(ctx, &vnetv1.SignForSSHSessionRequest{ + SessionId: sessionID, + Sign: sign, + }) + if err != nil { + return nil, trace.Wrap(err, "calling SignForSSHSession rpc") + } + return resp.GetSignature(), nil +} + +// ExchangeSSHKeys sends hostPublicKey to the client application so that it +// can write an OpenSSH-compatible configuration file. It returns the user +// public key that should be trusted for incoming connections from third-party +// SSH clients. +func (c *clientApplicationServiceClient) ExchangeSSHKeys(ctx context.Context, hostPublicKey ssh.PublicKey) (ssh.PublicKey, error) { + resp, err := c.clt.ExchangeSSHKeys(ctx, &vnetv1.ExchangeSSHKeysRequest{ + HostPublicKey: hostPublicKey.Marshal(), + }) + if err != nil { + return nil, trace.Wrap(err, "calling ExchangeSSHKeys rpc") + } + userPublicKey, err := ssh.ParsePublicKey(resp.GetUserPublicKey()) + if err != nil { + return nil, trace.Wrap(err, "parsing trusted user public key") + } + return userPublicKey, nil +} + +// rpcSigner implements [crypto.Signer] for signatures that are issued by the +// client application over gRPC. +type rpcSigner struct { + pub crypto.PublicKey + sendRequest func(signReq *vnetv1.SignRequest) ([]byte, error) +} + +// Public implements [crypto.Signer.Public] and returns the public key +// associated with the signer. +func (s *rpcSigner) Public() crypto.PublicKey { + return s.pub +} + +// Sign implements [crypto.Signer.Sign] and issues a signature over digest. +func (s *rpcSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + req := &vnetv1.SignRequest{ + Digest: digest, + } + switch opts.HashFunc() { + case 0: + req.Hash = vnetv1.Hash_HASH_NONE + case crypto.SHA256: + req.Hash = vnetv1.Hash_HASH_SHA256 + default: + return nil, trace.BadParameter("unsupported signature hash func %v", opts.HashFunc()) + } + if pssOpts, ok := opts.(*rsa.PSSOptions); ok { + saltLen := int32(pssOpts.SaltLength) + req.PssSaltLength = &saltLen + } + return s.sendRequest(req) +} diff --git a/lib/vnet/clusterconfigcache.go b/lib/vnet/clusterconfigcache.go index b0dde70c522bf..70b8403d86ddd 100644 --- a/lib/vnet/clusterconfigcache.go +++ b/lib/vnet/clusterconfigcache.go @@ -32,9 +32,10 @@ import ( ) type ClusterConfig struct { - // DNSZones is the list of DNS zones that are valid for this cluster, this includes ProxyPublicAddr *and* - // any configured custom DNS zones for the cluster. - DNSZones []string + // ProxyPublicAddr is the public address of the proxy, it is always a valid DNS zone for apps. + ProxyPublicAddr string + // CustomDNSZones is the list of custom DNS zones configured for the cluster. + CustomDNSZones []string // IPv4CIDRRange is the CIDR range that IPv4 addresses should be assigned from for apps in this cluster. IPv4CIDRRange string // Expires is the time at which this information should be considered stale and refetched. Stale data may @@ -46,6 +47,10 @@ func (e *ClusterConfig) stale(clock clockwork.Clock) bool { return clock.Now().After(e.Expires) } +func (c *ClusterConfig) appDNSZones() []string { + return append([]string{c.ProxyPublicAddr}, c.CustomDNSZones...) +} + // ClusterConfigCache is a read-through cache for cluster VnetConfigs. Cached entries go stale after 5 // minutes, after which they will be re-fetched on the next read. // @@ -116,7 +121,7 @@ func (c *ClusterConfigCache) getClusterConfigUncached(ctx context.Context, clust } } - dnsZones := []string{proxyPublicAddr} + var customDNSZones []string ipv4CIDRRange := typesvnet.DefaultIPv4CIDRRange vnetConfig, err := clusterClient.CurrentCluster().GetVnetConfig(ctx) @@ -126,14 +131,15 @@ func (c *ClusterConfigCache) getClusterConfigUncached(ctx context.Context, clust return nil, trace.Wrap(err) } else { for _, zone := range vnetConfig.GetSpec().GetCustomDnsZones() { - dnsZones = append(dnsZones, zone.GetSuffix()) + customDNSZones = append(customDNSZones, zone.GetSuffix()) } ipv4CIDRRange = cmp.Or(vnetConfig.GetSpec().GetIpv4CidrRange(), typesvnet.DefaultIPv4CIDRRange) } return &ClusterConfig{ - DNSZones: dnsZones, - IPv4CIDRRange: ipv4CIDRRange, - Expires: c.clock.Now().Add(5 * time.Minute), + ProxyPublicAddr: proxyPublicAddr, + CustomDNSZones: customDNSZones, + IPv4CIDRRange: ipv4CIDRRange, + Expires: c.clock.Now().Add(5 * time.Minute), }, nil } diff --git a/lib/vnet/diag/routeconflict_other.go b/lib/vnet/diag/routeconflict_other.go index ce0acdaca1553..5ca8360dabd60 100644 --- a/lib/vnet/diag/routeconflict_other.go +++ b/lib/vnet/diag/routeconflict_other.go @@ -1,4 +1,4 @@ -//go:build !darwin +//go:build !darwin && !windows // Teleport // Copyright (C) 2025 Gravitational, Inc. diff --git a/lib/vnet/diag/routeconflict_windows.go b/lib/vnet/diag/routeconflict_windows.go new file mode 100644 index 0000000000000..9dc89fc2d775d --- /dev/null +++ b/lib/vnet/diag/routeconflict_windows.go @@ -0,0 +1,79 @@ +// 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 ( + "context" + "net/netip" + "os/exec" + + "github.com/gravitational/trace" + "golang.org/x/sys/windows" + "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" +) + +var ipv4Broadcast = netip.AddrFrom4([4]byte{255, 255, 255, 255}) + +// WindowsRouting provides Windows-specific [Routing] implementation used by [RouteConflictDiag]. +type WindowsRouting struct{} + +// GetRouteDestinations gets routes from the OS and then extracts the only +// information needed from them: the route destination and the index of the +// network interface. It operates solely on IPv4 routes. +func (wr *WindowsRouting) GetRouteDestinations() ([]RouteDest, error) { + rows, err := winipcfg.GetIPForwardTable2(windows.AF_INET) + if err != nil { + return nil, trace.Wrap(err) + } + rds := make([]RouteDest, 0, len(rows)) + for _, row := range rows { + prefix := row.DestinationPrefix.Prefix() + addr := prefix.Addr() + if addr.IsLinkLocalMulticast() || addr == ipv4Broadcast { + // All interfaces seem to get a link local multicast and broadcast + // route assigned which would always appear as a conflict, so skip + // them. + continue + } + if prefix.IsSingleIP() { + rds = append(rds, &RouteDestIP{ + Addr: addr, + ifaceIndex: int(row.InterfaceIndex), + }) + } else { + rds = append(rds, &RouteDestPrefix{ + Prefix: prefix, + ifaceIndex: int(row.InterfaceIndex), + }) + } + } + return rds, nil +} + +func (n *NetInterfaces) interfaceApp(ctx context.Context, ifaceName string) (string, error) { + // Interfaces usually have descriptive names on Windows (the TUN interfaces + // used by VNet and Tailscale do, at least). + return ifaceName, nil +} + +func (c *RouteConflictDiag) commands(ctx context.Context) []*exec.Cmd { + return []*exec.Cmd{ + exec.CommandContext(ctx, "netstat.exe", "-rn"), + exec.CommandContext(ctx, "ipconfig.exe", "/all"), + exec.CommandContext(ctx, "netsh.exe", "namespace", "show", "effectivepolicy"), + } +} diff --git a/lib/vnet/diag/ssh.go b/lib/vnet/diag/ssh.go new file mode 100644 index 0000000000000..c0708aa650b2e --- /dev/null +++ b/lib/vnet/diag/ssh.go @@ -0,0 +1,279 @@ +// 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/constants" + "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 + sshConfigChecker *SSHConfigChecker +} + +// NewSSHDiag returns a new [SSHDiag]. +func NewSSHDiag(cfg *SSHConfig) (*SSHDiag, error) { + sshConfigChecker, err := NewSSHConfigChecker(cfg.ProfilePath) + if err != nil { + return nil, trace.Wrap(err) + } + return &SSHDiag{ + cfg: cfg, + sshConfigChecker: sshConfigChecker, + }, 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) { + userOpenSSHConfigContents, included, err := d.sshConfigChecker.OpenSSHConfigIncludesVNetSSHConfig() + if err != nil { + if trace.IsNotFound(err) { + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.sshConfigChecker.UserOpenSSHConfigPath, + VnetSshConfigPath: d.sshConfigChecker.VNetSSHConfigPath, + }, nil + } + return nil, trace.Wrap(err) + } + if !utf8.Valid(userOpenSSHConfigContents) { + return nil, trace.Errorf("%s is not valid UTF-8", d.sshConfigChecker.UserOpenSSHConfigPath) + } + return &diagv1.SSHConfigurationReport{ + UserOpensshConfigPath: d.sshConfigChecker.UserOpenSSHConfigPath, + VnetSshConfigPath: d.sshConfigChecker.VNetSSHConfigPath, + UserOpensshConfigIncludesVnetSshConfig: included, + UserOpensshConfigExists: true, + UserOpensshConfigContents: string(userOpenSSHConfigContents), + }, nil +} + +// SSHConfigChecker checks the state of the user's SSH configuration. +type SSHConfigChecker struct { + userHome string + UserOpenSSHConfigPath string + VNetSSHConfigPath string + isWindows bool +} + +// NewSSHConfigChecker returns a new SSHConfigChecker. +func NewSSHConfigChecker(profilePath string) (*SSHConfigChecker, 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 := keypaths.VNetSSHConfigPath(profilePath) + return &SSHConfigChecker{ + userHome: userHome, + UserOpenSSHConfigPath: userOpenSSHConfigPath, + VNetSSHConfigPath: vnetSSHConfigPath, + isWindows: runtime.GOOS == constants.WindowsOS, + }, nil +} + +// OpenSSHConfigIncludesVNetSSHConfig returns the current user OpenSSH +// configuration file contents (~/.ssh/config) and a boolean indicating whether +// it already includes VNet's generated OpenSSH-compatible configuration file. +// +// If ~/.ssh/config does not exist it returns a [trace.NotFoundError] +func (c *SSHConfigChecker) OpenSSHConfigIncludesVNetSSHConfig() ([]byte, bool, error) { + userOpenSSHConfigFile, err := os.Open(c.UserOpenSSHConfigPath) + if err != nil { + return nil, false, trace.Wrap(trace.ConvertSystemError(err), "opening %s for reading", c.UserOpenSSHConfigPath) + } + defer userOpenSSHConfigFile.Close() + + userOpenSSHConfigContents, err := io.ReadAll(io.LimitReader(userOpenSSHConfigFile, maxOpenSSHConfigFileSize)) + if err != nil { + return nil, false, trace.Wrap(trace.ConvertSystemError(err), "reading %s", c.UserOpenSSHConfigPath) + } + if len(userOpenSSHConfigContents) == maxOpenSSHConfigFileSize { + return nil, false, trace.Errorf("%s is too large to read (max size %s)", + c.UserOpenSSHConfigPath, humanize.Bytes(maxOpenSSHConfigFileSize)) + } + + included, err := c.openSSHConfigIncludesVNetSSHConfig(bytes.NewReader(userOpenSSHConfigContents)) + if err != nil { + return nil, false, trace.Wrap(err, "checking if the default user OpenSSH config includes VNet's SSH configuration") + } + return userOpenSSHConfigContents, included, nil +} + +func (c *SSHConfigChecker) openSSHConfigIncludesVNetSSHConfig(r io.Reader) (bool, error) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + if c.openSSHConfigLineIncludesPath(scanner.Text(), c.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 (c *SSHConfigChecker) openSSHConfigLineIncludesPath(line, wantPath string) bool { + wantPath = c.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 ( + // pathBuf is a running buffer holding the current argument as parsed up to + // the current point. + pathBuf strings.Builder + // quote holds the opening quote character if one has been found. + quote = byte(0) + ) +loop: + for i := 0; i < len(line); i++ { + b := line[i] + switch { + case b == '\\' && i < len(line)-1 && canBeEscaped(line[i+1]): + // Skip the escape char and write the next char literally. + i++ + pathBuf.WriteByte(line[i]) + case quote == 0 && (b == '"' || b == '\''): + // Start of quote + quote = b + case quote != 0 && b == quote: + // End of quote + quote = 0 + case pathBuf.Len() == 0 && b == '~': + // Support ~ as an alias for the user's home directory. + pathBuf.WriteString(c.userHome) + case quote == 0 && b == '#': + // Found an unquoted comment in the middle of the line, ignore the rest. + break loop + case quote == 0 && isSpace(rune(b)): + // Reached the end of this argument, check if it matches wantPath. + if c.normalizePath(pathBuf.String()) == wantPath { + return true + } + pathBuf.Reset() + default: + // By default just append the current byte to the path. + pathBuf.WriteByte(b) + } + } + if quote != 0 { + // Unmatched quote. + return false + } + // Handle an argument that ends at the end of the line. + return c.normalizePath(pathBuf.String()) == wantPath +} + +func (c *SSHConfigChecker) normalizePath(path string) string { + if c.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..a152f38c1ac90 --- /dev/null +++ b/lib/vnet/diag/ssh_test.go @@ -0,0 +1,250 @@ +// 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" + "strings" + "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" +) + +var sshDiagTestCases = []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, + }, +} + +// 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 sshDiagTestCases { + 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.sshConfigChecker.isWindows = tc.isWindows + diag.sshConfigChecker.userHome = tc.userHome + diag.sshConfigChecker.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) + }) + } +} + +// FuzzOpenSSHConfigIncludesPath fuzzes [SSHConfigChecker.openSSHConfigIncludesVNetSSHConfig] +// to make sure it won't panic on arbitrary input. +func FuzzOpenSSHConfigIncludesPath(f *testing.F) { + // Add all test cases as the base test corpus. + for _, tc := range sshDiagTestCases { + f.Add(tc.isWindows, tc.profilePath, tc.input) + } + f.Fuzz(func(t *testing.T, isWindows bool, profilePath, input string) { + vnetSSHConfigPath := keypaths.VNetSSHConfigPath(profilePath) + sshConfigChecker := &SSHConfigChecker{ + VNetSSHConfigPath: vnetSSHConfigPath, + isWindows: isWindows, + } + // Can't deterministically check the result for fuzzed inputs but it shouldn't panic. + sshConfigChecker.openSSHConfigIncludesVNetSSHConfig(strings.NewReader(input)) + }) +} diff --git a/lib/vnet/fqdn_resolver.go b/lib/vnet/fqdn_resolver.go index 553cf97e1ebbf..cce5c910a02a8 100644 --- a/lib/vnet/fqdn_resolver.go +++ b/lib/vnet/fqdn_resolver.go @@ -146,7 +146,7 @@ func (r *fqdnResolver) clusterClientForAppFQDN(ctx context.Context, profileName, log.ErrorContext(ctx, "Failed to get VNet config, apps in this cluster will not be resolved.", "profile", profileName, "leaf_cluster", leafClusterName, "error", err) continue } - for _, zone := range clusterConfig.DNSZones { + for _, zone := range clusterConfig.appDNSZones() { if isDescendantSubdomain(fqdn, zone) { return clusterClient, nil } @@ -226,11 +226,12 @@ func (r *fqdnResolver) resolveAppInfoForCluster( }, nil } -// VNet SSH handles SSH hostnames matching ".." or -// "...". tryResolveSSH checks if -// fqdn matches that pattern for any logged-in cluster and if so returns a -// match. We never actually query for whether or not a matching SSH node exists, -// we just attempt to dial it when the client connects to the assigned IP. +// VNet SSH handles SSH hostnames matching "..", where +// the may be the name of a root or leaf cluster. +// tryResolveSSH checks if fqdn matches that pattern for any known cluster +// and if so returns a match. We never actually query for whether or not a +// matching SSH node exists, we just attempt to dial it when the client +// connects to the assigned IP. func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, fqdn string) (*vnetv1.ResolveFQDNResponse, error) { for _, profileName := range profileNames { log := log.With("profile", profileName) @@ -240,23 +241,21 @@ func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, continue } rootClusterName := rootClient.ClusterName() - if !isDescendantSubdomain(fqdn, rootClusterName) { - continue - } + log = log.With("root_cluster", rootClusterName) leafClusters, err := r.cfg.leafClusterCache.getLeafClusters(ctx, rootClient) if err != nil { // Good chance we're here because the user is not logged in to the profile. log.ErrorContext(ctx, "Failed to list leaf clusters, SSH nodes in this cluster will not be resolved", "error", err) - return nil, errNoMatch + continue } rootDialOpts, err := r.cfg.clientApplication.GetDialOptions(ctx, profileName) if err != nil { log.ErrorContext(ctx, "Failed to get cluster dial options, SSH nodes in this cluster will not be resolved", "error", err) - return nil, errNoMatch + continue } for _, leafClusterName := range leafClusters { log := log.With("leaf_cluster", leafClusterName) - if !isDescendantSubdomain(fqdn, leafClusterName+"."+rootClusterName) { + if !isDescendantSubdomain(fqdn, leafClusterName) { continue } leafClient, err := r.cfg.clientApplication.GetCachedClient(ctx, profileName, leafClusterName) @@ -275,12 +274,17 @@ func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, MatchedCluster: &vnetv1.MatchedCluster{ WebProxyAddr: rootDialOpts.GetWebProxyAddr(), Ipv4CidrRange: clusterConfig.IPv4CIDRRange, + Profile: profileName, + RootCluster: rootClusterName, + LeafCluster: leafClusterName, }, }, }, nil } - // If it didn't match any leaf cluster assume it matches the root - // cluster. + // Didn't match any leaf, check if it's in the root cluster. + if !isDescendantSubdomain(fqdn, rootClusterName) { + continue + } clusterConfig, err := r.cfg.clusterConfigCache.GetClusterConfig(ctx, rootClient) if err != nil { log.ErrorContext(ctx, "Failed to get VNet config, SSH nodes in this cluster will not be resolved", "error", err) @@ -292,6 +296,8 @@ func (r *fqdnResolver) tryResolveSSH(ctx context.Context, profileNames []string, MatchedCluster: &vnetv1.MatchedCluster{ WebProxyAddr: rootDialOpts.GetWebProxyAddr(), Ipv4CidrRange: clusterConfig.IPv4CIDRRange, + Profile: profileName, + RootCluster: rootClusterName, }, }, }, nil diff --git a/lib/vnet/local_osconfig_provider.go b/lib/vnet/local_osconfig_provider.go deleted file mode 100644 index 0e54b8d522855..0000000000000 --- a/lib/vnet/local_osconfig_provider.go +++ /dev/null @@ -1,119 +0,0 @@ -// 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 vnet - -import ( - "context" - - "github.com/gravitational/trace" - - "github.com/gravitational/teleport/api/utils" - vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" -) - -// LocalOSConfigProvider fetches target OS configuration parameters. -// Its methods get exposed by [clientApplicationService] so that -// [remoteOSConfigProvider] can be implemented by calling these methods from the -// VNet admin process. -type LocalOSConfigProvider struct { - cfg *LocalOSConfigProviderConfig -} - -// LocalOSConfigProviderConfig holds configuration parameters for -// LocalOSConfigProvider. -type LocalOSConfigProviderConfig struct { - clientApplication ClientApplication - clusterConfigCache *ClusterConfigCache - leafClusterCache *leafClusterCache -} - -// NewLocalOSConfigProvider returns a new LocalOSConfigProvider. -func NewLocalOSConfigProvider(cfg *LocalOSConfigProviderConfig) *LocalOSConfigProvider { - return &LocalOSConfigProvider{ - cfg: cfg, - } -} - -// GetTargetOSConfiguration returns the configuration values that should be -// configured in the OS, including DNS zones that should be handled by the VNet -// DNS nameserver and the IPv4 CIDR ranges that should be routed to the VNet TUN -// interface. This is not all of the OS configuration values, only the ones that -// must be communicated from the client application to the admin process. -func (p *LocalOSConfigProvider) GetTargetOSConfiguration(ctx context.Context) (*vnetv1.TargetOSConfiguration, error) { - profiles, err := p.cfg.clientApplication.ListProfiles() - if err != nil { - return nil, trace.Wrap(err, "listing profiles") - } - var targetOSConfig vnetv1.TargetOSConfiguration - for _, profileName := range profiles { - profileTargetConfig := p.targetOSConfigurationForProfile(ctx, profileName) - targetOSConfig.DnsZones = append(targetOSConfig.DnsZones, profileTargetConfig.DnsZones...) - targetOSConfig.Ipv4CidrRanges = append(targetOSConfig.Ipv4CidrRanges, profileTargetConfig.Ipv4CidrRanges...) - } - targetOSConfig.DnsZones = utils.Deduplicate(targetOSConfig.DnsZones) - targetOSConfig.Ipv4CidrRanges = utils.Deduplicate(targetOSConfig.Ipv4CidrRanges) - return &targetOSConfig, nil -} - -// targetOSConfigurationForProfile does not return errors, it is better to -// configure VNet for any working profiles and log errors for failures. -func (p *LocalOSConfigProvider) targetOSConfigurationForProfile(ctx context.Context, profileName string) *vnetv1.TargetOSConfiguration { - targetOSConfig := &vnetv1.TargetOSConfiguration{} - rootClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, "" /*leafClusterName*/) - if err != nil { - log.WarnContext(ctx, - "Failed to get root cluster client from cache, profile may be expired, not configuring VNet for this cluster", - "profile", profileName, "error", err) - return targetOSConfig - } - rootClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, rootClusterClient) - if err != nil { - log.WarnContext(ctx, - "Failed to load VNet configuration, profile may be expired, not configuring VNet for this cluster", - "profile", profileName, "error", err) - return targetOSConfig - } - targetOSConfig.DnsZones = rootClusterConfig.DNSZones - targetOSConfig.Ipv4CidrRanges = []string{rootClusterConfig.IPv4CIDRRange} - - leafClusterNames, err := p.cfg.leafClusterCache.getLeafClusters(ctx, rootClusterClient) - if err != nil { - log.WarnContext(ctx, - "Failed to list leaf clusters, profile may be expired, not configuring VNet for leaf clusters of this cluster", - "profile", profileName, "error", err) - return targetOSConfig - } - for _, leafClusterName := range leafClusterNames { - leafClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, leafClusterName) - if err != nil { - log.WarnContext(ctx, - "Failed to create leaf cluster client, not configuring VNet for this cluster", - "profile", profileName, "leaf_cluster", leafClusterName, "error", err) - return targetOSConfig - } - leafClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, leafClusterClient) - if err != nil { - log.WarnContext(ctx, - "Failed to load VNet configuration, not configuring VNet for this cluster", - "profile", profileName, "leaf_cluster", leafClusterName, "error", err) - return targetOSConfig - } - targetOSConfig.DnsZones = append(targetOSConfig.DnsZones, leafClusterConfig.DNSZones...) - targetOSConfig.Ipv4CidrRanges = append(targetOSConfig.Ipv4CidrRanges, leafClusterConfig.IPv4CIDRRange) - } - return targetOSConfig -} diff --git a/lib/vnet/opensshconfig.go b/lib/vnet/opensshconfig.go new file mode 100644 index 0000000000000..7a3fcd52dc195 --- /dev/null +++ b/lib/vnet/opensshconfig.go @@ -0,0 +1,365 @@ +// 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 vnet + +import ( + "bytes" + "cmp" + "context" + "encoding/pem" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "text/template" + "time" + + renameio "github.com/google/renameio/v2/maybe" // Writes aren't guaranteed to be atomic on Windows. + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/keypaths" + "github.com/gravitational/teleport/lib/cryptosuites" + libutils "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/vnet/diag" +) + +const ( + filePerms os.FileMode = 0o600 + sshConfigurationUpdateInterval = 30 * time.Second +) + +// writeSSHKeys writes hostCAKey to ${TELEPORT_HOME}/vnet_known_hosts so that +// third-party SSH clients can trust it. It then reads or generates +// ${TELEPORT_HOME}/id_vnet(.pub) which SSH clients should be configured to use +// for connections to VNet SSH. It returns id_vnet.pub so that VNet SSH can +// trust it for incoming connections. +func writeSSHKeys(homePath string, hostCAKey ssh.PublicKey) (ssh.PublicKey, error) { + profilePath := fullProfilePath(homePath) + if err := writeKnownHosts(profilePath, hostCAKey); err != nil { + return nil, trace.Wrap(err) + } + userPubKey, err := readUserPubKey(profilePath) + if trace.IsNotFound(err) { + userPubKey, err = generateAndWriteUserKey(profilePath) + } + if err != nil { + return nil, trace.Wrap(err) + } + return userPubKey, nil +} + +func fullProfilePath(homePath string) string { + if homePath == "" { + if homeDir := os.Getenv(types.HomeEnvVar); homeDir != "" { + homePath = filepath.Clean(homeDir) + } + } + return profile.FullProfilePath(homePath) +} + +func writeKnownHosts(profilePath string, hostCAKey ssh.PublicKey) error { + // MarshalAuthorizedKey serializes the key for inclusion in an + // authorized_keys file, we need to add the @cert-authority prefix and the + // wildcard so this CA is trusted for all hosts. The SSH configuration file + // should only load this vnet_known_hosts file for hosts matching + // appropriate subdomains, there is no need to keep that list of domains + // updated in both the SSH config file and the vnet_known_hosts file. + authorizedKey := ssh.MarshalAuthorizedKey(hostCAKey) + authorizedCA := "@cert-authority * " + string(authorizedKey) + p := keypaths.VNetKnownHostsPath(profilePath) + err := renameio.WriteFile(p, []byte(authorizedCA), filePerms) + return trace.Wrap(trace.ConvertSystemError(err), "writing host CA to %s", p) +} + +func readUserPubKey(profilePath string) (ssh.PublicKey, error) { + p := keypaths.VNetClientSSHKeyPubPath(profilePath) + f, err := os.Open(p) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "opening %s for reading", p) + } + defer f.Close() + const maxPubKeyFileSize = 10000 // RSA 4096 pub key files are ~750 bytes, ~10x to be safe. + pubKeyBytes, err := io.ReadAll(io.LimitReader(f, maxPubKeyFileSize)) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "reading user public key from %s", p) + } + userPubKey, _, _, _, err := ssh.ParseAuthorizedKey(pubKeyBytes) + return userPubKey, trace.Wrap(err, "parsing user public key from %s", p) +} + +func generateAndWriteUserKey(profilePath string) (ssh.PublicKey, error) { + userKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err, "generating SSH user key") + } + + privPemBlock, err := ssh.MarshalPrivateKey(userKey, "") + if err != nil { + return nil, trace.Wrap(err, "marshaling SSH user key") + } + privKeyBytes := pem.EncodeToMemory(privPemBlock) + privKeyPath := keypaths.VNetClientSSHKeyPath(profilePath) + if err := renameio.WriteFile(privKeyPath, privKeyBytes, filePerms); err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "writing user private key to %s", privKeyPath) + } + + userPubKey, err := ssh.NewPublicKey(userKey.Public()) + if err != nil { + return nil, trace.Wrap(err) + } + pubKeyPath := keypaths.VNetClientSSHKeyPubPath(profilePath) + if err := renameio.WriteFile(pubKeyPath, ssh.MarshalAuthorizedKey(userPubKey), filePerms); err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err), "writing user public key to %s", pubKeyPath) + } + return userPubKey, nil +} + +// sshConfigurator writes an OpenSSH-compatible config file to +// TELEPORT_HOME/vnet_ssh_config, and keeps it up to date with the list of +// clusters that should match. +type sshConfigurator struct { + cfg sshConfiguratorConfig + profilePath string + clock clockwork.Clock +} + +type sshConfiguratorConfig struct { + clientApplication ClientApplication + leafClusterCache *leafClusterCache + homePath string + clock clockwork.Clock +} + +func newSSHConfigurator(cfg sshConfiguratorConfig) *sshConfigurator { + return &sshConfigurator{ + cfg: cfg, + profilePath: fullProfilePath(cfg.homePath), + clock: cmp.Or(cfg.clock, clockwork.NewRealClock()), + } +} + +func (c *sshConfigurator) runConfigurationLoop(ctx context.Context) error { + if err := c.updateSSHConfiguration(ctx); err != nil { + return trace.Wrap(err, "generating vnet_ssh_config") + } + // Delete the configuration file before exiting, if it is imported by the + // default SSH config file it will just stop taking effect. + defer func() { + if err := deleteSSHConfigFile(c.profilePath); err != nil { + log.WarnContext(ctx, "Failed to delete vnet_ssh_config while shutting down", "error", err) + } + }() + // clock.After is intentionally used in the loop instead of a ticker simply + // for more reliable testing. In the test I use clock.BlockUntilContext(1) + // to block until the loop is stuck waiting on the clock. If I used + // clock.NewTicker instead, the ticker always counts as a waiter, and that + // strategy doesn't work. In go 1.25 we can use testing/synctest instead. + for { + select { + case <-c.clock.After(sshConfigurationUpdateInterval): + if err := c.updateSSHConfiguration(ctx); err != nil { + return trace.Wrap(err, "updating vnet_ssh_config") + } + case <-ctx.Done(): + return trace.Wrap(ctx.Err(), "context canceled, shutting down vnet_ssh_config update loop") + } + } +} + +func (c *sshConfigurator) updateSSHConfiguration(ctx context.Context) error { + profileNames, err := c.cfg.clientApplication.ListProfiles() + if err != nil { + return trace.Wrap(err, "listing profiles") + } + // Build a set of unique cluster names for all active clusters. + clusterNames := make(map[string]struct{}) + for _, profileName := range profileNames { + rootClient, err := c.cfg.clientApplication.GetCachedClient(ctx, profileName, "" /*leafClusterName*/) + if err != nil { + log.WarnContext(ctx, + "Failed to get root cluster client from cache, profile may be expired, not configuring VNet SSH for this cluster", + "profile", profileName, "error", err) + continue + } + clusterNames[rootClient.RootClusterName()] = struct{}{} + leafClusters, err := c.cfg.leafClusterCache.getLeafClusters(ctx, rootClient) + if err != nil { + log.WarnContext(ctx, + "Failed to list leaf clusters, not configuring VNet SSH for leaf clusters of this cluster", + "root_cluster", rootClient.ClusterName(), "error", err) + continue + } + for _, leafCluster := range leafClusters { + clusterNames[leafCluster] = struct{}{} + } + } + return trace.Wrap(writeSSHConfigFile(c.profilePath, clusterNames)) +} + +func deleteSSHConfigFile(profilePath string) error { + p := keypaths.VNetSSHConfigPath(profilePath) + if err := os.Remove(p); err != nil { + err = trace.ConvertSystemError(err) + if trace.IsNotFound(err) { + return nil + } + return trace.Wrap(err, "deleting %s", p) + } + return nil +} + +func writeSSHConfigFile(profilePath string, clusterNames map[string]struct{}) error { + var b bytes.Buffer + b.WriteString(generatedFileHeader) + if len(clusterNames) == 0 { + // Avoid writing the Host block if there are no clusters to match. + b.WriteString("# VNet currently detects no logged-in clusters, log in to start using VNet\n") + } else { + hosts := strings.Join(hostMatchers(clusterNames), " ") + if err := configFileTemplate.Execute(&b, configFileTemplateInput{ + Hosts: hosts, + PrivateKeyPath: strconv.Quote(keypaths.VNetClientSSHKeyPath(profilePath)), + KnownHostsPath: strconv.Quote(keypaths.VNetKnownHostsPath(profilePath)), + }); err != nil { + return trace.Wrap(err, "generating SSH config file") + } + } + p := keypaths.VNetSSHConfigPath(profilePath) + err := renameio.WriteFile(p, b.Bytes(), filePerms) + return trace.Wrap(trace.ConvertSystemError(err), "writing SSH config file to %s", p) +} + +// hostMatchers returns a sorted list of host matchers for a given set of +// cluster names. +func hostMatchers(clusterNames map[string]struct{}) []string { + sortedClusterNames := slices.Sorted(maps.Keys(clusterNames)) + matchers := make([]string, 0, len(sortedClusterNames)) + for _, clusterName := range sortedClusterNames { + matchers = append(matchers, hostMatcher(clusterName)) + } + return matchers +} + +func hostMatcher(clusterName string) string { + return "*." + strings.Trim(clusterName, ".") +} + +const generatedFileHeader = `# --------------------------------------------------------------------- +# THIS FILE IS AUTOMATICALLY GENERATED BY TELEPORT VNET. DO NOT EDIT. +# Your changes will be overwritten the next time the file is generated. +# --------------------------------------------------------------------- + +` + +var configFileTemplate = template.Must(template.New("vnet_ssh_config"). + Parse(`Host {{ .Hosts }} + IdentityFile {{ .PrivateKeyPath }} + GlobalKnownHostsFile {{ .KnownHostsPath }} + UserKnownHostsFile /dev/null + StrictHostKeyChecking yes + IdentitiesOnly yes +`)) + +type configFileTemplateInput struct { + Hosts string + PrivateKeyPath string + KnownHostsPath string +} + +type autoConfigureOpenSSHOptions struct { + overrideUserSSHConfigPath string +} +type autoConfigureOpenSSHOption func(*autoConfigureOpenSSHOptions) + +func withUserSSHConfigPathOverride(path string) autoConfigureOpenSSHOption { + return func(opts *autoConfigureOpenSSHOptions) { + opts.overrideUserSSHConfigPath = path + } +} + +// AutoConfigureOpenSSH adds an Include directive to the default user OpenSSH +// config file (~/.ssh/config) to include the vnet_ssh_config file found under +// profilePath. +func AutoConfigureOpenSSH(ctx context.Context, profilePath string, opts ...autoConfigureOpenSSHOption) (err error) { + var options autoConfigureOpenSSHOptions + for _, opt := range opts { + opt(&options) + } + + sshConfigChecker, err := diag.NewSSHConfigChecker(profilePath) + if err != nil { + return trace.Wrap(err) + } + + if options.overrideUserSSHConfigPath != "" { + sshConfigChecker.UserOpenSSHConfigPath = options.overrideUserSSHConfigPath + } + + // Create ~/.ssh if it does not exist yet. + err = trace.ConvertSystemError(os.Mkdir( + filepath.Dir(sshConfigChecker.UserOpenSSHConfigPath), os.FileMode(0o700))) + switch { + case trace.IsAlreadyExists(err): + // This is fine/expected. + case err != nil: + return trace.Wrap(err, "creating directory for %s", sshConfigChecker.UserOpenSSHConfigPath) + } + + // There should not be much lock contention on this file and it's okay if + // this fails so just try once to grab the lock. + unlock, err := libutils.FSTryWriteLock(sshConfigChecker.UserOpenSSHConfigPath) + if err != nil { + return trace.Wrap(err, "getting write lock for %s", sshConfigChecker.UserOpenSSHConfigPath) + } + defer func() { + unlockErr := unlock() + err = trace.NewAggregate(err, trace.Wrap(unlockErr, "unlocking %s", sshConfigChecker.UserOpenSSHConfigPath)) + }() + + currentContents, alreadyIncluded, err := sshConfigChecker.OpenSSHConfigIncludesVNetSSHConfig() + switch { + case trace.IsNotFound(err): + // This is fine, the file will be created with a single include. + case err != nil: + return trace.Wrap(err) + case alreadyIncluded: + return trace.AlreadyExists("%s is already included in %s", + sshConfigChecker.VNetSSHConfigPath, sshConfigChecker.UserOpenSSHConfigPath) + } + + // Add the include at the top of the file for 2 reasons: + // - options set first take precedence over options set later in the file + // - if the include line is added after an existing Host block it will only + // be included if the host block matches + var newContents bytes.Buffer + fmt.Fprintf(&newContents, `# Include Teleport VNet generated configuration +Include "%s" + +`, sshConfigChecker.VNetSSHConfigPath) + newContents.Write(currentContents) + + err = renameio.WriteFile(sshConfigChecker.UserOpenSSHConfigPath, newContents.Bytes(), filePerms) + return trace.Wrap(trace.ConvertSystemError(err), "writing to %s", sshConfigChecker.UserOpenSSHConfigPath) +} diff --git a/lib/vnet/opensshconfig_test.go b/lib/vnet/opensshconfig_test.go new file mode 100644 index 0000000000000..7557743c796e2 --- /dev/null +++ b/lib/vnet/opensshconfig_test.go @@ -0,0 +1,217 @@ +// 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 vnet + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/utils/keypaths" +) + +func TestSSHConfigurator(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + homePath := t.TempDir() + + // This test gives a fake clock only to the SSH configurator and a real + // clock to everything else, so that fakeClock.BlockUntilContext will + // reliably only capture the SSH configuration loop and nothing else. + fakeClock := clockwork.NewFakeClockAt(time.Now()) + realClock := clockwork.NewRealClock() + + fakeClientApp := newFakeClientApp(ctx, t, &fakeClientAppConfig{ + clusters: map[string]testClusterSpec{ + "cluster1": { + leafClusters: map[string]testClusterSpec{ + "leaf1": {}, + }, + }, + "cluster2": {}, + }, + clock: realClock, + }) + leafClusterCache, err := newLeafClusterCache(realClock) + require.NoError(t, err) + + c := newSSHConfigurator(sshConfiguratorConfig{ + clientApplication: fakeClientApp, + leafClusterCache: leafClusterCache, + homePath: homePath, + clock: fakeClock, + }) + errC := make(chan error) + go func() { + errC <- c.runConfigurationLoop(ctx) + }() + + // Intentionally not using the template defined in the production code to + // test that it actually produces output that looks like this. + expectedConfigFile := func(expectedHosts string) string { + if expectedHosts == "" { + return generatedFileHeader + "# VNet currently detects no logged-in clusters, log in to start using VNet\n" + } + return generatedFileHeader + fmt.Sprintf(`Host %s + IdentityFile "%s/id_vnet" + GlobalKnownHostsFile "%s/vnet_known_hosts" + UserKnownHostsFile /dev/null + StrictHostKeyChecking yes + IdentitiesOnly yes +`, + expectedHosts, + homePath, homePath) + } + + assertConfigFile := func(expectedHosts string) { + t.Helper() + expected := expectedConfigFile(expectedHosts) + contents, err := os.ReadFile(keypaths.VNetSSHConfigPath(homePath)) + require.NoError(t, err) + require.Equal(t, expected, string(contents)) + } + + // Wait until the configurator has had a chance to write the initial config + // file and then get blocked in the loop. + fakeClock.BlockUntilContext(ctx, 1) + // Assert the config file contains both root clusters reported by + // fakeClientApp. + assertConfigFile("*.cluster1 *.cluster2 *.leaf1") + + // To reliably advance the clock and allow runConfigurationLoop to update + // the config the test waits until the loop is blocked on the clock, then + // advances the clock, then waits until the loop is blocked again. + advance := func() { + fakeClock.BlockUntilContext(ctx, 1) + fakeClock.Advance(sshConfigurationUpdateInterval) + fakeClock.BlockUntilContext(ctx, 1) + } + + // Add a new root and leaf cluster, allow the configuration loop to run, + // and then assert that the new clusters are in the config file. + fakeClientApp.cfg.clusters["cluster3"] = testClusterSpec{ + leafClusters: map[string]testClusterSpec{ + "leaf2": {}, + }, + } + advance() + assertConfigFile("*.cluster1 *.cluster2 *.cluster3 *.leaf1 *.leaf2") + + // Delete all clusters as if the user logged out, allow the configuration + // loop to run, and then assert that the config file is well-formed. + fakeClientApp.cfg.clusters = nil + advance() + assertConfigFile("") + + // Kill the configurator, wait for it to return, and assert that the config + // file was deleted. + cancel() + require.ErrorIs(t, <-errC, context.Canceled) + _, err = os.Stat(keypaths.VNetSSHConfigPath(homePath)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestAutoConfigureOpenSSH(t *testing.T) { + d := t.TempDir() + profilePath := filepath.Join(d, ".tsh") + vnetSSHConfigPath := keypaths.VNetSSHConfigPath(profilePath) + userOpenSSHConfigPath := filepath.Join(d, ".ssh", "config") + expectedInclude := fmt.Sprintf(`# Include Teleport VNet generated configuration +Include "%s" + +`, vnetSSHConfigPath) + for _, tc := range []struct { + desc string + userOpenSSHConfigExists bool + userOpenSSHConfigContents string + expectAlreadyIncludedError bool + expectUserOpenSSHConfigContents string + }{ + { + // When the user OpenSSH config file doesn't exist, it should be + // created with the include. + desc: "no file", + expectUserOpenSSHConfigContents: expectedInclude, + }, + { + // When the user OpenSSH config file already exists but it's empty, + // the include should be added. + desc: "empty file", + userOpenSSHConfigExists: true, + expectUserOpenSSHConfigContents: expectedInclude, + }, + { + // When the user OpenSSH config file already exists with some + // content, the include should be added at the top. + desc: "not empty", + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: "something\nsomethingelse\n", + expectUserOpenSSHConfigContents: expectedInclude + "something\nsomethingelse\n", + }, + { + // When the user OpenSSH config file already includes VNet's config + // file, it should return an AlreadyExists error and the file + // should not be modified. + desc: "already included", + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: expectedInclude, + expectAlreadyIncludedError: true, + expectUserOpenSSHConfigContents: expectedInclude, + }, + { + // When the user OpenSSH config file already includes VNet's config + // file along with existing content, it should return an + // AlreadyExists error and the file should not be modified. + desc: "already included with extra content", + userOpenSSHConfigExists: true, + userOpenSSHConfigContents: "something\n" + expectedInclude + "somethingelse", + expectAlreadyIncludedError: true, + expectUserOpenSSHConfigContents: "something\n" + expectedInclude + "somethingelse", + }, + } { + t.Run(tc.desc, func(t *testing.T) { + if tc.userOpenSSHConfigExists { + // Write the existing user OpenSSH config file if it's supposed + // to exist for this test case. + require.NoError(t, os.WriteFile(userOpenSSHConfigPath, + []byte(tc.userOpenSSHConfigContents), filePerms)) + } + + err := AutoConfigureOpenSSH(t.Context(), profilePath, withUserSSHConfigPathOverride(userOpenSSHConfigPath)) + + if tc.expectAlreadyIncludedError { + assert.ErrorIs(t, err, trace.AlreadyExists("%s is already included in %s", + vnetSSHConfigPath, userOpenSSHConfigPath)) + } else { + assert.NoError(t, err) + } + + contents, err := os.ReadFile(userOpenSSHConfigPath) + require.NoError(t, err) + assert.Equal(t, tc.expectUserOpenSSHConfigContents, string(contents)) + }) + } +} diff --git a/lib/vnet/osconfig.go b/lib/vnet/osconfig.go index 435296c429996..307b3363f0170 100644 --- a/lib/vnet/osconfig.go +++ b/lib/vnet/osconfig.go @@ -18,6 +18,7 @@ package vnet import ( "context" + "net" "net/netip" "os/exec" "strings" @@ -29,6 +30,7 @@ import ( type osConfig struct { tunName string tunIPv4 string + tunIPv4Net *net.IPNet tunIPv6 string cidrRanges []string dnsAddrs []string @@ -44,11 +46,11 @@ func configureOS(ctx context.Context, osConfig *osConfig, osConfigState *osConfi } type osConfigurator struct { - remoteOSConfigProvider *remoteOSConfigProvider + remoteOSConfigProvider *osConfigProvider osConfigState osConfigState } -func newOSConfigurator(remoteOSConfigProvider *remoteOSConfigProvider) *osConfigurator { +func newOSConfigurator(remoteOSConfigProvider *osConfigProvider) *osConfigurator { return &osConfigurator{ remoteOSConfigProvider: remoteOSConfigProvider, } diff --git a/lib/vnet/remote_osconfig_provider.go b/lib/vnet/osconfig_provider.go similarity index 73% rename from lib/vnet/remote_osconfig_provider.go rename to lib/vnet/osconfig_provider.go index a7f32a1c88049..64975a762d121 100644 --- a/lib/vnet/remote_osconfig_provider.go +++ b/lib/vnet/osconfig_provider.go @@ -26,17 +26,18 @@ import ( vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" ) -// remoteOSConfigProvider fetches a target OS configuration based on cluster +// osConfigProvider fetches a target OS configuration based on cluster // configuration fetched via the client application process available over gRPC. -type remoteOSConfigProvider struct { - cfg remoteOSConfigProviderConfig - dnsAddrs []string - tunIPv6 string - tunIPv4 string +type osConfigProvider struct { + cfg osConfigProviderConfig + dnsAddrs []string + tunIPv6 string + tunIPv4 string + tunIPv4Net *net.IPNet } -// remoteOSConfigProviderConfig holds configuration parameters for a remoteOSConfigProvider. -type remoteOSConfigProviderConfig struct { +// osConfigProviderConfig holds configuration parameters for an osConfigProvider. +type osConfigProviderConfig struct { clt targetOSConfigGetter tunName string ipv6Prefix string @@ -48,19 +49,19 @@ type targetOSConfigGetter interface { GetTargetOSConfiguration(context.Context) (*vnetv1.TargetOSConfiguration, error) } -func newRemoteOSConfigProvider(cfg remoteOSConfigProviderConfig) (*remoteOSConfigProvider, error) { +func newOSConfigProvider(cfg osConfigProviderConfig) (*osConfigProvider, error) { tunIPv6, err := tunIPv6ForPrefix(cfg.ipv6Prefix) if err != nil { return nil, trace.Wrap(err) } - return &remoteOSConfigProvider{ + return &osConfigProvider{ cfg: cfg, dnsAddrs: []string{cfg.dnsIPv6}, tunIPv6: tunIPv6, }, nil } -func (p *remoteOSConfigProvider) targetOSConfig(ctx context.Context) (*osConfig, error) { +func (p *osConfigProvider) targetOSConfig(ctx context.Context) (*osConfig, error) { targetOSConfig, err := p.cfg.clt.GetTargetOSConfiguration(ctx) if err != nil { return nil, trace.Wrap(err, "getting target OS configuration from client application") @@ -78,18 +79,19 @@ func (p *remoteOSConfigProvider) targetOSConfig(ctx context.Context) (*osConfig, tunName: p.cfg.tunName, tunIPv6: p.tunIPv6, tunIPv4: p.tunIPv4, + tunIPv4Net: p.tunIPv4Net, dnsAddrs: p.dnsAddrs, dnsZones: targetOSConfig.GetDnsZones(), cidrRanges: targetOSConfig.GetIpv4CidrRanges(), }, nil } -func (p *remoteOSConfigProvider) setV4IPsFromFirstCIDR(cidrRange string) error { +func (p *osConfigProvider) setV4IPsFromFirstCIDR(cidrRange string) error { if p.tunIPv4 != "" { // Only set these once. return nil } - tunIPv4, dnsIPv4, err := ipsForCIDR(cidrRange) + tunIPv4, tunIPv4Net, dnsIPv4, err := ipsForCIDR(cidrRange) if err != nil { return trace.Wrap(err, "setting TUN IPv4 address for range %s", cidrRange) } @@ -97,25 +99,26 @@ func (p *remoteOSConfigProvider) setV4IPsFromFirstCIDR(cidrRange string) error { return trace.Wrap(err, "adding IPv4 DNS server at %s", dnsIPv4.String()) } p.tunIPv4 = tunIPv4.String() + p.tunIPv4Net = tunIPv4Net p.dnsAddrs = append(p.dnsAddrs, dnsIPv4.String()) return nil } // ipsForCIDR returns the V4 IPs to assign to the interface and use for DNS in // cidrRange. -func ipsForCIDR(cidrRange string) (tunIP net.IP, dnsIP net.IP, err error) { - _, ipnet, err := net.ParseCIDR(cidrRange) +func ipsForCIDR(cidrRange string) (tunIP net.IP, tunIPNet *net.IPNet, dnsIP net.IP, err error) { + _, tunIPNet, err = net.ParseCIDR(cidrRange) if err != nil { - return nil, nil, trace.Wrap(err, "parsing CIDR %q", cidrRange) + return nil, nil, nil, trace.Wrap(err, "parsing CIDR %q", cidrRange) } - // ipnet.IP is the network address, ending in 0s, like 100.64.0.0 + // tunIPNet.IP is the network address, ending in 0s, like 100.64.0.0 // Add 1 to assign the TUN address, like 100.64.0.1 - tunIP = ipnet.IP + tunIP = slices.Clone(tunIPNet.IP) tunIP[len(tunIP)-1]++ // Add 1 again to assign the DNS address, like 100.64.0.2 dnsIP = slices.Clone(tunIP) dnsIP[len(dnsIP)-1]++ - return tunIP, dnsIP, nil + return tunIP, tunIPNet, dnsIP, nil } diff --git a/lib/vnet/remote_osconfig_provider_test.go b/lib/vnet/osconfig_provider_test.go similarity index 88% rename from lib/vnet/remote_osconfig_provider_test.go rename to lib/vnet/osconfig_provider_test.go index a5a5699992229..e368524ee465e 100644 --- a/lib/vnet/remote_osconfig_provider_test.go +++ b/lib/vnet/osconfig_provider_test.go @@ -26,7 +26,7 @@ import ( vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" ) -func TestRemoteOSConfigProvider(t *testing.T) { +func TestOSConfigProvider(t *testing.T) { ctx := context.Background() for _, tc := range []struct { desc string @@ -65,8 +65,9 @@ func TestRemoteOSConfigProvider(t *testing.T) { expectTargetOSConfig: &osConfig{ tunName: "testtun1", // Should be the first non-broadcast address in the CIDR range. - tunIPv4: "192.168.1.1", - tunIPv6: "fd01:2345:6789::1", + tunIPv4: "192.168.1.1", + tunIPv4Net: &net.IPNet{IP: []byte{192, 168, 1, 0}, Mask: []byte{255, 255, 255, 0}}, + tunIPv6: "fd01:2345:6789::1", // Should include the second non-broadcast address in the CIDR range. dnsAddrs: []string{"fd01:2345:6789::2", "192.168.1.2"}, dnsZones: []string{"test.example.com"}, @@ -79,15 +80,16 @@ func TestRemoteOSConfigProvider(t *testing.T) { ipv6Prefix: "fd01:2345:6789::", dnsIPv6: "fd01:2345:6789::2", dnsZones: []string{"test.example.com"}, - ipv4CIDRRanges: []string{"10.64.0.0/16", "192.168.1.0/24"}, + ipv4CIDRRanges: []string{"10.64.0.0/10", "192.168.1.0/24"}, expectTargetOSConfig: &osConfig{ tunName: "testtun1", // Should be chosen from the first CIDR range. tunIPv4: "10.64.0.1", + tunIPv4Net: &net.IPNet{IP: []byte{10, 64, 0, 0}, Mask: []byte{255, 192, 0, 0}}, tunIPv6: "fd01:2345:6789::1", dnsAddrs: []string{"fd01:2345:6789::2", "10.64.0.2"}, dnsZones: []string{"test.example.com"}, - cidrRanges: []string{"10.64.0.0/16", "192.168.1.0/24"}, + cidrRanges: []string{"10.64.0.0/10", "192.168.1.0/24"}, }, }, } { @@ -101,7 +103,7 @@ func TestRemoteOSConfigProvider(t *testing.T) { } // Keep track of new DNS addresses the osConfigProvider tried to add. var addedDNSAddrs []string - remoteOSConfigProvider, err := newRemoteOSConfigProvider(remoteOSConfigProviderConfig{ + osConfigProvider, err := newOSConfigProvider(osConfigProviderConfig{ clt: targetOSConfigGetter, tunName: tc.tunName, ipv6Prefix: tc.ipv6Prefix, @@ -113,7 +115,7 @@ func TestRemoteOSConfigProvider(t *testing.T) { }) require.NoError(t, err) - targetOSConfig, err := remoteOSConfigProvider.targetOSConfig(ctx) + targetOSConfig, err := osConfigProvider.targetOSConfig(ctx) if tc.expectErr != nil { require.ErrorIs(t, err, tc.expectErr) return diff --git a/lib/vnet/osconfig_windows.go b/lib/vnet/osconfig_windows.go index 6ce2df16eb698..b480d2d7a5a67 100644 --- a/lib/vnet/osconfig_windows.go +++ b/lib/vnet/osconfig_windows.go @@ -68,8 +68,9 @@ func platformConfigureOS(ctx context.Context, cfg *osConfig, state *platformOSCo if !state.configuredV4Address { log.InfoContext(ctx, "Setting IPv4 address for the TUN device", "device", cfg.tunName, "address", cfg.tunIPv4) + netMask := maskForIPNet(cfg.tunIPv4Net) if err := runCommand(ctx, - "netsh", "interface", "ip", "set", "address", cfg.tunName, "static", cfg.tunIPv4, + "netsh", "interface", "ip", "set", "address", cfg.tunName, "static", cfg.tunIPv4, netMask, ); err != nil { return trace.Wrap(err) } @@ -126,7 +127,11 @@ func addrMaskForCIDR(cidr string) (string, string, error) { if err != nil { return "", "", trace.Wrap(err, "parsing CIDR range %s", cidr) } - return ipNet.IP.String(), net.IP(ipNet.Mask).String(), nil + return ipNet.IP.String(), maskForIPNet(ipNet), nil +} + +func maskForIPNet(ipNet *net.IPNet) string { + return net.IP(ipNet.Mask).String() } const ( diff --git a/lib/vnet/ssh_agent.go b/lib/vnet/ssh_agent.go new file mode 100644 index 0000000000000..1db229c1e82fa --- /dev/null +++ b/lib/vnet/ssh_agent.go @@ -0,0 +1,162 @@ +// 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 vnet + +import ( + "context" + "crypto/rand" + "sync" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + + "github.com/gravitational/teleport/api/utils/sshutils" +) + +// sshAgent implements [agent.ExtendedAgent]. The sole purpose is to forward +// the user's Teleport SSH key to the proxy in case the cluster is in proxy +// recording mode. In this case there will be an SSH connection between VNet +// and the root cluster proxy terminated with the SSH key in the +// [ssh.ClientConfig], and then the key forwarded via this agent will be used +// to terminate the final SSH connection to the target node. +type sshAgent struct { + mu sync.Mutex + signer ssh.Signer +} + +func newSSHAgent() *sshAgent { + return &sshAgent{} +} + +// setSessionKey must be called at most once, before the agent will be used. +// It's not possible to initialize sshAgent with the SSH signer because the +// agent must be passed to [proxy.Client.DialHost] before the session SSH +// signer has been created. +func (a *sshAgent) setSessionKey(signer ssh.Signer) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.signer != nil { + return trace.Errorf("sshAgent.setSessionKey must be called at most once (this is a bug)") + } + a.signer = signer + return nil +} + +// List implements [agent.ExtendedAgent.List], it returns a single key if it +// has been set by setSessionKey. +func (a *sshAgent) List() ([]*agent.Key, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.signer == nil { + return nil, nil + } + pub := a.signer.PublicKey() + return []*agent.Key{{ + Format: pub.Type(), + Blob: pub.Marshal(), + }}, nil +} + +// List implements [agent.ExtendedAgent.Signers], it returns a single key if it +// has been set by setSessionKey. +func (a *sshAgent) Signers() ([]ssh.Signer, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.signer == nil { + return nil, nil + } + return []ssh.Signer{a.signer}, nil +} + +// SignWithFlags implements [agent.ExtendedAgent.Sign], it returns an SSH +// signature with a.signer if it has been set and matches the requested key. +func (a *sshAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return a.SignWithFlags(key, data, 0) +} + +// SignWithFlags implements [agent.ExtendedAgent.SignWithFlags], it returns an +// SSH signature with a.signer if it has been set and matches the requested +// key. +func (a *sshAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.signer == nil { + return nil, trace.Errorf("VNet SSH agent has no signer") + } + if !sshutils.KeysEqual(a.signer.PublicKey(), key) { + return nil, trace.BadParameter("requested key does not equal VNet SSH agent key") + } + var algo string + switch flags { + case 0: + case agent.SignatureFlagRsaSha256: + algo = ssh.KeyAlgoRSASHA256 + case agent.SignatureFlagRsaSha512: + algo = ssh.KeyAlgoRSASHA512 + default: + return nil, trace.Errorf("unsupported signature flag %v", flags) + } + log.DebugContext(context.Background(), "VNet SSH agent signature requested", + "key_type", a.signer.PublicKey().Type(), "algo", algo) + if algo == "" { + sig, err := a.signer.Sign(rand.Reader, data) + return sig, trace.Wrap(err) + } + algorithmSigner, ok := a.signer.(ssh.AlgorithmSigner) + if !ok { + return nil, trace.Errorf("VNet SSH agent signer does not implement ssh.AlgorithmSigner") + } + sig, err := algorithmSigner.SignWithAlgorithm(rand.Reader, data, algo) + return sig, trace.Wrap(err, "signing with VNet SSH agent signer") +} + +// Add implements [agent.ExtendedAgent.Add]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Add(key agent.AddedKey) error { + return trace.NotImplemented("sshAgent.Add is not implemented") +} + +// Remove implements [agent.ExtendedAgent.Remove]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Remove(key ssh.PublicKey) error { + return trace.NotImplemented("sshAgent.Remove is not implemented") +} + +// RemoveAll implements [agent.ExtendedAgent.RemoveAll]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) RemoveAll() error { + return trace.NotImplemented("sshAgent.RemoveAll is not implemented") +} + +// Lock implements [agent.ExtendedAgent.Lock]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Lock(passphrase []byte) error { + return trace.NotImplemented("sshAgent.Lock is not implemented") +} + +// Unlock implements [agent.ExtendedAgent.Unlock]. It is irrelevant for this +// implementation and always returns an error, it is not called. +func (a *sshAgent) Unlock(passphrase []byte) error { + return trace.NotImplemented("sshAgent.Unlock is not implemented") +} + +// Extension implements [agent.ExtendedAgent.Extension]. It is irrelevant for +// this implementation and always returns an error, it is not called. +func (a *sshAgent) Extension(extensionType string, contents []byte) ([]byte, error) { + return nil, trace.NotImplemented("sshAgent.Extension is not implemented") +} diff --git a/lib/vnet/ssh_handler.go b/lib/vnet/ssh_handler.go new file mode 100644 index 0000000000000..89efeb8d35652 --- /dev/null +++ b/lib/vnet/ssh_handler.go @@ -0,0 +1,222 @@ +// 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 vnet + +import ( + "context" + "crypto/rand" + "fmt" + "net" + "strings" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + tracessh "github.com/gravitational/teleport/api/observability/tracing/ssh" + "github.com/gravitational/teleport/api/utils/sshutils" + "github.com/gravitational/teleport/lib/cryptosuites" + "github.com/gravitational/teleport/lib/utils" +) + +// sshHandler handles incoming VNet SSH connections. +type sshHandler struct { + cfg sshHandlerConfig +} + +type sshHandlerConfig struct { + sshProvider *sshProvider + target dialTarget +} + +func newSSHHandler(cfg sshHandlerConfig) *sshHandler { + return &sshHandler{ + cfg: cfg, + } +} + +// handleTCPConnector handles an incoming TCP connection from VNet and proxies +// the connection to a target SSH node. +func (h *sshHandler) handleTCPConnector(ctx context.Context, localPort uint16, connector func() (net.Conn, error)) error { + if localPort != 22 { + return trace.BadParameter("SSH is only handled on port 22") + } + agent := newSSHAgent() + targetConn, err := h.cfg.sshProvider.dial(ctx, h.cfg.target, agent) + if err != nil { + return trace.Wrap(err) + } + defer targetConn.Close() + return trace.Wrap(h.handleTCPConnectorWithTargetConn(ctx, connector, targetConn, agent)) +} + +// handleTCPConnectorWithTargetTCPConn handles an incoming TCP connection from +// VNet when a TCP connection to the target host has already been established. +func (h *sshHandler) handleTCPConnectorWithTargetConn( + ctx context.Context, + connector func() (net.Conn, error), + targetConn net.Conn, + agent *sshAgent, +) error { + target := h.cfg.target + hostCert, err := newHostCert(target.fqdn, h.cfg.sshProvider.hostCASigner) + if err != nil { + return trace.Wrap(err) + } + + localConn, err := connector() + if err != nil { + return trace.Wrap(err) + } + defer localConn.Close() + + var ( + clientConn *sshConn + clientConnErr error + initiatedSSHConn bool + ) + serverConfig := &ssh.ServerConfig{ + // We attempt to initiate an SSH connection with the target server in + // PublicKeyCallback in order to fail the SSH authentication phase with + // the client if SSH authentication to the target fails. Otherwise, when + // connection to an SSH node the user is not allowed to access, they + // would just see an succesfull SSH handshake and then an immediately + // closed connection. + // + // TODO(nklaassen): if https://github.com/golang/go/issues/70795 ever + // gets implemented we should do this in VerifiedPublicKeyCallback + // instead. + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if !sshutils.KeysEqual(h.cfg.sshProvider.trustedUserPublicKey, key) { + return nil, trace.AccessDenied("client public key is not trusted") + } + // Make sure to only initiate the SSH connection once in case + // PublicKeyCallback is called multiple times. + if initiatedSSHConn { + return nil, clientConnErr + } + initiatedSSHConn = true + clientConn, clientConnErr = h.initiateSSHConn(ctx, targetConn, conn.User(), agent) + if clientConnErr != nil { + // Attempt to send a friendlier errer message if we failed to + // initiate the SSH connection to the target by sending an auth + // banner message. + if utils.IsHandshakeFailedError(clientConnErr) { + // We don't have much real information about the error in + // this case, this is the same message tsh prints. + return nil, &ssh.BannerError{ + Err: clientConnErr, + Message: formatBannerMessage(fmt.Sprintf("access denied to %s connecting to %s", conn.User(), target.hostname)), + } + } + return nil, &ssh.BannerError{ + Err: clientConnErr, + Message: formatBannerMessage(trace.UserMessage(clientConnErr)), + } + } + return nil, nil + }, + } + serverConfig.AddHostKey(hostCert) + + serverConn, serverChans, serverReqs, err := ssh.NewServerConn(localConn, serverConfig) + if err != nil { + // Make sure to close the client conn if we already accepted it. + if clientConn != nil { + clientConn.Close() + } + return trace.Wrap(err, "accepting incoming SSH connection") + } + log.DebugContext(ctx, "Accepted incoming SSH connection", + "profile", target.profile, + "cluster", target.cluster, + "host", target.hostname, + "user", serverConn.User(), + ) + + // proxySSHConnection transparently proxies the SSH connection from the + // client to the target. It will handle closing the connections before it + // returns. + proxySSHConnection(ctx, + sshConn{ + conn: serverConn, + chans: serverChans, + reqs: serverReqs, + }, + *clientConn, + ) + return nil +} + +func (h *sshHandler) initiateSSHConn(ctx context.Context, targetConn net.Conn, user string, agent *sshAgent) (*sshConn, error) { + target := h.cfg.target + clientConfig, err := h.cfg.sshProvider.sessionSSHConfig(ctx, target, user, agent) + if err != nil { + return nil, trace.Wrap(err, "building SSH client config") + } + clientConn, clientChans, clientReqs, err := tracessh.NewClientConn(ctx, targetConn, target.addr, clientConfig) + if err != nil { + return nil, trace.Wrap(err, "initiating SSH connection to %s@%s", user, target.addr) + } + log.DebugContext(ctx, "Initiated SSH connection to target", "root_cluster", target.rootCluster, + "leaf_cluster", target.leafCluster, "host", target.addr) + return &sshConn{ + conn: clientConn, + chans: clientChans, + reqs: clientReqs, + }, nil +} + +func newHostCert(fqdn string, ca ssh.Signer) (ssh.Signer, error) { + // If the user typed "ssh host.com" or "ssh host.com." our DNS handler will + // only see the fully-qualified variant with the trailing "." but the SSH + // client treats them differently, we need both in the principals if we want + // the cert to be trusted in both cases. + validPrincipals := []string{ + fqdn, + strings.TrimSuffix(fqdn, "."), + } + // We generate an ephemeral key for every connection, Ed25519 is fast and + // well supported. + hostKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err, "generating SSH host key") + } + hostSigner, err := ssh.NewSignerFromSigner(hostKey) + if err != nil { + return nil, trace.Wrap(err) + } + cert := &ssh.Certificate{ + Key: hostSigner.PublicKey(), + Serial: 1, + CertType: ssh.HostCert, + ValidPrincipals: validPrincipals, + // This cert will only ever be used to handle this one SSH connection, + // the private key is held only in memory, the issuing CA is regenerated + // every time this process restarts and will only be trusted on this one + // host. The expiry doesn't matter. + ValidBefore: ssh.CertTimeInfinity, + } + if err := cert.SignCert(rand.Reader, ca); err != nil { + return nil, trace.Wrap(err, "signing SSH host cert") + } + certSigner, err := ssh.NewCertSigner(cert, hostSigner) + return certSigner, trace.Wrap(err) +} + +func formatBannerMessage(msg string) string { + return "VNet: " + msg + "\n" +} diff --git a/lib/vnet/ssh_provider.go b/lib/vnet/ssh_provider.go new file mode 100644 index 0000000000000..2584794cf2b75 --- /dev/null +++ b/lib/vnet/ssh_provider.go @@ -0,0 +1,295 @@ +// 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 vnet + +import ( + "cmp" + "context" + "crypto/tls" + "crypto/x509" + "net" + "strings" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "golang.org/x/crypto/ssh" + + proxyclient "github.com/gravitational/teleport/api/client/proxy" + "github.com/gravitational/teleport/api/utils/sshutils" + vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" + "github.com/gravitational/teleport/lib/cryptosuites" +) + +// sshProvider provides methods necessary for VNet SSH access. +type sshProvider struct { + cfg sshProviderConfig + // hostCASigner is the host CA key used internally in VNet to terminate + // connections from clients, it is not a Teleport CA used by any cluster. + hostCASigner ssh.Signer + trustedUserPublicKey ssh.PublicKey +} + +type sshProviderConfig struct { + clt *clientApplicationServiceClient + clock clockwork.Clock + // overrideNodeDialer can be used in tests to dial SSH nodes with the real + // TLS configuration but without setting up the proxy transport service. + overrideNodeDialer func( + ctx context.Context, + target dialTarget, + tlsConfig *tls.Config, + dialOpts *vnetv1.DialOptions, + agent *sshAgent, + ) (net.Conn, error) +} + +func newSSHProvider(ctx context.Context, cfg sshProviderConfig) (*sshProvider, error) { + hostCAKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, trace.Wrap(err) + } + hostCASigner, err := ssh.NewSignerFromSigner(hostCAKey) + if err != nil { + return nil, trace.Wrap(err) + } + trustedUserPublicKey, err := cfg.clt.ExchangeSSHKeys(ctx, hostCASigner.PublicKey()) + if err != nil { + return nil, trace.Wrap(err) + } + return &sshProvider{ + cfg: cfg, + hostCASigner: hostCASigner, + trustedUserPublicKey: trustedUserPublicKey, + }, nil +} + +// dial dials the target SSH host. +func (p *sshProvider) dial(ctx context.Context, target dialTarget, agent *sshAgent) (net.Conn, error) { + userTLSCertResp, err := p.cfg.clt.UserTLSCert(ctx, target.profile) + if err != nil { + return nil, trace.Wrap(err) + } + rawCert := userTLSCertResp.GetCert() + dialOpts := userTLSCertResp.GetDialOptions() + tlsConfig, err := p.userTLSConfig(ctx, target.profile, rawCert, dialOpts) + if err != nil { + return nil, trace.Wrap(err) + } + if p.cfg.overrideNodeDialer != nil { + conn, err := p.cfg.overrideNodeDialer(ctx, target, tlsConfig, dialOpts, agent) + return conn, trace.Wrap(err) + } + return p.dialViaProxy(ctx, target, tlsConfig, dialOpts, agent) +} + +// dialViaProxy dials the target SSH host via the proxy transport service. +func (p *sshProvider) dialViaProxy( + ctx context.Context, + target dialTarget, + tlsConfig *tls.Config, + dialOpts *vnetv1.DialOptions, + agent *sshAgent, +) (net.Conn, error) { + // TODO(nklaassen): consider reusing proxy clients, need to figure out when + // it's necessary to make a new client e.g. if the user's TLS credentials + // are replaced by a relogin. For now it's simpler to make a new client for + // every SSH dial. + pclt, err := proxyclient.NewClient(ctx, proxyclient.ClientConfig{ + ProxyAddress: dialOpts.GetWebProxyAddr(), + TLSConfigFunc: func(cluster string) (*tls.Config, error) { return tlsConfig, nil }, + ALPNConnUpgradeRequired: dialOpts.GetAlpnConnUpgradeRequired(), + InsecureSkipVerify: dialOpts.GetInsecureSkipVerify(), + // This empty SSH client config should never be used, we dial to the + // proxy over TLS only. + SSHConfig: &ssh.ClientConfig{}, + }) + if err != nil { + return nil, trace.Wrap(err, "building proxy client") + } + // Forward an SSH agent in case proxy recording mode is enabled in the cluster. + // At this point there is no SSH key for the user yet and the agent is + // empty, the SSH key will be added to the agent in [sshProvider.sessionSSHConfig]. + // This forwarded agent will be used only to make the next SSH connection + // to the target SSH node, it is not actually forwarded to the target node + // and does not prevent the client from forwarding its own agent if + // requested. + conn, _, err := pclt.DialHost(ctx, target.addr, target.cluster, agent) + if err != nil { + pclt.Close() + return nil, trace.Wrap(err, "dialing target via proxy") + } + // Make sure to close the proxy client, but not until we're done with the + // target connection or else it would close the underlying gRPC stream. + conn = newConnWithExtraCloser(conn, pclt.Close) + return conn, nil +} + +func (p *sshProvider) userTLSConfig( + ctx context.Context, + profile string, + rawCert []byte, + dialOpts *vnetv1.DialOptions, +) (*tls.Config, error) { + parsedCert, err := x509.ParseCertificate(rawCert) + if err != nil { + return nil, trace.Wrap(err, "parsing user TLS certificate") + } + signer := &rpcSigner{ + pub: parsedCert.PublicKey, + sendRequest: func(req *vnetv1.SignRequest) ([]byte, error) { + return p.cfg.clt.SignForUserTLS(ctx, &vnetv1.SignForUserTLSRequest{ + Profile: profile, + Sign: req, + }) + }, + } + tlsCert := tls.Certificate{ + Certificate: [][]byte{rawCert}, + PrivateKey: signer, + } + caPool := x509.NewCertPool() + if !caPool.AppendCertsFromPEM(dialOpts.GetRootClusterCaCertPool()) { + return nil, trace.Errorf("failed to parse root cluster CA cert pool") + } + return &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + RootCAs: caPool, + ServerName: dialOpts.GetSni(), + InsecureSkipVerify: dialOpts.GetInsecureSkipVerify(), + }, nil +} + +func (p *sshProvider) sessionSSHConfig( + ctx context.Context, + target dialTarget, + user string, + agent *sshAgent, +) (*ssh.ClientConfig, error) { + // TODO(nklaassen): cache session SSH configs so we don't have to regenerate + // every time. + resp, err := p.cfg.clt.SessionSSHConfig(ctx, target, user) + if err != nil { + return nil, trace.Wrap(err) + } + sshPub, err := ssh.ParsePublicKey(resp.GetCert()) + if err != nil { + return nil, trace.Wrap(err, "parsing session SSH cert") + } + sshCert, ok := sshPub.(*ssh.Certificate) + if !ok { + return nil, trace.BadParameter("expected ssh.Certificate, got %T", sshCert) + } + cryptoPub, ok := sshCert.Key.(ssh.CryptoPublicKey) + if !ok { + return nil, trace.BadParameter("expected SSH key to implement CryptoPublicKey, got %T", sshCert.Key) + } + sessionID := resp.GetSessionId() + signer := &rpcSigner{ + pub: cryptoPub.CryptoPublicKey(), + sendRequest: func(req *vnetv1.SignRequest) ([]byte, error) { + return p.cfg.clt.SignForSSHSession(ctx, sessionID, req) + }, + } + sshSigner, err := ssh.NewSignerFromSigner(signer) + if err != nil { + return nil, trace.Wrap(err) + } + certSigner, err := ssh.NewCertSigner(sshCert, sshSigner) + if err != nil { + return nil, trace.Wrap(err) + } + // Add the session SSH key to the SSH agent in case proxy recording mode is + // enabled. Adding it to the agent here before returning an + // ssh.ClientConfig guarantees the key is added to the agent before the + // agent could be used. + if err := agent.setSessionKey(certSigner); err != nil { + return nil, trace.Wrap(err) + } + hostKeyCallback, err := buildHostKeyCallback(resp.GetTrustedCas(), p.cfg.clock) + if err != nil { + return nil, trace.Wrap(err) + } + return &ssh.ClientConfig{ + Auth: []ssh.AuthMethod{ssh.PublicKeys(certSigner)}, + User: user, + HostKeyCallback: hostKeyCallback, + }, nil +} + +func buildHostKeyCallback(trustedCAs [][]byte, clock clockwork.Clock) (ssh.HostKeyCallback, error) { + var caKeys []ssh.PublicKey + for _, trustedCA := range trustedCAs { + caKey, err := ssh.ParsePublicKey(trustedCA) + if err != nil { + return nil, trace.Wrap(err, "parsing trusted CA key") + } + caKeys = append(caKeys, caKey) + } + hostKeyCallback, err := sshutils.NewHostKeyCallback(sshutils.HostKeyCallbackConfig{ + GetHostCheckers: func() ([]ssh.PublicKey, error) { + return caKeys, nil + }, + Clock: clock, + }) + return hostKeyCallback, trace.Wrap(err, "building host key callback") +} + +type dialTarget struct { + fqdn string + profile string + rootCluster string + leafCluster string + cluster string + hostname string + addr string +} + +func computeDialTarget(matchedCluster *vnetv1.MatchedCluster, fqdn string) dialTarget { + // matchedCluster.LeafCluster will be set if the host was in a leaf + // cluster, else it will be unset and the target cluster is the root. + targetCluster := cmp.Or(matchedCluster.GetLeafCluster(), matchedCluster.GetRootCluster()) + targetHost := strings.TrimSuffix(fqdn, "."+fullyQualify(targetCluster)) + return dialTarget{ + fqdn: fqdn, + profile: matchedCluster.GetProfile(), + rootCluster: matchedCluster.GetRootCluster(), + leafCluster: matchedCluster.GetLeafCluster(), + cluster: targetCluster, + hostname: targetHost, + addr: targetHost + ":0", + } +} + +// connWithExtraCloser embeds a net.Conn and overrides the Close method to close +// an extra closer. Useful when the lifetime of a client providing the net.Conn +// must be tied to the lifetime of the Conn. +type connWithExtraCloser struct { + net.Conn + extraCloser func() error +} + +func newConnWithExtraCloser(conn net.Conn, extraCloser func() error) *connWithExtraCloser { + return &connWithExtraCloser{ + Conn: conn, + extraCloser: extraCloser, + } +} + +// Close closes the net.Conn and the extra closer. +func (c *connWithExtraCloser) Close() error { + return trace.NewAggregate(c.Conn.Close(), c.extraCloser()) +} diff --git a/lib/vnet/ssh_proxy.go b/lib/vnet/ssh_proxy.go index 35c1f44e17b13..df82175c25fd7 100644 --- a/lib/vnet/ssh_proxy.go +++ b/lib/vnet/ssh_proxy.go @@ -19,12 +19,12 @@ package vnet import ( "context" "errors" + "io" "log/slog" "sync" + "github.com/gravitational/trace" "golang.org/x/crypto/ssh" - - "github.com/gravitational/teleport/lib/utils" ) // sshConn represents an established SSH client or server connection. @@ -34,6 +34,16 @@ type sshConn struct { reqs <-chan *ssh.Request } +// Close closes the connection and drains all channels. +func (c *sshConn) Close() error { + err := trace.Wrap(c.conn.Close()) + go ssh.DiscardRequests(c.reqs) + for newChan := range c.chans { + newChan.Reject(0, "") + } + return err +} + // proxySSHConnection transparently proxies SSH channels and requests // between 2 established SSH connections. serverConn represents an incoming SSH // connection where this proxy acts as a server, client represents an outgoing @@ -44,8 +54,8 @@ func proxySSHConnection( clientConn sshConn, ) { closeConnections := sync.OnceFunc(func() { - clientConn.conn.Close() - serverConn.conn.Close() + clientConn.Close() + serverConn.Close() }) // Close both connections if the context is canceled. stop := context.AfterFunc(ctx, closeConnections) @@ -160,60 +170,134 @@ func proxyChannel( return } - // Copy channel requests in both directions concurrently. If either fails or - // exits it will cancel the context so that utils.ProxyConn below will close - // both channels so the other goroutine can also exit. + // Copy channel data and requests from the incoming channel to the target + // channel, and vice-versa. + target := newSSHChan(targetChan, targetChanRequests, slog.With("direction", "client->target")) + incoming := newSSHChan(incomingChan, incomingChanRequests, slog.With("direction", "target->client")) + var wg sync.WaitGroup wg.Add(2) - ctx, cancel := context.WithCancel(ctx) go func() { - proxyChannelRequests(ctx, log, targetChan, incomingChanRequests, cancel) - cancel() + target.writeFrom(ctx, incoming) wg.Done() }() go func() { - proxyChannelRequests(ctx, log, incomingChan, targetChanRequests, cancel) - cancel() + incoming.writeFrom(ctx, target) wg.Done() }() + wg.Wait() +} - // ProxyConn copies channel data bidirectionally. If the context is - // canceled it will terminate, it always closes both channels before - // returning. - if err := utils.ProxyConn(ctx, incomingChan, targetChan); err != nil && - !utils.IsOKNetworkError(err) && !errors.Is(err, context.Canceled) { - log.DebugContext(ctx, "Unexpected error proxying channel data", "error", err) +// sshChan manages all writes to an SSH channel and handles closing the channel +// once no more data or requests will be written to it. +type sshChan struct { + ch ssh.Channel + requests <-chan *ssh.Request + log *slog.Logger +} + +func newSSHChan(ch ssh.Channel, requests <-chan *ssh.Request, log *slog.Logger) *sshChan { + return &sshChan{ + ch: ch, + requests: requests, + log: log, } +} - // Wait for all goroutines to terminate. +// writeFrom writes channel data and requests from the source to this SSH channel. +// +// In the happy path it waits for: +// - channel data reads from source to return EOF +// - the source request channel to be closed +// and then closes this channel. +// +// Channel data reads from source can return EOF at any time if it has sent +// SSH_MSG_CHANNEL_EOF but it is still valid to send more channel requests +// after this. +// +// If an unrecoverable error is encountered it immediately closes both +// channels. +func (c *sshChan) writeFrom(ctx context.Context, source *sshChan) { + // Close the channel after all data and request writes are complete. + defer c.ch.Close() + + var wg sync.WaitGroup + wg.Add(2) + go func() { + c.writeDataFrom(ctx, source) + wg.Done() + }() + go func() { + c.writeRequestsFrom(ctx, source) + wg.Done() + }() wg.Wait() } -func proxyChannelRequests( - ctx context.Context, - log *slog.Logger, - targetChan ssh.Channel, - reqs <-chan *ssh.Request, - closeChannels func(), -) { - log = log.With("request_layer", "channel") +// writeDataFrom writes channel data from source to this SSH channel. +// It handles standard channel data and extended channel data of type stderr. +func (c *sshChan) writeDataFrom(ctx context.Context, source *sshChan) { + // Close the channel for writes only after both the standard and stderr + // streams are finished writing. + defer c.ch.CloseWrite() + + errors := make(chan error, 2) + go func() { + _, err := io.Copy(c.ch, source.ch) + errors <- err + }() + go func() { + _, err := io.Copy(c.ch.Stderr(), source.ch.Stderr()) + errors <- err + }() + + // Read both errors to make sure both goroutines terminate, but only do + // anything on the first non-nil error, the second error is likely either + // the same as the first one or caused by closing the channel. + handledError := false + for range 2 { + err := <-errors + if err != nil && !handledError { + handledError = true + // Failed to write channel data from source to this channel. This was + // not an EOF from source or io.Copy would have returned nil. The + // stream might be missing data so close both channels. + // + // This should also unblock the stderr stream if the regular stream + // returned an error, and vice-versa. + c.log.ErrorContext(ctx, "Fatal error proxying SSH channel data", "error", err) + c.ch.Close() + source.ch.Close() + } + } +} + +// writeRequestsFrom forwards channel requests from source to this SSH channel. +func (c *sshChan) writeRequestsFrom(ctx context.Context, source *sshChan) { + log := c.log.With("request_layer", "channel") sendRequest := func(name string, wantReply bool, payload []byte) (bool, []byte, error) { - ok, err := targetChan.SendRequest(name, wantReply, payload) + ok, err := c.ch.SendRequest(name, wantReply, payload) // Replies to channel requests never have a payload. return ok, nil, err } - proxyRequests(ctx, log, sendRequest, reqs, closeChannels) + // Must forcibly close both channels if there was a fatal error proxying + // channel requests so that we don't continue in a bad state. + onFatalError := func() { + c.ch.Close() + source.ch.Close() + } + proxyRequests(ctx, log, sendRequest, source.requests, onFatalError) } func proxyGlobalRequests( ctx context.Context, targetConn ssh.Conn, reqs <-chan *ssh.Request, - closeConnections func(), + onFatalError func(), ) { log := log.With("request_layer", "global") sendRequest := targetConn.SendRequest - proxyRequests(ctx, log, sendRequest, reqs, closeConnections) + proxyRequests(ctx, log, sendRequest, reqs, onFatalError) } func proxyRequests( @@ -221,7 +305,7 @@ func proxyRequests( log *slog.Logger, sendRequest func(name string, wantReply bool, payload []byte) (bool, []byte, error), reqs <-chan *ssh.Request, - closeRequestSources func(), + onFatalError func(), ) { for req := range reqs { log := log.With("request_type", req.Type) @@ -229,23 +313,20 @@ func proxyRequests( ok, reply, err := sendRequest(req.Type, req.WantReply, req.Payload) if err != nil { // We failed to send the request, the target must be dead. - log.DebugContext(ctx, "Failed to forward SSH request", "request_type", req.Type, "error", err) - // Close both connections or channels to clean up but we must - // continue handling requests on the chan until it is closed by - // crypto/ssh. - closeRequestSources() - _ = req.Reply(false, nil) - continue + log.DebugContext(ctx, "Failed to forward SSH request", "error", err) + onFatalError() + req.Reply(false, nil) + ssh.DiscardRequests(reqs) + return } if err := req.Reply(ok, reply); err != nil { // A reply was expected and returned by the target but we failed to // forward it back, the connection that initiated the request must // be dead. - log.DebugContext(ctx, "Failed to reply to SSH request", "request_type", req.Type, "error", err) - // Close both connections or channels to clean up but we must - // continue handling requests on the chan until it is closed by - // crypto/ssh. - closeRequestSources() + log.DebugContext(ctx, "Failed to reply to SSH request", "error", err) + onFatalError() + ssh.DiscardRequests(reqs) + return } } } diff --git a/lib/vnet/ssh_proxy_test.go b/lib/vnet/ssh_proxy_test.go index 3f654436d9068..d7eb8313c8a11 100644 --- a/lib/vnet/ssh_proxy_test.go +++ b/lib/vnet/ssh_proxy_test.go @@ -21,6 +21,7 @@ import ( "fmt" "io" "net" + "sync" "testing" "github.com/gravitational/trace" @@ -103,11 +104,22 @@ func testSSHConnection(t *testing.T, dial dialer) { sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, "localhost", clientConfig) require.NoError(t, err) defer sshConn.Close() - go ssh.DiscardRequests(reqs) + + testConnectionToSshEchoServer(t, sshConn, chans, reqs) +} + +func testConnectionToSshEchoServer(t *testing.T, sshConn ssh.Conn, chans <-chan ssh.NewChannel, reqs <-chan *ssh.Request) { + requestStreamEnded := make(chan struct{}) + go func() { + ssh.DiscardRequests(reqs) + close(requestStreamEnded) + }() + chanStreamEnded := make(chan struct{}) go func() { for newChan := range chans { newChan.Reject(ssh.Prohibited, "test") } + close(chanStreamEnded) }() // Try sending some global requests. @@ -131,6 +143,26 @@ func testSSHConnection(t *testing.T, dial dialer) { t.Run("echo channel 2", func(t *testing.T) { testEchoChannel(t, sshConn) }) + + t.Run("closing", func(t *testing.T) { + // Send a request that causes the target server to close the connection + // immediately and make sure channel reads are unblocked, and the global + // request and channel request streams end. + ch, reqs, err := sshConn.OpenChannel("echo", nil) + require.NoError(t, err) + go ssh.DiscardRequests(reqs) + readErr := make(chan error) + go func() { + var b [1]byte + _, err := ch.Read(b[:]) + readErr <- err + }() + _, _, err = sshConn.SendRequest("close", false, nil) + require.NoError(t, err) + require.ErrorIs(t, <-readErr, io.EOF) + <-requestStreamEnded + <-chanStreamEnded + }) } func testGlobalRequests(t *testing.T, conn ssh.Conn) { @@ -151,7 +183,11 @@ func testGlobalRequests(t *testing.T, conn ssh.Conn) { func testEchoChannel(t *testing.T, conn ssh.Conn) { ch, reqs, err := conn.OpenChannel("echo", nil) require.NoError(t, err) - go ssh.DiscardRequests(reqs) + requestStreamEnded := make(chan struct{}) + go func() { + ssh.DiscardRequests(reqs) + close(requestStreamEnded) + }() defer ch.Close() // Try sending a message over the SSH channel and asserting that it is @@ -165,16 +201,43 @@ func testEchoChannel(t *testing.T, conn ssh.Conn) { require.Equal(t, len(msg), n) require.Equal(t, msg, buf[:n]) + // Try sending a message over stderr and asserting that it is echoed back. + _, err = ch.Stderr().Write(msg) + require.NoError(t, err) + n, err = ch.Stderr().Read(buf[:]) + require.NoError(t, err) + require.Equal(t, len(msg), n) + require.Equal(t, msg, buf[:n]) + // Try sending a channel request that expects a reply. reply, err := ch.SendRequest("echo", true, nil) require.NoError(t, err) require.True(t, reply) + // Close the channel for writes of in-band data and send another channel + // request, which should succeed. + require.NoError(t, ch.CloseWrite()) + reply, err = ch.SendRequest("echo", true, nil) + require.NoError(t, err) + require.True(t, reply) + // The test server replies false to channel requests with type other than // "echo". reply, err = ch.SendRequest("unknown", true, nil) require.NoError(t, err) require.False(t, reply) + + // Send a channel request that causes the server to close the channel and + // make sure channel reads get unblocked and the incoming request stream ends. + readErr := make(chan error) + go func() { + _, err := ch.Read(buf[:]) + readErr <- err + }() + _, err = ch.SendRequest("close", false, nil) + require.NoError(t, err) + require.ErrorIs(t, <-readErr, io.EOF) + <-requestStreamEnded } type dialer interface { @@ -277,7 +340,7 @@ func runTestSSHServerInstance(tcpConn net.Conn, cfg *ssh.ServerConfig) error { return trace.Wrap(err) } go func() { - handleEchoRequests(reqs) + handleSSHRequests(reqs, sshConn.Close) sshConn.Close() }() handleEchoChannels(chans) @@ -285,17 +348,6 @@ func runTestSSHServerInstance(tcpConn net.Conn, cfg *ssh.ServerConfig) error { return nil } -func handleEchoRequests(reqs <-chan *ssh.Request) { - for req := range reqs { - switch req.Type { - case "echo": - req.Reply(true, req.Payload) - default: - req.Reply(false, nil) - } - } -} - func handleEchoChannels(chans <-chan ssh.NewChannel) { for newChan := range chans { switch newChan.ChannelType() { @@ -312,8 +364,33 @@ func handleEchoChannel(newChan ssh.NewChannel) { if err != nil { return } - go handleEchoRequests(reqs) - io.Copy(ch, ch) + go handleSSHRequests(reqs, ch.Close) + defer ch.CloseWrite() + var wg sync.WaitGroup + wg.Add(2) + go func() { + io.Copy(ch, ch) + wg.Done() + }() + go func() { + io.Copy(ch.Stderr(), ch.Stderr()) + wg.Done() + }() + wg.Wait() +} + +func handleSSHRequests(reqs <-chan *ssh.Request, closeSource func() error) { + defer closeSource() + for req := range reqs { + switch req.Type { + case "echo": + req.Reply(true, req.Payload) + case "close": + closeSource() + default: + req.Reply(false, nil) + } + } } func sshServerConfig(t *testing.T) *ssh.ServerConfig { diff --git a/lib/vnet/tcp_handler_resolver.go b/lib/vnet/tcp_handler_resolver.go index 6274e42eb19f2..ff679b48eb2d6 100644 --- a/lib/vnet/tcp_handler_resolver.go +++ b/lib/vnet/tcp_handler_resolver.go @@ -37,9 +37,11 @@ type tcpHandlerResolver struct { } type tcpHandlerResolverConfig struct { - clt *clientApplicationServiceClient - appProvider *appProvider - clock clockwork.Clock + clt *clientApplicationServiceClient + appProvider *appProvider + sshProvider *sshProvider + clock clockwork.Clock + alwaysTrustRootClusterCA bool } func newTCPHandlerResolver(cfg *tcpHandlerResolverConfig) *tcpHandlerResolver { @@ -67,9 +69,10 @@ func (r *tcpHandlerResolver) resolveTCPHandler(ctx context.Context, fqdn string) return &tcpHandlerSpec{ ipv4CIDRRange: appInfo.GetIpv4CidrRange(), tcpHandler: newTCPAppHandler(&tcpAppHandlerConfig{ - appInfo: appInfo, - appProvider: r.cfg.appProvider, - clock: r.cfg.clock, + appInfo: appInfo, + appProvider: r.cfg.appProvider, + clock: r.cfg.clock, + alwaysTrustRootClusterCA: r.cfg.alwaysTrustRootClusterCA, }), }, nil } @@ -85,11 +88,9 @@ func (r *tcpHandlerResolver) resolveTCPHandler(ctx context.Context, fqdn string) // TCP connection if this may match an SSH node or an app that may be // added later so we return an undecidedHandler. handler, err := newUndecidedHandler(&undecidedHandlerConfig{ - clt: r.cfg.clt, - appProvider: r.cfg.appProvider, - clock: r.cfg.clock, - fqdn: fqdn, - webProxyAddr: matchedCluster.GetWebProxyAddr(), + tcpHandlerResolverConfig: r.cfg, + fqdn: fqdn, + webProxyAddr: matchedCluster.GetWebProxyAddr(), }) if err != nil { return nil, trace.Wrap(err) @@ -118,9 +119,7 @@ type undecidedHandler struct { } type undecidedHandlerConfig struct { - clt *clientApplicationServiceClient - appProvider *appProvider - clock clockwork.Clock + *tcpHandlerResolverConfig fqdn string webProxyAddr string } @@ -163,14 +162,16 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin if err != nil { return trace.Wrap(err, "resolving target in undecidedHandler") } + log := log.With("fqdn", h.cfg.fqdn) if matchedTCPApp := resp.GetMatchedTcpApp(); matchedTCPApp != nil { // If matched a TCP app, build a tcpAppHandler that will be used for this // and all subsequent connections to this address. - log.DebugContext(ctx, "Resolved FQDN to a matched TCP app", "fqdn", h.cfg.fqdn) + log.DebugContext(ctx, "Resolved FQDN to a matched TCP app") tcpAppHandler := newTCPAppHandler(&tcpAppHandlerConfig{ - appInfo: matchedTCPApp.GetAppInfo(), - appProvider: h.cfg.appProvider, - clock: h.cfg.clock, + appInfo: matchedTCPApp.GetAppInfo(), + appProvider: h.cfg.appProvider, + clock: h.cfg.clock, + alwaysTrustRootClusterCA: h.cfg.alwaysTrustRootClusterCA, }) h.setDecidedHandler(tcpAppHandler) return tcpAppHandler.handleTCPConnector(ctx, localPort, connector) @@ -178,13 +179,38 @@ func (h *undecidedHandler) handleTCPConnector(ctx context.Context, localPort uin if matchedWebApp := resp.GetMatchedWebApp(); matchedWebApp != nil && localPort == h.webProxyPort { // If matched a web app, build a webAppHandler that will be used for this // and all subsequent connections to this address. - log.DebugContext(ctx, "Resolved FQDN to a matched web app", "fqdn", h.cfg.fqdn) + log.DebugContext(ctx, "Resolved FQDN to a matched web app") webAppHandler := newWebAppHandler(h.cfg.webProxyAddr, h.webProxyPort) h.setDecidedHandler(webAppHandler) return webAppHandler.handleTCPConnector(ctx, localPort, connector) } if matchedCluster := resp.GetMatchedCluster(); matchedCluster != nil && localPort == 22 { - return trace.NotImplemented("SSH connection forwarding not yet implemented") + // Matched a cluster, this FQDN could potentially match an SSH node. + log.DebugContext(ctx, "Resolved FQDN to a matched cluster") + // Attempt a dial to the target SSH node to see if it exists. + target := computeDialTarget(matchedCluster, h.cfg.fqdn) + agent := newSSHAgent() + targetConn, err := h.cfg.sshProvider.dial(ctx, target, agent) + if err != nil { + if trace.IsConnectionProblem(err) { + log.DebugContext(ctx, "Failed TCP dial to target, node might be offline") + return nil + } + return trace.Wrap(err, "unexpected error TCP dialing to target node at %s", h.cfg.fqdn) + } + defer targetConn.Close() + log.DebugContext(ctx, "TCP dial to target SSH node succeeded", "fqdn", h.cfg.fqdn) + // Now that we know there is a matching SSH node, this handler will + // permanently handle SSH connections at this address and avoid app + // queries on subsequent connections. + sshHandler := newSSHHandler(sshHandlerConfig{ + sshProvider: h.cfg.sshProvider, + target: target, + }) + h.setDecidedHandler(sshHandler) + // Handle the incoming connection with the TCP connection to the target + // SSH node that has already been established. + return sshHandler.handleTCPConnectorWithTargetConn(ctx, connector, targetConn, agent) } return trace.Errorf("rejecting connection to %s:%d", h.cfg.fqdn, localPort) } diff --git a/lib/vnet/unified_cluster_config_provider.go b/lib/vnet/unified_cluster_config_provider.go new file mode 100644 index 0000000000000..bb6cdf1f4f470 --- /dev/null +++ b/lib/vnet/unified_cluster_config_provider.go @@ -0,0 +1,152 @@ +// 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 vnet + +import ( + "context" + "slices" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/utils" +) + +// UnifiedClusterConfig is a unified VNet configuration for all clusters +// the user is logged in to. +type UnifiedClusterConfig struct { + // ClusterNames contains the name of each root or leaf cluster the user is + // logged in to. SSH hosts are reachable via VNet SSH at subdomains of + // these cluster names. + ClusterNames []string + // ProxyPublicAddrs contains the proxy public address of root and leaf + // cluster the user is logged in to. These are always valid DNS suffixes for + // TCP apps. + ProxyPublicAddrs []string + // CustomDNSZones is the unified set of custom DNS zones configured in all clusters. + CustomDNSZones []string + // IPv4CidrRanges is the unified set of IPv4 CIDR ranges configured in all + // clusters, VNet will try to route all of these to the TUN interface. + IPv4CidrRanges []string +} + +// AppDNSZones returns the DNS suffixes valid for TCP apps. +func (c *UnifiedClusterConfig) AppDNSZones() []string { + return utils.Deduplicate(slices.Concat(c.CustomDNSZones, c.ProxyPublicAddrs)) +} + +// AllDNSZones return all DNS suffixes VNet handles. +func (c *UnifiedClusterConfig) AllDNSZones() []string { + return utils.Deduplicate(slices.Concat(c.CustomDNSZones, c.ProxyPublicAddrs, c.ClusterNames)) +} + +// UnifiedClusterConfigProvider fetches the [UnifiedClusterConfig]. +type UnifiedClusterConfigProvider struct { + cfg *UnifiedClusterConfigProviderConfig +} + +// UnifiedClusterConfigProviderConfig holds configuration parameters for +// [UnifiedClusterConfigProvider]. +type UnifiedClusterConfigProviderConfig struct { + clientApplication ClientApplication + clusterConfigCache *ClusterConfigCache + leafClusterCache *leafClusterCache +} + +// NewUnifiedClusterConfigProvider returns a new [UnifiedClusterConfigProvider]. +func NewUnifiedClusterConfigProvider(cfg *UnifiedClusterConfigProviderConfig) *UnifiedClusterConfigProvider { + return &UnifiedClusterConfigProvider{ + cfg: cfg, + } +} + +// GetUnifiedClusterConfig returns the unified VNet configuration of all +// clusters the user is logged in to. +func (p *UnifiedClusterConfigProvider) GetUnifiedClusterConfig(ctx context.Context) (*UnifiedClusterConfig, error) { + profiles, err := p.cfg.clientApplication.ListProfiles() + if err != nil { + return nil, trace.Wrap(err, "listing profiles") + } + var unifiedClusterConfig UnifiedClusterConfig + for _, profileName := range profiles { + if err := p.fetchForProfile(ctx, profileName, &unifiedClusterConfig); err != nil { + log.WarnContext(ctx, + "Failed to fetch VNet configuration, profile may be expired, not configuring VNet for this profile", + "profile", profileName, "error", err) + } + } + unifiedClusterConfig.ClusterNames = utils.Deduplicate(unifiedClusterConfig.ClusterNames) + unifiedClusterConfig.ProxyPublicAddrs = utils.Deduplicate(unifiedClusterConfig.ProxyPublicAddrs) + unifiedClusterConfig.CustomDNSZones = utils.Deduplicate(unifiedClusterConfig.CustomDNSZones) + unifiedClusterConfig.IPv4CidrRanges = utils.Deduplicate(unifiedClusterConfig.IPv4CidrRanges) + return &unifiedClusterConfig, nil +} + +func (p *UnifiedClusterConfigProvider) fetchForProfile( + ctx context.Context, + profileName string, + unifiedClusterConfig *UnifiedClusterConfig, +) error { + rootClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, "" /*leafClusterName*/) + if err != nil { + return trace.Wrap(err, "getting root cluster client from cache") + } + rootClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, rootClusterClient) + if err != nil { + return trace.Wrap(err, "fetching root cluster VNet config") + } + unifiedClusterConfig.ClusterNames = append(unifiedClusterConfig.ClusterNames, rootClusterClient.ClusterName()) + unifiedClusterConfig.ProxyPublicAddrs = append(unifiedClusterConfig.ProxyPublicAddrs, rootClusterConfig.ProxyPublicAddr) + unifiedClusterConfig.CustomDNSZones = append(unifiedClusterConfig.CustomDNSZones, rootClusterConfig.CustomDNSZones...) + unifiedClusterConfig.IPv4CidrRanges = append(unifiedClusterConfig.IPv4CidrRanges, rootClusterConfig.IPv4CIDRRange) + + leafClusterNames, err := p.cfg.leafClusterCache.getLeafClusters(ctx, rootClusterClient) + if err != nil { + log.WarnContext(ctx, + "Failed to list leaf clusters, profile may be expired, not configuring VNet for leaf clusters in this profile", + "profile", profileName, "error", err) + return nil + } + for _, leafClusterName := range leafClusterNames { + if err := p.fetchForLeafCluster(ctx, profileName, leafClusterName, unifiedClusterConfig); err != nil { + log.WarnContext(ctx, + "Failed to fetch VNet configuration for leaf cluster, VNet will not be configured for this cluster", + "profile", profileName, "leaf_cluster", leafClusterName, "error", err) + } + } + return nil +} + +func (p *UnifiedClusterConfigProvider) fetchForLeafCluster( + ctx context.Context, + profileName string, + leafClusterName string, + unifiedClusterConfig *UnifiedClusterConfig, +) error { + leafClusterClient, err := p.cfg.clientApplication.GetCachedClient(ctx, profileName, leafClusterName) + if err != nil { + return trace.Wrap(err, "getting leaf cluster client from cache") + } + leafClusterConfig, err := p.cfg.clusterConfigCache.GetClusterConfig(ctx, leafClusterClient) + if err != nil { + return trace.Wrap(err, "fetching leaf cluster VNet config from cache") + } + unifiedClusterConfig.ClusterNames = append(unifiedClusterConfig.ClusterNames, leafClusterName) + unifiedClusterConfig.ProxyPublicAddrs = append(unifiedClusterConfig.ProxyPublicAddrs, leafClusterConfig.ProxyPublicAddr) + unifiedClusterConfig.CustomDNSZones = append(unifiedClusterConfig.CustomDNSZones, leafClusterConfig.CustomDNSZones...) + unifiedClusterConfig.IPv4CidrRanges = append(unifiedClusterConfig.IPv4CidrRanges, leafClusterConfig.IPv4CIDRRange) + return nil +} diff --git a/lib/vnet/user_process.go b/lib/vnet/user_process.go index e490a8c639405..b74a662d60098 100644 --- a/lib/vnet/user_process.go +++ b/lib/vnet/user_process.go @@ -25,6 +25,7 @@ import ( vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/client" ) // ClientApplication is the common interface implemented by each VNet client @@ -45,16 +46,23 @@ type ClientApplication interface { // ReissueAppCert issues a new cert for the target app. ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppInfo, targetPort uint16) (tls.Certificate, error) + // UserTLSCert returns the user TLS certificate for the given profile. + UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) + // GetDialOptions returns ALPN dial options for the profile. GetDialOptions(ctx context.Context, profileName string) (*vnetv1.DialOptions, error) - // OnNewConnection gets called whenever a new connection is about to be established through VNet. - // By the time OnNewConnection, VNet has already verified that the user holds a valid cert for the + // OnNewSSHSession should be called whenever a new SSH session is about to be + // started, after getting the user SSH certificate for the session. + OnNewSSHSession(ctx context.Context, profileName, rootClusterName string) + + // OnNewAppConnection gets called whenever a new app connection is about to be established through VNet. + // By the time OnNewAppConnection, VNet has already verified that the user holds a valid cert for the // app. // - // The connection won't be established until OnNewConnection returns. Returning an error prevents + // The connection won't be established until OnNewAppConnection returns. Returning an error prevents // the connection from being made. - OnNewConnection(ctx context.Context, appKey *vnetv1.AppKey) error + OnNewAppConnection(ctx context.Context, appKey *vnetv1.AppKey) error // OnInvalidLocalPort gets called before VNet refuses to handle a connection to a multi-port TCP app // because the provided port does not match any of the TCP ports in the app spec. @@ -67,6 +75,7 @@ type ClusterClient interface { CurrentCluster() authclient.ClientI ClusterName() string RootClusterName() string + SessionSSHKeyRing(ctx context.Context, user string, target client.NodeDetails) (keyRing *client.KeyRing, completedMFA bool, err error) } // RunUserProcess is the entry point called by all VNet client applications @@ -93,23 +102,38 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) - osConfigProvider := NewLocalOSConfigProvider(&LocalOSConfigProviderConfig{ + unifiedClusterConfigProvider := NewUnifiedClusterConfigProvider(&UnifiedClusterConfigProviderConfig{ clientApplication: clientApplication, clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) - clientApplicationService := newClientApplicationService(&clientApplicationServiceConfig{ - clientApplication: clientApplication, - fqdnResolver: fqdnResolver, - localOSConfigProvider: osConfigProvider, + clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{ + clientApplication: clientApplication, + fqdnResolver: fqdnResolver, + unifiedClusterConfigProvider: unifiedClusterConfigProvider, + clock: clock, }) + if err != nil { + return nil, trace.Wrap(err) + } + + processManager, processCtx := newProcessManager() + sshConfigurator := newSSHConfigurator(sshConfiguratorConfig{ + clientApplication: clientApplication, + leafClusterCache: leafClusterCache, + }) + processManager.AddCriticalBackgroundTask("SSH configuration loop", func() error { + return trace.Wrap(sshConfigurator.runConfigurationLoop(processCtx)) + }) + userProcess := &UserProcess{ - clientApplication: clientApplication, - osConfigProvider: osConfigProvider, - clientApplicationService: clientApplicationService, - clock: clock, + clientApplication: clientApplication, + unifiedClusterConfigProvider: unifiedClusterConfigProvider, + clientApplicationService: clientApplicationService, + clock: clock, + processManager: processManager, } - if err := userProcess.runPlatformUserProcess(ctx); err != nil { + if err := userProcess.runPlatformUserProcess(processCtx); err != nil { return nil, trace.Wrap(err) } return userProcess, nil @@ -120,9 +144,9 @@ func RunUserProcess(ctx context.Context, clientApplication ClientApplication) (* type UserProcess struct { clientApplication ClientApplication - clock clockwork.Clock - osConfigProvider *LocalOSConfigProvider - clientApplicationService *clientApplicationService + clock clockwork.Clock + unifiedClusterConfigProvider *UnifiedClusterConfigProvider + clientApplicationService *clientApplicationService processManager *ProcessManager networkStackInfo *vnetv1.NetworkStackInfo @@ -143,6 +167,6 @@ func (p *UserProcess) NetworkStackInfo() *vnetv1.NetworkStackInfo { // GetTargetOSConfiguration returns the LocalOSConfigProvider which clients may // use to report the proxied DNS zones, run diagnostics, etc. The returned // *LocalOSConfigProvider will remain valid even if the UserProcess is closed. -func (p *UserProcess) GetOSConfigProvider() *LocalOSConfigProvider { - return p.osConfigProvider +func (p *UserProcess) GetUnifiedClusterConfigProvider() *UnifiedClusterConfigProvider { + return p.unifiedClusterConfigProvider } diff --git a/lib/vnet/user_process_darwin.go b/lib/vnet/user_process_darwin.go index a88f2313688a6..44c0d672470ab 100644 --- a/lib/vnet/user_process_darwin.go +++ b/lib/vnet/user_process_darwin.go @@ -35,7 +35,7 @@ import ( // interface that the daemon uses to query application names and get user // certificates for apps. If successful it sets p.processManager and // p.networkStackInfo. -func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { +func (p *UserProcess) runPlatformUserProcess(processCtx context.Context) error { ipcCreds, err := newIPCCredentials() if err != nil { return trace.Wrap(err, "creating credentials for IPC") @@ -67,16 +67,14 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { ) vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService) - pm, processCtx := newProcessManager() - p.processManager = pm - pm.AddCriticalBackgroundTask("admin process", func() error { + p.processManager.AddCriticalBackgroundTask("admin process", func() error { defer func() { // Delete service credentials after the service terminates. if ipcCreds.client.remove(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential files", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential files", "error", err) } if err := os.RemoveAll(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential directory", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential directory", "error", err) } }() daemonConfig := daemon.Config{ @@ -85,13 +83,13 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { } return trace.Wrap(execAdminProcess(processCtx, daemonConfig)) }) - pm.AddCriticalBackgroundTask("gRPC service", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC service", func() error { log.InfoContext(processCtx, "Starting gRPC service", "addr", listener.Addr().String()) return trace.Wrap(grpcServer.Serve(listener), "serving VNet user process gRPC service") }) - pm.AddCriticalBackgroundTask("gRPC server closer", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC server closer", func() error { // grpcServer.Serve does not stop on its own when processCtx is done, so // this task waits for processCtx and then explicitly stops grpcServer. <-processCtx.Done() @@ -104,6 +102,6 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { p.networkStackInfo = nsi return nil case <-processCtx.Done(): - return trace.Wrap(pm.Wait(), "process manager exited before network stack info was received") + return trace.Wrap(p.processManager.Wait(), "process manager exited before network stack info was received") } } diff --git a/lib/vnet/user_process_windows.go b/lib/vnet/user_process_windows.go index 80d935f209fd1..e0ab82eda82cf 100644 --- a/lib/vnet/user_process_windows.go +++ b/lib/vnet/user_process_windows.go @@ -37,7 +37,7 @@ import ( // interface that the admin process uses to query application names and get user // certificates for apps. It returns a [ProcessManager] which controls the // lifecycle of both the user and admin processes. -func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { +func (p *UserProcess) runPlatformUserProcess(processCtx context.Context) error { ipcCreds, err := newIPCCredentials() if err != nil { return trace.Wrap(err, "creating credentials for IPC") @@ -77,17 +77,15 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { ) vnetv1.RegisterClientApplicationServiceServer(grpcServer, p.clientApplicationService) - pm, processCtx := newProcessManager() - p.processManager = pm - pm.AddCriticalBackgroundTask("admin process", func() error { + p.processManager.AddCriticalBackgroundTask("admin process", func() error { log.InfoContext(processCtx, "Starting Windows service") defer func() { // Delete service credentials after the service terminates. if ipcCreds.client.remove(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential files", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential files", "error", err) } if err := os.RemoveAll(credDir); err != nil { - log.ErrorContext(ctx, "Failed to remove service credential directory", "error", err) + log.ErrorContext(processCtx, "Failed to remove service credential directory", "error", err) } }() return trace.Wrap(runService(processCtx, &windowsAdminProcessConfig{ @@ -96,13 +94,13 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { userSID: userSID, })) }) - pm.AddCriticalBackgroundTask("gRPC service", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC service", func() error { log.InfoContext(processCtx, "Starting gRPC service", "addr", listener.Addr().String()) return trace.Wrap(grpcServer.Serve(listener), "serving VNet user process gRPC service") }) - pm.AddCriticalBackgroundTask("gRPC server closer", func() error { + p.processManager.AddCriticalBackgroundTask("gRPC server closer", func() error { // grpcServer.Serve does not stop on its own when processCtx is done, so // this task waits for processCtx and then explicitly stops grpcServer. <-processCtx.Done() @@ -115,7 +113,7 @@ func (p *UserProcess) runPlatformUserProcess(ctx context.Context) error { p.networkStackInfo = nsi return nil case <-processCtx.Done(): - return trace.Wrap(pm.Wait(), "process manager exited before network stack info was received") + return trace.Wrap(p.processManager.Wait(), "process manager exited before network stack info was received") } } diff --git a/lib/vnet/vnet_test.go b/lib/vnet/vnet_test.go index 4b275173cfa64..405a466a14919 100644 --- a/lib/vnet/vnet_test.go +++ b/lib/vnet/vnet_test.go @@ -43,6 +43,7 @@ import ( "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" "google.golang.org/grpc" grpccredentials "google.golang.org/grpc/credentials" "gvisor.dev/gvisor/pkg/tcpip" @@ -52,14 +53,19 @@ import ( "gvisor.dev/gvisor/pkg/tcpip/stack" "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/defaults" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" "github.com/gravitational/teleport/api/types" typesvnet "github.com/gravitational/teleport/api/types/vnet" "github.com/gravitational/teleport/api/utils/grpc/interceptors" + "github.com/gravitational/teleport/api/utils/keypaths" + "github.com/gravitational/teleport/api/utils/sshutils" vnetv1 "github.com/gravitational/teleport/gen/proto/go/teleport/lib/vnet/v1" "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/cryptosuites" + alpncommon "github.com/gravitational/teleport/lib/srv/alpnproxy/common" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/testutils" @@ -87,9 +93,14 @@ type testPack struct { type testPackConfig struct { clock clockwork.Clock fakeClientApp *fakeClientApp + homePath string } func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPack { + if cfg.homePath == "" { + cfg.homePath = t.TempDir() + } + // Create two sides of an emulated TUN interface: writes to one can be read on the other, and vice versa. tun1, tun2 := newSplitTUN() @@ -143,12 +154,20 @@ func newTestPack(t *testing.T, ctx context.Context, cfg testPackConfig) *testPac // client application and communicates over gRPC. For the test, everything // runs in a single process, but we still set up the gRPC service and only // interface with fakeClientApp via the gRPC client. - clt := runTestClientApplicationService(t, ctx, cfg.clock, cfg.fakeClientApp) + clt := runTestClientApplicationService(t, ctx, cfg) appProvider := newAppProvider(clt) + sshProvider, err := newSSHProvider(ctx, sshProviderConfig{ + clt: clt, + clock: cfg.clock, + overrideNodeDialer: cfg.fakeClientApp.dialSSHNode, + }) + require.NoError(t, err) tcpHandlerResolver := newTCPHandlerResolver(&tcpHandlerResolverConfig{ - clt: clt, - appProvider: appProvider, - clock: cfg.clock, + clt: clt, + appProvider: appProvider, + sshProvider: sshProvider, + clock: cfg.clock, + alwaysTrustRootClusterCA: true, }) // Create the VNet and connect it to the other side of the TUN. @@ -254,20 +273,22 @@ func (p *testPack) dialHost(ctx context.Context, host string, port int) (net.Con // runTestClientApplicationService runs the gRPC service that's normally used to // expose the client application and Teleport client methods to the VNet // admin/networking process over gRPC. It returns a client of the gRPC service. -func runTestClientApplicationService(t *testing.T, ctx context.Context, clock clockwork.Clock, clientApp *fakeClientApp) *clientApplicationServiceClient { - clusterConfigCache := NewClusterConfigCache(clock) - leafClusterCache, err := newLeafClusterCache(clock) +func runTestClientApplicationService(t *testing.T, ctx context.Context, cfg testPackConfig) *clientApplicationServiceClient { + clusterConfigCache := NewClusterConfigCache(cfg.clock) + leafClusterCache, err := newLeafClusterCache(cfg.clock) require.NoError(t, err) fqdnResolver := newFQDNResolver(&fqdnResolverConfig{ - clientApplication: clientApp, + clientApplication: cfg.fakeClientApp, clusterConfigCache: clusterConfigCache, leafClusterCache: leafClusterCache, }) - clientApplicationService := newClientApplicationService(&clientApplicationServiceConfig{ - clientApplication: clientApp, - fqdnResolver: fqdnResolver, - localOSConfigProvider: nil, // OS configuration is not needed in tests. + clientApplicationService, err := newClientApplicationService(&clientApplicationServiceConfig{ + clientApplication: cfg.fakeClientApp, + fqdnResolver: fqdnResolver, + homePath: cfg.homePath, + clock: cfg.clock, }) + require.NoError(t, err) ipcCredentials, err := newIPCCredentials() require.NoError(t, err) @@ -319,8 +340,13 @@ type appSpec struct { tcpPorts []*types.PortRange } +type nodeSpec struct { + denyAccess bool +} + type testClusterSpec struct { apps []appSpec + nodes map[string]nodeSpec cidrRange string customDNSZones []string leafClusters map[string]testClusterSpec @@ -332,11 +358,21 @@ type fakeClientApp struct { tlsCA tls.Certificate dialOpts *vnetv1.DialOptions - onNewConnectionCallCount atomic.Uint32 + userTLSCertMu sync.Mutex + userTLSCert tls.Certificate + userTLSCertExpires time.Time + + teleportHostCA ssh.Signer + teleportUserCA ssh.Signer + + onNewSSHSessionCallCount atomic.Uint32 + onNewAppConnectionCallCount atomic.Uint32 onInvalidLocalPortCallCount atomic.Uint32 // requestedRouteToApps indexed by public address. requestedRouteToApps map[string][]*proto.RouteToApp requestedRouteToAppsMu sync.RWMutex + + forwardedAgents *forwardedAgents } type fakeClientAppConfig struct { @@ -350,13 +386,36 @@ type fakeClientAppConfig struct { // able to run with any implementation of [ClientApplication] and little to no // other configuration. func newFakeClientApp(ctx context.Context, t *testing.T, cfg *fakeClientAppConfig) *fakeClientApp { + teleportHostCAKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + require.NoError(t, err) + teleportHostCA, err := ssh.NewSignerFromSigner(teleportHostCAKey) + require.NoError(t, err) + + teleportUserCAKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + require.NoError(t, err) + teleportUserCA, err := ssh.NewSignerFromSigner(teleportUserCAKey) + require.NoError(t, err) + + forwardedAgents := &forwardedAgents{} + tlsCA := newSelfSignedCA(t) - dialOpts := mustStartFakeWebProxy(ctx, t, tlsCA, cfg.clock, cfg.signatureAlgorithmSuite) + dialOpts := mustStartFakeWebProxy(ctx, t, fakeWebProxyConfig{ + tlsCA: tlsCA, + hostCA: teleportHostCA, + userCA: teleportUserCA, + clock: cfg.clock, + suite: cfg.signatureAlgorithmSuite, + forwardedAgents: forwardedAgents, + }) + return &fakeClientApp{ cfg: cfg, tlsCA: tlsCA, dialOpts: dialOpts, + teleportHostCA: teleportHostCA, + teleportUserCA: teleportUserCA, requestedRouteToApps: make(map[string][]*proto.RouteToApp), + forwardedAgents: forwardedAgents, } } @@ -380,6 +439,10 @@ func (p *fakeClientApp) GetCachedClient(ctx context.Context, profileName, leafCl clusterName: profileName, rootClusterName: profileName, }, + clusterSpec: &rootCluster, + teleportHostCA: p.teleportHostCA, + teleportUserCA: p.teleportUserCA, + clock: p.cfg.clock, }, nil } leafCluster, ok := rootCluster.leafClusters[leafClusterName] @@ -392,6 +455,10 @@ func (p *fakeClientApp) GetCachedClient(ctx context.Context, profileName, leafCl clusterName: leafClusterName, rootClusterName: profileName, }, + clusterSpec: &leafCluster, + teleportHostCA: p.teleportHostCA, + teleportUserCA: p.teleportUserCA, + clock: p.cfg.clock, }, nil } @@ -410,6 +477,29 @@ func (p *fakeClientApp) ReissueAppCert(ctx context.Context, appInfo *vnetv1.AppI cryptosuites.UserTLS) } +func (p *fakeClientApp) UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) { + p.userTLSCertMu.Lock() + defer p.userTLSCertMu.Unlock() + + now := p.cfg.clock.Now() + if now.Before(p.userTLSCertExpires) { + return p.userTLSCert, nil + } + expiry := now.Add(defaults.CertDuration) + userTLSCert, err := newClientCert(ctx, + p.tlsCA, + "testuser", + expiry, + p.cfg.signatureAlgorithmSuite, + cryptosuites.UserTLS) + if err != nil { + return tls.Certificate{}, trace.Wrap(err) + } + p.userTLSCert = userTLSCert + p.userTLSCertExpires = expiry + return userTLSCert, nil +} + func (p *fakeClientApp) RequestedRouteToApps(publicAddr string) []*proto.RouteToApp { p.requestedRouteToAppsMu.RLock() defer p.requestedRouteToAppsMu.RUnlock() @@ -476,8 +566,12 @@ func (p *fakeClientApp) GetVnetConfig(ctx context.Context, profileName, leafClus return cfg, nil } -func (p *fakeClientApp) OnNewConnection(_ context.Context, _ *vnetv1.AppKey) error { - p.onNewConnectionCallCount.Add(1) +func (p *fakeClientApp) OnNewSSHSession(ctx context.Context, profileName, rootClusterName string) { + p.onNewSSHSessionCallCount.Add(1) +} + +func (p *fakeClientApp) OnNewAppConnection(_ context.Context, _ *vnetv1.AppKey) error { + p.onNewAppConnectionCallCount.Add(1) return nil } @@ -485,8 +579,42 @@ func (p *fakeClientApp) OnInvalidLocalPort(_ context.Context, _ *vnetv1.AppInfo, p.onInvalidLocalPortCallCount.Add(1) } +func (p *fakeClientApp) dialSSHNode( + ctx context.Context, + target dialTarget, + tlsConfig *tls.Config, + dialOpts *vnetv1.DialOptions, + agent *sshAgent, +) (net.Conn, error) { + targetCluster, ok := p.cfg.clusters[target.profile] + if !ok { + return nil, trace.NotFound("no such profile") + } + if target.cluster != target.profile { + targetCluster, ok = targetCluster.leafClusters[target.cluster] + if !ok { + return nil, trace.NotFound("no such cluster") + } + } + if _, ok := targetCluster.nodes[target.hostname]; !ok { + return nil, trace.NotFound("no such host") + } + // In this test suite all SSH dials go to a single faked web proxy expecting + // the ALPN protocol alpncomm.ProtocolProxySSH for SSH dials. It doesn't + // run the real transport service that handles SSH agent forwarding over + // gRPC, but the test shares the forwarded agent with the fake proxy via + // the forwardedAgents collection. + p.forwardedAgents.add(agent) + tlsConfig.NextProtos = []string{string(alpncommon.ProtocolProxySSH)} + return tls.Dial("tcp", dialOpts.GetWebProxyAddr(), tlsConfig) +} + type fakeClusterClient struct { - authClient *fakeAuthClient + authClient *fakeAuthClient + clusterSpec *testClusterSpec + teleportHostCA ssh.Signer + teleportUserCA ssh.Signer + clock clockwork.Clock } func (c *fakeClusterClient) CurrentCluster() authclient.ClientI { @@ -501,6 +629,52 @@ func (c *fakeClusterClient) RootClusterName() string { return c.authClient.rootClusterName } +func (c *fakeClusterClient) SessionSSHKeyRing(ctx context.Context, user string, target client.NodeDetails) (*client.KeyRing, bool, error) { + targetHost, _, err := net.SplitHostPort(target.Addr) + if err != nil { + return nil, false, trace.Wrap(err) + } + nodeSpec, ok := c.clusterSpec.nodes[targetHost] + if !ok { + return nil, false, trace.NotFound("no such node") + } + if nodeSpec.denyAccess { + return nil, false, trace.AccessDenied("access denied to %s", targetHost) + } + userSSHKey, err := cryptosuites.GeneratePrivateKeyWithAlgorithm(cryptosuites.Ed25519) + if err != nil { + return nil, false, trace.Wrap(err) + } + userSSHSigner, err := ssh.NewSignerFromSigner(userSSHKey) + if err != nil { + return nil, false, trace.Wrap(err) + } + now := c.clock.Now() + cert := &ssh.Certificate{ + Key: userSSHSigner.PublicKey(), + Serial: 1, + CertType: ssh.UserCert, + ValidPrincipals: []string{user}, + ValidAfter: uint64(now.Add(-1 * time.Minute).Unix()), + ValidBefore: uint64(now.Add(time.Minute).Unix()), + } + if err := cert.SignCert(rand.Reader, c.teleportUserCA); err != nil { + return nil, false, trace.Wrap(err) + } + trustedCert := ssh.MarshalAuthorizedKey(c.teleportHostCA.PublicKey()) + k := &client.KeyRing{ + SSHPrivateKey: userSSHKey, + Cert: ssh.MarshalAuthorizedKey(cert), + TrustedCerts: []authclient.TrustedCerts{ + { + ClusterName: c.ClusterName(), + AuthorizedKeys: [][]byte{trustedCert}, + }, + }, + } + return k, false, nil +} + // fakeAuthClient is a fake auth client that answers GetResources requests with a static list of apps and // basic/faked predicate filtering. type fakeAuthClient struct { @@ -593,20 +767,16 @@ func TestDialFakeApp(t *testing.T) { clusters: map[string]testClusterSpec{ "root1.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.root1.example.com"}, - appSpec{publicAddr: "echo2.root1.example.com"}, - appSpec{publicAddr: "echo.myzone.example.com"}, - appSpec{publicAddr: "echo.nested.myzone.example.com"}, - appSpec{publicAddr: "not.in.a.custom.zone"}, - appSpec{ + {publicAddr: "echo1.root1.example.com"}, + {publicAddr: "echo2.root1.example.com"}, + {publicAddr: "echo.myzone.example.com"}, + {publicAddr: "echo.nested.myzone.example.com"}, + {publicAddr: "not.in.a.custom.zone"}, + { publicAddr: "multi-port.root1.example.com", tcpPorts: []*types.PortRange{ - &types.PortRange{ - Port: 1337, - }, - &types.PortRange{ - Port: 4242, - }, + {Port: 1337}, + {Port: 4242}, }, }, }, @@ -617,36 +787,32 @@ func TestDialFakeApp(t *testing.T) { leafClusters: map[string]testClusterSpec{ "leaf1.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.leaf1.example.com"}, - appSpec{ + {publicAddr: "echo1.leaf1.example.com"}, + { publicAddr: "multi-port.leaf1.example.com", tcpPorts: []*types.PortRange{ - &types.PortRange{ - Port: 1337, - }, - &types.PortRange{ - Port: 4242, - }, + {Port: 1337}, + {Port: 4242}, }, }, }, }, "leaf2.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.leaf2.example.com"}, + {publicAddr: "echo1.leaf2.example.com"}, }, }, }, }, "root2.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.root2.example.com"}, - appSpec{publicAddr: "echo2.root2.example.com"}, + {publicAddr: "echo1.root2.example.com"}, + {publicAddr: "echo2.root2.example.com"}, }, leafClusters: map[string]testClusterSpec{ "leaf3.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1.leaf3.example.com"}, + {publicAddr: "echo1.leaf3.example.com"}, }, }, }, @@ -876,9 +1042,9 @@ func testEchoConnection(t *testing.T, conn net.Conn) { } } -// TestOnNewConnection tests that the client applications OnNewConnection method +// TestOnNewAppConnection tests that the client applications OnNewAppConnection method // is called when a user connects to a valid TCP app. -func TestOnNewConnection(t *testing.T) { +func TestOnNewAppConnection(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -888,7 +1054,7 @@ func TestOnNewConnection(t *testing.T) { clusters: map[string]testClusterSpec{ "root1.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1"}, + {publicAddr: "echo1"}, }, cidrRange: "192.168.2.0/24", leafClusters: map[string]testClusterSpec{}, @@ -906,19 +1072,19 @@ func TestOnNewConnection(t *testing.T) { fakeClientApp: clientApp, }) - // Attempt to establish a connection to an invalid app and verify that OnNewConnection was not + // Attempt to establish a connection to an invalid app and verify that OnNewAppConnection was not // called. lookupCtx, lookupCtxCancel := context.WithTimeout(ctx, 200*time.Millisecond) defer lookupCtxCancel() _, err := p.lookupHost(lookupCtx, invalidAppName) require.Error(t, err, "Expected lookup of an invalid app to fail") - require.Equal(t, uint32(0), clientApp.onNewConnectionCallCount.Load()) + require.Equal(t, uint32(0), clientApp.onNewAppConnectionCallCount.Load()) - // Establish a connection to a valid app and verify that OnNewConnection was called. + // Establish a connection to a valid app and verify that OnNewAppConnection was called. conn, err := p.dialHost(ctx, validAppName, 80 /* bogus port */) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn.Close()) }) - require.Equal(t, uint32(1), clientApp.onNewConnectionCallCount.Load()) + require.Equal(t, uint32(1), clientApp.onNewAppConnectionCallCount.Load()) } // TestWithAlgorithmSuites tests basic VNet functionality with each signature @@ -949,15 +1115,15 @@ func testWithAlgorithmSuite(t *testing.T, suite types.SignatureAlgorithmSuite) { clusters: map[string]testClusterSpec{ "root.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1"}, - appSpec{publicAddr: "echo2"}, + {publicAddr: "echo1"}, + {publicAddr: "echo2"}, }, cidrRange: "192.168.2.0/24", leafClusters: map[string]testClusterSpec{ "leaf.example.com": { apps: []appSpec{ - appSpec{publicAddr: "echo1"}, - appSpec{publicAddr: "echo2"}, + {publicAddr: "echo1"}, + {publicAddr: "echo2"}, }, cidrRange: "192.168.2.0/24", }, @@ -997,9 +1163,9 @@ func testWithAlgorithmSuite(t *testing.T, suite types.SignatureAlgorithmSuite) { // TestSSH tests basic VNet SSH functionality. func TestSSH(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) + ctx := t.Context() clock := clockwork.NewRealClock() + homePath := t.TempDir() const ( root1CIDR = "192.168.1.0/24" @@ -1011,17 +1177,30 @@ func TestSSH(t *testing.T) { clusters: map[string]testClusterSpec{ "root1.example.com": { cidrRange: root1CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + "denynode": {denyAccess: true}, + }, leafClusters: map[string]testClusterSpec{ "leaf1.example.com": { cidrRange: leaf1CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + }, }, }, }, "root2.example.com": { cidrRange: root2CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + }, leafClusters: map[string]testClusterSpec{ "leaf2.example.com": { cidrRange: leaf2CIDR, + nodes: map[string]nodeSpec{ + "node": {}, + }, }, }, }, @@ -1033,56 +1212,264 @@ func TestSSH(t *testing.T) { p := newTestPack(t, ctx, testPackConfig{ fakeClientApp: clientApp, clock: clock, + homePath: homePath, + }) + + // Read the generated vnet_known_hosts file to get the trusted host CA key. + knownHosts, err := os.ReadFile(keypaths.VNetKnownHostsPath(homePath)) + require.NoError(t, err) + marker, hosts, hostCAPubKey, _, _, err := ssh.ParseKnownHosts(knownHosts) + require.NoError(t, err) + require.Equal(t, "cert-authority", marker) + require.Equal(t, []string{"*"}, hosts) + + // Read the generated id_vnet file to get the user key. + sshUserKey, err := os.ReadFile(keypaths.VNetClientSSHKeyPath(homePath)) + require.NoError(t, err) + sshUserSigner, err := ssh.ParsePrivateKey(sshUserKey) + require.NoError(t, err) + + // Create a fake user key to test failed authentication. + badUserKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.Ed25519) + require.NoError(t, err) + badUserSigner, err := ssh.NewSignerFromSigner(badUserKey) + require.NoError(t, err) + + // Check that each successful SSH session is reported to the client + // application, do this in a t.Cleanup func so that the check runs after + // all parallel subtests have completed. + var expectReportedSSHSessions atomic.Uint32 + t.Cleanup(func() { + assert.Equal(t, expectReportedSSHSessions.Load(), clientApp.onNewSSHSessionCallCount.Load(), + "OnNewSSHSession call count does not match the expected number of reported SSH sessions") }) for _, tc := range []struct { - addr string - expectCIDR string + dialAddr string + dialPort int + expectCIDR string + expectLookupToFail bool + expectDialToFail bool + sshUser string + sshUserSigner ssh.Signer + expectSSHHandshakeToFail bool + expectBannerMessages []string + expectSSHSessionReported bool }{ { - addr: "node.root1.example.com", - expectCIDR: root1CIDR, + // Connection to node in root cluster should work. + dialAddr: "node.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + expectSSHSessionReported: true, }, { - addr: "node.leaf1.example.com.root1.example.com", - expectCIDR: leaf1CIDR, + // Fully-qualified hostname should also work. + dialAddr: "node.root1.example.com.", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + expectSSHSessionReported: true, }, { - addr: "node.root2.example.com", - expectCIDR: root2CIDR, + // Dial should fail on non-standard SSH port. + dialAddr: "node.root1.example.com", + dialPort: 23, + expectCIDR: root1CIDR, + expectDialToFail: true, }, { - addr: "node.leaf2.example.com.root2.example.com", - expectCIDR: leaf2CIDR, + // SSH handshake should fail if using the wrong user key. + dialAddr: "node.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "baduser", + sshUserSigner: badUserSigner, + expectSSHHandshakeToFail: true, + }, + { + // Access to denied node should be denied with appropriate banner + // messages. + dialAddr: "denynode.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + expectBannerMessages: []string{ + "VNet: building SSH client config\n\tcalling SessionSSHConfig rpc\n\t\tgetting KeyRing for SSH session\n\taccess denied to denynode\n", + }, + expectSSHHandshakeToFail: true, + }, + { + // username "denyuser" is hardcoded to be denied. + dialAddr: "node.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + sshUser: "denyuser", + sshUserSigner: sshUserSigner, + expectBannerMessages: []string{ + "VNet: access denied to denyuser connecting to node\n", + }, + expectSSHHandshakeToFail: true, + // The session should be reported because VNet successfully got a + // Teleport user SSH cert for this session and made the SSH dial to + // the target, only then the target SSH server rejected the + // connection. + expectSSHSessionReported: true, + }, + { + // Connection to node in leaf cluster should work. + dialAddr: "node.leaf1.example.com", + dialPort: 22, + expectCIDR: leaf1CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + expectSSHSessionReported: true, + }, + { + // Connection to node in root cluster in alternate profile should + // work. + dialAddr: "node.root2.example.com", + dialPort: 22, + expectCIDR: root2CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + expectSSHSessionReported: true, + }, + { + // Connection to node in leaf cluster in alternate profile should + // work. + dialAddr: "node.leaf2.example.com", + dialPort: 22, + expectCIDR: leaf2CIDR, + sshUser: "testuser", + sshUserSigner: sshUserSigner, + expectSSHSessionReported: true, + }, + { + // DNS lookup should fail if the FQDN doesn't match any cluster. + dialAddr: "node.bogus.example.com.", + dialPort: 22, + expectLookupToFail: true, + }, + { + // If the FQDN matches a cluster but no node, the DNS lookup should + // succeed but the TCP dial should fail. + dialAddr: "bogus.root1.example.com", + dialPort: 22, + expectCIDR: root1CIDR, + expectDialToFail: true, }, } { - t.Run(tc.addr, func(t *testing.T) { + t.Run(fmt.Sprintf("%s@%s:%d", tc.sshUser, tc.dialAddr, tc.dialPort), func(t *testing.T) { t.Parallel() - // SSH access isn't fully implemented yet, at this point the DNS - // lookup for *. should resolve to an IP in the - // expected CIDR range for the cluster. - resolvedAddrs, err := p.lookupHost(ctx, tc.addr) + + if tc.expectSSHSessionReported { + expectReportedSSHSessions.Add(1) + } + + if tc.expectLookupToFail { + // In these cases the DNS lookup is expected to fail, just run the DNS lookup. + ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + _, err := p.lookupHost(ctx, tc.dialAddr) + require.Error(t, err) + return + } + + if tc.expectDialToFail { + // In these cases the DNS lookup should succeed but then the + // TCP dial should fail, do each separately to make sure we + // catch the error at the right step. + resolvedAddrs, err := p.lookupHost(ctx, tc.dialAddr) + require.NoError(t, err) + require.NotEmpty(t, resolvedAddrs) + + ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + _, err = p.dialHost(ctx, resolvedAddrs[0], tc.dialPort) + require.Error(t, err) + return + } + + conn, err := p.dialHost(ctx, tc.dialAddr, tc.dialPort) require.NoError(t, err) + defer conn.Close() + // The DNS query may have resolved to a v4 or v6 address, either + // way the 4-byte suffix should be a valid IPv4 address in the + // expected CIDR range. + resolvedIP := conn.RemoteAddr().(*net.TCPAddr).IP + resolvedIPSuffix := resolvedIP[len(resolvedIP)-4:] _, expectNet, err := net.ParseCIDR(tc.expectCIDR) require.NoError(t, err) + assert.True(t, expectNet.Contains(resolvedIPSuffix), + "expected CIDR range %s does not include resolved IP %s", expectNet, resolvedIPSuffix) + + // Initiate an SSH connection to the target. At this point the + // handshake should complete successfully as long as the right keys + // are used, but the SSH connection will be immediately closed by + // the server. + certChecker := ssh.CertChecker{ + IsHostAuthority: func(auth ssh.PublicKey, address string) bool { + return sshutils.KeysEqual(auth, hostCAPubKey) + }, + Clock: clock.Now, + } + var bannerMessages []string + clientConfig := &ssh.ClientConfig{ + User: tc.sshUser, + Auth: []ssh.AuthMethod{ssh.PublicKeys(tc.sshUserSigner)}, + HostKeyCallback: certChecker.CheckHostKey, + BannerCallback: func(msg string) error { + bannerMessages = append(bannerMessages, msg) + return nil + }, + } - for _, resolvedAddr := range resolvedAddrs { - resolvedIP := net.ParseIP(resolvedAddr) - // The query may have resolved to a v4 or v6 address or both, - // either way the 4-byte suffix should be a valid IPv4 address - // in the expected CIDR range. - resolvedIPSuffix := resolvedIP[len(resolvedIP)-4:] - assert.True(t, expectNet.Contains(resolvedIPSuffix), - "expected CIDR range %s does not include resolved IP %s", expectNet, resolvedIPSuffix) + sshConn, chans, reqs, err := ssh.NewClientConn(conn, fmt.Sprintf("%s:%d", tc.dialAddr, tc.dialPort), clientConfig) + assert.Equal(t, tc.expectBannerMessages, bannerMessages, "actual banner messages did not match the expected") + if tc.expectSSHHandshakeToFail { + assert.Error(t, err, "expected SSH handshake to fail") + return } + require.NoError(t, err) + defer sshConn.Close() - // Actually dialing the address should still fail until VNet SSH is - // implemented. - _, err = p.dialHost(ctx, tc.addr, 22) - require.Error(t, err) + testConnectionToSshEchoServer(t, sshConn, chans, reqs) }) } + + // Test that a fresh SSH host cert is used on each connection. + t.Run("ephemeral certs", func(t *testing.T) { + t.Parallel() + // Set up the SSH client config to capture the host certs it sees. + var checkedHostCerts []*ssh.Certificate + clientConfig := &ssh.ClientConfig{ + User: "testuser", + Auth: []ssh.AuthMethod{ssh.PublicKeys(sshUserSigner)}, + HostKeyCallback: func(addr string, remote net.Addr, key ssh.PublicKey) error { + checkedHostCerts = append(checkedHostCerts, key.(*ssh.Certificate)) + return nil + }, + } + const connections = 3 + for range connections { + conn, err := p.dialHost(ctx, "node.root1.example.com", 22) + require.NoError(t, err) + sshConn, _, _, err := ssh.NewClientConn(conn, "node.root1.example.com:22", clientConfig) + require.NoError(t, err) + sshConn.Close() + expectReportedSSHSessions.Add(1) + } + require.Len(t, checkedHostCerts, connections) + for i := range connections - 1 { + require.NotEqual(t, checkedHostCerts[i], checkedHostCerts[i+1]) + } + }) } func randomULAAddress() (tcpip.Address, error) { @@ -1256,27 +1643,34 @@ func newLeafCert( }, nil } +type fakeWebProxyConfig struct { + tlsCA tls.Certificate + hostCA ssh.Signer + userCA ssh.Signer + clock clockwork.Clock + suite types.SignatureAlgorithmSuite + forwardedAgents *forwardedAgents +} + func mustStartFakeWebProxy( ctx context.Context, t *testing.T, - ca tls.Certificate, - clock clockwork.Clock, - suite types.SignatureAlgorithmSuite, + cfg fakeWebProxyConfig, ) *vnetv1.DialOptions { t.Helper() roots := x509.NewCertPool() - caX509, err := x509.ParseCertificate(ca.Certificate[0]) + caX509, err := x509.ParseCertificate(cfg.tlsCA.Certificate[0]) require.NoError(t, err) roots.AddCert(caX509) const proxyCN = "testproxy" proxyCert, err := newServerCert( ctx, - ca, + cfg.tlsCA, proxyCN, - clock.Now().Add(365*24*time.Hour), - suite, + cfg.clock.Now().Add(365*24*time.Hour), + cfg.suite, cryptosuites.HostIdentity, ) require.NoError(t, err) @@ -1285,6 +1679,58 @@ func mustStartFakeWebProxy( Certificates: []tls.Certificate{proxyCert}, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: roots, + NextProtos: []string{ + string(alpncommon.ProtocolProxySSH), + string(alpncommon.ProtocolTCP), + }, + } + + tcpAppHandler := func(conn net.Conn) error { + // All fake TCP apps for the tests get routed to this handler which + // simply echos any input back on the tcp connection. + _, err := io.Copy(conn, conn) + return trace.Wrap(err, "io.Copy error in proxy echo server") + } + sshHandler := func(conn net.Conn) error { + // All fake SSH nodes for the tests get routed to this handler which + // terminates the incoming SSH connection with an ephemeral host cert. + // It trusts cfg.userCA for incoming SSH connections but always denies + // access for SSH users named "denyuser". After completing the handshake + // it runs a test "echo" SSH server implemented in + // runTestSSHServerInstance. + hostCert, err := newHostCert("node", cfg.hostCA) + if err != nil { + return trace.Wrap(err) + } + certChecker := ssh.CertChecker{ + IsUserAuthority: func(auth ssh.PublicKey) bool { + return sshutils.KeysEqual(auth, cfg.userCA.PublicKey()) + }, + Clock: cfg.clock.Now, + } + serverConfig := &ssh.ServerConfig{ + PublicKeyCallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + if conn.User() == "denyuser" { + return nil, trace.AccessDenied("access denied for denyuser") + } + // The test suite doesn't implement real "proxy recording mode" + // or SSH agent forwarding, but at least test here that the + // user key used to make this connection was forwarded so that + // the real SSH forwarding proxy would have access to it. + if !cfg.forwardedAgents.forwarded(pubKey) { + return nil, trace.Errorf("user SSH key was not forwarded") + } + return certChecker.Authenticate(conn, pubKey) + }, + } + serverConfig.AddHostKey(hostCert) + return trace.Wrap(runTestSSHServerInstance(conn, serverConfig)) + } + + // Run a simplified TLS router for the test. + protocolHandlers := map[alpncommon.Protocol]func(net.Conn) error{ + alpncommon.ProtocolTCP: tcpAppHandler, + alpncommon.ProtocolProxySSH: sshHandler, } listener, err := tls.Listen("tcp", "localhost:0", proxyTLSConfig) @@ -1325,14 +1771,19 @@ func mustStartFakeWebProxy( // It's important that the fake clock is never far behind the real clock, and that the // cert NotBefore is always at/before the real current time, so the TLS library is // satisfied. - if clock.Now().After(clientCerts[0].NotAfter) { - t.Logf("client cert is expired: currentTime=%s expiry=%s", clock.Now(), clientCerts[0].NotAfter) + if cfg.clock.Now().After(clientCerts[0].NotAfter) { + t.Logf("client cert is expired: currentTime=%s expiry=%s", cfg.clock.Now(), clientCerts[0].NotAfter) return } - _, err := io.Copy(conn, conn) - if err != nil && !utils.IsOKNetworkError(err) { - t.Logf("error in io.Copy for echo proxy server: %v", err) + protocol := tlsConn.ConnectionState().NegotiatedProtocol + handler, ok := protocolHandlers[alpncommon.Protocol(protocol)] + if !ok { + t.Logf("unhandled proxy protocol %s", protocol) + return + } + if err := handler(conn); err != nil { + t.Logf("error in protocol handler: %v", err) } }() } @@ -1354,3 +1805,35 @@ func mustStartFakeWebProxy( } return dialOpts } + +// forwardedAgents is a crude way of tracking all the forwarded SSH agents and +// checking if any of them forward a specific SSH key. +type forwardedAgents struct { + mu sync.Mutex + agents []*sshAgent +} + +func (a *forwardedAgents) add(agent *sshAgent) { + a.mu.Lock() + defer a.mu.Unlock() + a.agents = append(a.agents, agent) +} + +func (a *forwardedAgents) forwarded(key ssh.PublicKey) bool { + a.mu.Lock() + defer a.mu.Unlock() + blob := key.Marshal() + for _, agent := range a.agents { + agentKeys, err := agent.List() + if err != nil { + // sshAgent.List never returns an error. + continue + } + for _, agentKey := range agentKeys { + if slices.Equal(agentKey.Blob, blob) { + return true + } + } + } + return false +} diff --git a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto index 05acea0a5c6ca..0336e122b523a 100644 --- a/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto +++ b/proto/teleport/lib/teleterm/vnet/v1/vnet_service.proto @@ -30,16 +30,8 @@ service VnetService { // Stop stops VNet. rpc Stop(StopRequest) returns (StopResponse); - // ListDNSZones returns DNS zones of all root and leaf clusters with non-expired user certs. This - // includes the proxy service hostnames and custom DNS zones configured in vnet_config. - // - // This is fetched independently of what the Electron app thinks the current state of the cluster - // looks like, since the VNet admin process also fetches this data independently of the Electron - // app. - // - // Just like the admin process, it skips root and leaf clusters for which the vnet_config couldn't - // be fetched (due to e.g., a network error or an expired cert). - rpc ListDNSZones(ListDNSZonesRequest) returns (ListDNSZonesResponse); + // GetServiceInfo returns info about the running VNet service. + rpc GetServiceInfo(GetServiceInfoRequest) returns (GetServiceInfoResponse); // GetBackgroundItemStatus returns the status of the background item responsible for launching // VNet daemon. macOS only. tsh must be compiled with the vnetdaemon build tag. @@ -48,6 +40,10 @@ service VnetService { // RunDiagnostics runs a set of heuristics to determine if VNet actually works on the device, that // is receives network traffic and DNS queries. RunDiagnostics requires VNet to be started. rpc RunDiagnostics(RunDiagnosticsRequest) returns (RunDiagnosticsResponse); + + // AutoConfigureSSH automatically configures OpenSSH-compatible clients for + // connections to Teleport SSH hosts. + rpc AutoConfigureSSH(AutoConfigureSSHRequest) returns (AutoConfigureSSHResponse); } // Request for Start. @@ -62,13 +58,22 @@ message StopRequest {} // Response for Stop. message StopResponse {} -// Request for ListDNSZones. -message ListDNSZonesRequest {} - -// Response for ListDNSZones. -message ListDNSZonesResponse { - // dns_zones is a deduplicated list of DNS zones. - repeated string dns_zones = 1; +// Request for GetServiceInfo. +message GetServiceInfoRequest {} + +// GetServiceInfoResponse contains the status of the running VNet service. +message GetServiceInfoResponse { + // app_dns_zones is a deduplicated list of all DNS zones valid as DNS + // suffixes for connections to TCP apps. + repeated string app_dns_zones = 1; + // clusters is a list of cluster names valid as DNS suffixes for SSH hosts. + repeated string clusters = 2; + // ssh_configured is true if the user's SSH config file includes VNet's + // generated SSH config necessary for SSH access. + bool ssh_configured = 3; + // vnet_ssh_config_path is the path of VNet's generated OpenSSH-compatible + // config file. + string vnet_ssh_config_path = 4; } // Request for GetBackgroundItemStatus. @@ -99,3 +104,9 @@ message RunDiagnosticsRequest {} message RunDiagnosticsResponse { teleport.lib.vnet.diag.v1.Report report = 1; } + +// Request for AutoConfigureSSH. +message AutoConfigureSSHRequest {} + +// Response for AutoConfigureSSH. +message AutoConfigureSSHResponse {} 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/proto/teleport/lib/vnet/v1/client_application_service.proto b/proto/teleport/lib/vnet/v1/client_application_service.proto index e552e5bd0b277..b8193a53ff4fa 100644 --- a/proto/teleport/lib/vnet/v1/client_application_service.proto +++ b/proto/teleport/lib/vnet/v1/client_application_service.proto @@ -44,15 +44,27 @@ service ClientApplicationService { // SignForApp issues a signature with the private key associated with an x509 // certificate previously issued for a requested app. rpc SignForApp(SignForAppRequest) returns (SignForAppResponse); - // OnNewConnection gets called whenever a new connection is about to be + // OnNewAppConnection gets called whenever a new app connection is about to be // established through VNet for observability. - rpc OnNewConnection(OnNewConnectionRequest) returns (OnNewConnectionResponse); + rpc OnNewAppConnection(OnNewAppConnectionRequest) returns (OnNewAppConnectionResponse); // OnInvalidLocalPort gets called before VNet refuses to handle a connection // to a multi-port TCP app because the provided port does not match any of the // TCP ports in the app spec. rpc OnInvalidLocalPort(OnInvalidLocalPortRequest) returns (OnInvalidLocalPortResponse); // GetTargetOSConfiguration gets the target OS configuration. rpc GetTargetOSConfiguration(GetTargetOSConfigurationRequest) returns (GetTargetOSConfigurationResponse); + // UserTLSCert returns the user TLS certificate for a specific profile. + rpc UserTLSCert(UserTLSCertRequest) returns (UserTLSCertResponse); + // SignForUserTLS signs a digest with the user TLS private key. + rpc SignForUserTLS(SignForUserTLSRequest) returns (SignForUserTLSResponse); + // SessionSSHConfig returns the user SSH configuration for an SSH session. + rpc SessionSSHConfig(SessionSSHConfigRequest) returns (SessionSSHConfigResponse); + // SignForSSHSession signs a digest with the SSH private key associated with the + // session from a previous call to SessionSSHConfig. + rpc SignForSSHSession(SignForSSHSessionRequest) returns (SignForSSHSessionResponse); + // ExchangeSSHKeys sends VNet's SSH host CA key to the client application and + // returns the user public key. + rpc ExchangeSSHKeys(ExchangeSSHKeysRequest) returns (ExchangeSSHKeysResponse); } // AuthenticateProcessRequest is a request for AuthenticateProcess. @@ -131,6 +143,14 @@ message MatchedCluster { // WebProxyAddr is the web proxy address of the root cluster that matched the // query. string web_proxy_addr = 2; + // Profile is the profile the matched cluster was found in. + string profile = 3; + // RootCluster will always be set to the name of the root cluster that matched + // the query. + string root_cluster = 4; + // LeafCluster will be set only when the query matched a leaf cluster of + // RootCluster, or else it will be empty. + string leaf_cluster = 5; } // AppInfo holds all necessary info for making connections to VNet TCP apps. @@ -172,8 +192,10 @@ message DialOptions { string sni = 3; // InsecureSkipVerify turns off verification for x509 upstream ALPN proxy service certificate. bool insecure_skip_verify = 4; - // RootClusterCaCertPool overrides the x509 certificate pool used to verify the server. - // It is a PEM-encoded X509 certificate pool. + // RootClusterCaCertPool is the host CA TLS certificate pool for the root + // cluster. It is a PEM-encoded X509 certificate pool. It should be used when + // dialing the proxy and AlpnConnUpgradeRequired is true or when dialing the + // transport service. bytes root_cluster_ca_cert_pool = 5; } @@ -198,6 +220,8 @@ message ReissueAppCertResponse { // ReissueAppCert. The private key used for the signature will match the subject // public key of the issued x509 certificate. message SignForAppRequest { + reserved 3, 4, 5; + reserved "digest", "hash", "pss_salt_length"; // AppKey uniquely identifies a TCP app, it must match the key of an app from // a previous successful call to ReissueAppCert. AppKey app_key = 1; @@ -205,13 +229,19 @@ message SignForAppRequest { // TargetPort of a previous successful call to ReissueAppCert for an app // matching AppKey. uint32 target_port = 2; + // Sign holds signature request details. + SignRequest sign = 6; +} + +// SignRequest holds signature request details. +message SignRequest { // Digest is the bytes to sign. - bytes digest = 3; + bytes digest = 1; // Hash is the hash function used to compute digest. - Hash hash = 4; + Hash hash = 2; // PssSaltLength specifies the length of the salt added to the digest before a // signature. Only used and required for RSA PSS signatures. - optional int32 pss_salt_length = 5; + optional int32 pss_salt_length = 3; } // Hash specifies a cryptographic hash function. @@ -232,14 +262,14 @@ message SignForAppResponse { bytes signature = 1; } -// OnNewConnectionRequest is a request for OnNewConnection. -message OnNewConnectionRequest { +// OnNewAppConnectionRequest is a request for OnNewAppConnection. +message OnNewAppConnectionRequest { // AppKey identifies the app the connection is being made for. AppKey app_key = 1; } -// OnNewConnectionRequest is a response for OnNewConnection. -message OnNewConnectionResponse {} +// OnNewAppConnectionResponse is a response for OnNewAppConnection. +message OnNewAppConnectionResponse {} // OnInvalidLocalPortRequest is a request for OnInvalidLocalPort. message OnInvalidLocalPortRequest { @@ -278,3 +308,89 @@ message TargetOSConfiguration { // should also include the default range. repeated string ipv4_cidr_ranges = 2; } + +// UserTLSCertRequest is a request for UserTLSCert. +message UserTLSCertRequest { + // Profile is the profile to retrieve the certificate for. + string profile = 1; +} + +// UserTLSCertResponse is a response for UserTLSCert. +message UserTLSCertResponse { + // Cert is the user TLS certificate in X.509 ASN.1 DER format. + bytes cert = 1; + // DialOptions holds options that should be used when dialing the root cluster + // proxy. + DialOptions dial_options = 2; +} + +// SignForUserTLSRequest is a request for SignForUserTLS. +message SignForUserTLSRequest { + // Profile is the user profile to sign for. + string profile = 1; + // Sign holds signature request details. + SignRequest sign = 2; +} + +// SignForUserTLSResponse is a response for SignForUserTLS. +message SignForUserTLSResponse { + // Signature is the signature. + bytes signature = 1; +} + +// SessionSSHConfigRequest is a request for SessionSSHConfig. +message SessionSSHConfigRequest { + // Profile is the profile in which the SSH server is found. + string profile = 1; + // RootCluster is the cluster in which the SSH server is found. + string root_cluster = 2; + // LeafCluster is the leaf cluster in which the SSH server is found. + // If empty, the SSH server is in the root cluster. + string leaf_cluster = 3; + // Address is the address of the SSH server. + string address = 4; + // User is the SSH user the session is for. + string user = 5; +} + +// SessionSSHConfigResponse is a response for SessionSSHConfig. +message SessionSSHConfigResponse { + // SessionId is an opaque identifier for the session, it should be passed to + // SignForSSHSession to issue signatures with the private key associated with + // the session. + string session_id = 1; + // Cert is the session SSH certificate in SSH wire format. + bytes cert = 2; + // TrustedCas is a list of trusted SSH certificate authorities in SSH wire + // format. + repeated bytes trusted_cas = 3; +} + +// SignForSSHSessionRequest is a request for SignForSSHSession. +message SignForSSHSessionRequest { + // SessionId is an opaque identifier for the session returned from a previous + // call to SessionSSHConfig. + string session_id = 1; + // Sign holds signature request details. + SignRequest sign = 2; +} + +// SignForSSHSessionResponse is a response for SignForSSHSession. +message SignForSSHSessionResponse { + // Signature is the signature. + bytes signature = 1; +} + +// ExchangeSSHKeysRequest is a request to exchange SSH keys for VNet SSH. +message ExchangeSSHKeysRequest { + // HostPublicKey is the host key that should be trusted by clients connecting + // to VNet SSH addresses. It is encoded in OpenSSH wire format. + bytes host_public_key = 1; +} + +// ExchangeSSHKeysResponse is a response for ExchangeSSHKeys. +message ExchangeSSHKeysResponse { + // UserPublicKey is the user key that should be trusted by VNet for incoming + // connections from SSH clients. It is encoded in OpenSSH wire format. + bytes user_public_key = 1; +} diff --git a/rfd/0207-vnet-ssh.md b/rfd/0207-vnet-ssh.md index a78cf3c5fe8dc..66ebabf392673 100644 --- a/rfd/0207-vnet-ssh.md +++ b/rfd/0207-vnet-ssh.md @@ -7,9 +7,9 @@ state: draft ## Required Approvers -* Engineering: @espadolini && @rosstimothy -* Security: doyensec -* Product: @klizhentas +- Engineering: @espadolini && @rosstimothy +- Security: doyensec +- Product: @klizhentas ## What @@ -24,7 +24,7 @@ Advanced Teleport features like per-session MFA and hardware keys will be fully supported. Here's a demo with a proof-of-concept of the feature in action: -https://goteleport.zoom.us/clips/share/3xSvI4taSD6YgM1C0l12nQ + ## Why @@ -197,13 +197,11 @@ we already have an SSH forwarding server implemented in `lib/srv/forward/sshserv VNet SSH will support SSH connections to DNS names matching any of the following: - `.` -- `..` - `.` -- `..` These are the same patterns supported by our existing OpenSSH client integration, which will offer a seamless transition for users switching to VNet SSH -https://goteleport.com/docs/enroll-resources/server-access/openssh/openssh-agentless/#step-23-generate-an-ssh-client-configuration + If users prefer to use a shorter name to connect to SSH hosts, they can add a `CanonicalDomains` option to their `~/.ssh/config` file, e.g. @@ -236,7 +234,7 @@ If the user runs `tsh vnet` instead of Connect, we won't add anything to `vnet_ssh_config` will include the following: ``` -Host *.teleport.example.com *.leaf.teleport.example.com +Host *.root.example.com *.leaf.example.com IdentityFile "/Users/nic/Library/Application Support/tsh/id_vnet" GlobalKnownHostsFile "/Users/nic/Library/Application Support/tsh/vnet_known_hosts" UserKnownHostsFile /dev/null @@ -299,9 +297,8 @@ When the VNet process receives a DNS query this is how it will be resolved: 1. If it matches a web app the DNS request will be forwarded to upstream DNS servers (this is also as it implicitly works today, now we'll do it explicitly to skip assigning a VNet IP for web apps). -1. If the name does not match `*.` or - `*..` for any profile, the request will - be forwarded to upstream DNS servers. +1. If the name does not match `*.` or for any cluster, the + request will be forwarded to upstream DNS servers. 1. VNet will assign a free IP address to the FQDN, but at this point it will not know if this IP will later resolve to an SSH host or an app or neither. 1. VNet will return the IP address in an authoritative DNS answer. @@ -310,6 +307,7 @@ When the VNet process receives a DNS query this is how it will be resolved: When the VNet process receives a TCP connection at an address that has been assigned to an FQDN but does not yet know if there is a matching app or SSH host: + 1. An app lookup will run first in case an app has been added since the DNS query that assigned this IP. If the queried FQDN matches a TCP app then the IP will be permanently diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index a0648680bdfd0..22d664be06385 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -1420,6 +1420,7 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { workloadIdentityCmd := newWorkloadIdentityCommands(app) vnetCommand := newVnetCommand(app) + vnetSSHAutoConfigCommand := newVnetSSHAutoConfigCommand(app) vnetAdminSetupCommand := newVnetAdminSetupCommand(app) vnetDaemonCommand := newVnetDaemonCommand(app) vnetServiceCommand := newVnetServiceCommand(app) @@ -1839,6 +1840,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { err = workloadIdentityCmd.issueX509.run(&cf) case vnetCommand.FullCommand(): err = vnetCommand.run(&cf) + case vnetSSHAutoConfigCommand.FullCommand(): + err = vnetSSHAutoConfigCommand.run(&cf) case vnetAdminSetupCommand.FullCommand(): err = vnetAdminSetupCommand.run(&cf) case vnetDaemonCommand.FullCommand(): diff --git a/tool/tsh/common/vnet.go b/tool/tsh/common/vnet.go index 5d01ff4e67ae0..256e9cf3a832e 100644 --- a/tool/tsh/common/vnet.go +++ b/tool/tsh/common/vnet.go @@ -23,6 +23,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/lib/vnet" ) @@ -77,6 +78,22 @@ func (c *vnetCommand) run(cf *CLIConf) error { return trace.Wrap(vnetProcess.Wait()) } +type vnetSSHAutoConfigCommand struct { + *kingpin.CmdClause +} + +func newVnetSSHAutoConfigCommand(app *kingpin.Application) *vnetSSHAutoConfigCommand { + cmd := &vnetSSHAutoConfigCommand{ + CmdClause: app.Command("vnet-ssh-autoconfig", "Automatically include VNet's generated OpenSSH-compatible config file in ~/.ssh/config."), + } + return cmd +} + +func (c *vnetSSHAutoConfigCommand) run(cf *CLIConf) error { + err := vnet.AutoConfigureOpenSSH(cf.Context, profile.FullProfilePath(cf.HomePath)) + return trace.Wrap(err) +} + func newVnetAdminSetupCommand(app *kingpin.Application) vnetCLICommand { return newPlatformVnetAdminSetupCommand(app) } @@ -104,6 +121,7 @@ type vnetCommandNotSupported struct{} func (vnetCommandNotSupported) FullCommand() string { return "" } + func (vnetCommandNotSupported) run(*CLIConf) error { panic("vnetCommandNotSupported.run should never be called, this is a bug") } diff --git a/tool/tsh/common/vnet_client_application.go b/tool/tsh/common/vnet_client_application.go index b9ee5c1ed4103..a4e8c9a87241f 100644 --- a/tool/tsh/common/vnet_client_application.go +++ b/tool/tsh/common/vnet_client_application.go @@ -100,6 +100,22 @@ func (p *vnetClientApplication) ReissueAppCert(ctx context.Context, appInfo *vne return cert, trace.Wrap(err) } +// UserTLSCert returns the user TLS certificate for the given profile. +func (p *vnetClientApplication) UserTLSCert(ctx context.Context, profileName string) (tls.Certificate, error) { + profile, err := p.clientStore.GetProfile(profileName) + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "loading user profile %s", profileName) + } + tlsConfig, err := profile.TLSConfig() + if err != nil { + return tls.Certificate{}, trace.Wrap(err, "loading TLS config for profile") + } + if len(tlsConfig.Certificates) == 0 { + return tls.Certificate{}, trace.Errorf("user tls config has no certificates") + } + return tlsConfig.Certificates[0], nil +} + // GetDialOptions returns ALPN dial options for the profile. func (p *vnetClientApplication) GetDialOptions(ctx context.Context, profileName string) (*vnetv1.DialOptions, error) { profile, err := p.clientStore.GetProfile(profileName) @@ -111,18 +127,21 @@ func (p *vnetClientApplication) GetDialOptions(ctx context.Context, profileName AlpnConnUpgradeRequired: profile.TLSRoutingConnUpgradeRequired, InsecureSkipVerify: p.cf.InsecureSkipVerify, } - if dialOpts.AlpnConnUpgradeRequired { - dialOpts.RootClusterCaCertPool, err = p.getRootClusterCACertPoolPEM(ctx, profileName) - if err != nil { - return nil, trace.Wrap(err) - } + dialOpts.RootClusterCaCertPool, err = p.getRootClusterCACertPoolPEM(ctx, profileName) + if err != nil { + return nil, trace.Wrap(err) } return dialOpts, nil } -// OnNewConnection gets called before each VNet connection. It's a noop as tsh doesn't need to do +// OnNewSSHSession gets called before each VNet SSH connection. It's a noop as +// tsh doesn't need to do anything extra here. +func (p *vnetClientApplication) OnNewSSHSession(ctx context.Context, profileName, rootClusterName string) { +} + +// OnNewAppConnection gets called before each VNet app connection. It's a noop as tsh doesn't need to do // anything extra here. -func (p *vnetClientApplication) OnNewConnection(_ context.Context, _ *vnetv1.AppKey) error { +func (p *vnetClientApplication) OnNewAppConnection(_ context.Context, _ *vnetv1.AppKey) error { return nil } diff --git a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx index 7c29494ce00d1..8a46e5a35b377 100644 --- a/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx +++ b/web/packages/shared/components/MenuLoginWithActionMenu/MenuLoginWithActionMenu.tsx @@ -54,7 +54,7 @@ export const MenuLoginWithActionMenu = ({ inputType, }: { /** Button text for main menu button. */ - buttonText: string; + buttonText?: string; /** * Handles select or click in main menu items. * If isExternalUrl item returned by getLoginItems is true, a button with tag is rendered 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/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts index a53701d1bf292..9f2a80fae0196 100644 --- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -118,9 +118,15 @@ export class MockTshClient implements TshdClient { export class MockVnetClient implements VnetClient { start = () => new MockedUnaryCall({}); stop = () => new MockedUnaryCall({}); - listDNSZones = () => new MockedUnaryCall({ dnsZones: [] }); + getServiceInfo = () => + new MockedUnaryCall({ + appDnsZones: [], + clusters: [], + sshConfigured: false, + vnetSshConfigPath: + '/Users/user/Library/Application Support/Teleport Connect/tsh/vnet_ssh_config', + }); getBackgroundItemStatus = () => new MockedUnaryCall({ status: 0 }); - runDiagnostics() { return new MockedUnaryCall({ report: { @@ -129,4 +135,5 @@ export class MockVnetClient implements VnetClient { }, }); } + autoConfigureSSH = () => new MockedUnaryCall({}); } 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/DocumentCluster/ActionButtons.tsx b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx index 2e34a07ba7c2b..8a9a2759ed2d2 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/ActionButtons.tsx @@ -35,6 +35,7 @@ import { MenuLogin, MenuLoginProps, } from 'shared/components/MenuLogin'; +import { MenuLoginWithActionMenu } from 'shared/components/MenuLoginWithActionMenu'; import { formatPortRange, @@ -57,12 +58,25 @@ import { import { IAppContext } from 'teleterm/ui/types'; import { DatabaseUri, routing } from 'teleterm/ui/uri'; import { retryWithRelogin } from 'teleterm/ui/utils'; -import { useVnetAppLauncher, useVnetContext } from 'teleterm/ui/Vnet'; +import { useVnetContext, useVnetLauncher } from 'teleterm/ui/Vnet'; export function ConnectServerActionButton(props: { server: Server; }): React.JSX.Element { const ctx = useAppContext(); + const { isSupported: isVnetSupported } = useVnetContext(); + const { launchVnet } = useVnetLauncher(); + + function connectWithVnet(): void { + const hostname = props.server.hostname; + const cluster = ctx.clustersService.findClusterByResource(props.server.uri); + const clusterName = cluster?.name || ''; + const addr = `${hostname}.${clusterName}`; + launchVnet({ + addrToCopy: addr, + resourceUri: props.server.uri, + }); + } function getSshLogins(): string[] { const cluster = ctx.clustersService.findClusterByResource(props.server.uri); @@ -80,21 +94,28 @@ export function ConnectServerActionButton(props: { ); } + const commonProps = { + inputType: MenuInputType.FILTER, + textTransform: 'none', + getLoginItems: () => getSshLogins().map(login => ({ login, url: '' })), + onSelect: (e, login) => connect(login), + transformOrigin: { + vertical: 'top', + horizontal: 'right', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'right', + }, + }; + + if (!isVnetSupported) { + return ; + } return ( - getSshLogins().map(login => ({ login, url: '' }))} - onSelect={(e, login) => connect(login)} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - anchorOrigin={{ - vertical: 'bottom', - horizontal: 'right', - }} - /> + + Connect with VNet + ); } @@ -121,13 +142,13 @@ export function ConnectKubeActionButton(props: { export function ConnectAppActionButton(props: { app: App }): React.JSX.Element { const appContext = useAppContext(); const { isSupported: isVnetSupported } = useVnetContext(); - const { launchVnet } = useVnetAppLauncher(); + const { launchVnet } = useVnetLauncher(); function connectWithVnet(targetPort?: number): void { void launchVnet({ addrToCopy: appToAddrToCopy(props.app, targetPort), resourceUri: props.app.uri, - isMultiPort: !!props.app.tcpPorts.length, + isMultiPortApp: !!props.app.tcpPorts.length, }); } diff --git a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx index e7c7d8245c569..2da3ec26a9d90 100644 --- a/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentCluster/UnifiedResources.test.tsx @@ -52,7 +52,9 @@ import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvi import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; import { MockWorkspaceContextProvider } from 'teleterm/ui/fixtures/MockWorkspaceContextProvider'; import { makeDocumentCluster } from 'teleterm/ui/services/workspacesService/documentsService/testHelpers'; +import { ConnectionsContextProvider } from 'teleterm/ui/TopBar/Connections/connectionsContext'; import * as uri from 'teleterm/ui/uri'; +import { VnetContextProvider } from 'teleterm/ui/Vnet'; const mio = mockIntersectionObserver(); @@ -318,12 +320,16 @@ test.each([ - - + + + + + + @@ -398,7 +404,9 @@ it('passes props with stable identity to ', async () => { - {children} + + {children} + diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index e5fd1b58b97ef..77cc1037883a1 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -27,6 +27,7 @@ import { Dialog } from 'teleterm/ui/services/modals'; import { ClusterLogout } from '../ClusterLogout'; import { ResourceSearchErrors } from '../Search/ResourceSearchErrors'; import { assertUnreachable } from '../utils'; +import { ConfigureSSHClients } from '../Vnet/ConfigureSSHClients'; import { ChangeAccessRequestKind } from './modals/ChangeAccessRequestKind'; import { AskPin, ChangePin, OverwriteSlot, Touch } from './modals/HardwareKeys'; import { ReAuthenticate } from './modals/ReAuthenticate'; @@ -281,6 +282,17 @@ function renderDialog({ /> ); } + case 'configure-ssh-clients': { + return ( +