# -*- coding: utf-8 -*- # # Copyright 2021 Google LLC. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Implements the command for SSHing into an instance.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import argparse import datetime import sys from googlecloudsdk.api_lib.compute import base_classes from googlecloudsdk.calliope import base from googlecloudsdk.command_lib.compute import completers from googlecloudsdk.command_lib.compute import flags from googlecloudsdk.command_lib.compute import iap_tunnel from googlecloudsdk.command_lib.compute import network_troubleshooter from googlecloudsdk.command_lib.compute import scope as compute_scope from googlecloudsdk.command_lib.compute import ssh_utils from googlecloudsdk.command_lib.compute import user_permission_troubleshooter from googlecloudsdk.command_lib.compute import vm_boot_troubleshooter from googlecloudsdk.command_lib.compute import vm_status_troubleshooter from googlecloudsdk.command_lib.compute import vpc_troubleshooter from googlecloudsdk.command_lib.compute.instances import flags as instance_flags from googlecloudsdk.command_lib.util.ssh import containers from googlecloudsdk.command_lib.util.ssh import ssh from googlecloudsdk.core import log from googlecloudsdk.core import properties from googlecloudsdk.core.util import retry RECOMMEND_MESSAGE = """ Recommendation: To check for possible causes of SSH connectivity issues and get recommendations, rerun the ssh command with the --troubleshoot option. {0} """ _RECOMMEND_IAP = """ Or, to investigate an IAP tunneling issue: {0} """ ReleaseTrack = { 'alpha': 'alpha', 'beta': 'beta', } TROUBLESHOOT_HEADER = """ Starting ssh troubleshooting for instance {0} in zone {1} Start time: {2} """ def AddCommandArg(parser): parser.add_argument( '--command', help="""\ A command to run on the virtual machine. Runs the command on the target instance and then exits. """) def AddSSHArgs(parser): """Additional flags and positional args to be passed to *ssh(1)*.""" parser.add_argument( '--ssh-flag', action='append', help="""\ Additional flags to be passed to *ssh(1)*. It is recommended that flags be passed using an assignment operator and quotes. Example: $ {command} example-instance --zone=us-central1-a --ssh-flag="-vvv" --ssh-flag="-L 80:localhost:80" This flag will replace occurences of ``%USER%'', ``%INSTANCE%'', and ``%INTERNAL%'' with their dereferenced values. For example, passing ``80:%INSTANCE%:80'' into the flag is equivalent to passing ``80:162.222.181.197:80'' to *ssh(1)* if the external IP address of 'example-instance' is 162.222.181.197. If connecting to the instance's external IP, then ``%INSTANCE%'' is replaced with that, otherwise it is replaced with the internal IP. ``%INTERNAL%'' is always replaced with the internal interface of the instance. """) parser.add_argument( 'user_host', completer=completers.InstancesCompleter, metavar='[USER@]INSTANCE', help="""\ Specifies the instance to SSH into. ``USER'' specifies the username with which to SSH. If omitted, the user login name is used. If using OS Login, USER will be replaced by the OS Login user. ``INSTANCE'' specifies the name of the virtual machine instance to SSH into. """) parser.add_argument( 'ssh_args', nargs=argparse.REMAINDER, help="""\ Flags and positionals passed to the underlying ssh implementation. """, example="""\ $ {command} example-instance --zone=us-central1-a -- -vvv -L 80:%INSTANCE%:80 """) def AddContainerArg(parser): parser.add_argument( '--container', help="""\ The name or ID of a container inside of the virtual machine instance to connect to. This only applies to virtual machines that are using a Google Container-Optimized virtual machine image. For more information, see [](https://cloud.google.com/compute/docs/containers). """) def AddInternalIPArg(group): group.add_argument( '--internal-ip', default=False, action='store_true', help="""\ Connect to instances using their internal IP addresses rather than their external IP addresses. Use this to connect from one instance to another on the same VPC network, over a VPN connection, or between two peered VPC networks. For this connection to work, you must configure your networks and firewall to allow SSH connections to the internal IP address of the instance to which you want to connect. To learn how to use this flag, see [](https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances). """) def TroubleshootHelp(): """Generate the help text for troubleshot argument.""" help_text = """\ If you can't connect to a virtual machine (VM) instance using SSH, you can investigate the problem using the `--troubleshoot` flag: $ {command} VM_NAME --zone=ZONE --troubleshoot""" if base_classes.SupportIAP(): help_text += """ [--tunnel-through-iap]""" help_text += """ The troubleshoot flag runs tests and returns recommendations for the following types of issues: - VM status""" if base_classes.SupportNetworkConnectivityTest(): help_text += """ - Network connectivity""" help_text += """ - User permissions - Virtual Private Cloud (VPC) settings - VM boot""" if base_classes.SupportIAP(): help_text += """ If you specify the `--tunnel-through-iap` flag, the tool also checks IAP port forwarding.""" return help_text + """ """ def RecommendMessage( release_track: str, project_name: str, zone_name: str, instance_name: str, ssh_key_file: str, force_key_file_overwrite: bool, ) -> str: """Generate the recommend message for troubleshot.""" command = 'gcloud {0}compute ssh {1} --project={2} --zone={3} '.format( release_track, instance_name, project_name, zone_name ) if ssh_key_file: command += '--ssh-key-file={0} '.format(ssh_key_file) if force_key_file_overwrite: command += '--force-key-file-overwrite ' command += '--troubleshoot' recommend_message = RECOMMEND_MESSAGE.format(command) if not base_classes.SupportIAP(): return recommend_message recommend_iap = _RECOMMEND_IAP.format(command + ' --tunnel-through-iap') return recommend_message + recommend_iap def AddTroubleshootArg(parser): parser.add_argument( '--troubleshoot', action='store_true', help=TroubleshootHelp()) # pylint: disable=unused-argument def RunTroubleshooting(project=None, zone=None, instance=None, iap_tunnel_args=None): """Run each category of troubleshoot action.""" if base_classes.SupportNetworkConnectivityTest(): network_args = { 'project': project, 'zone': zone, 'instance': instance, } network = network_troubleshooter.NetworkTroubleshooter(**network_args) network() user_permission_args = { 'project': project, 'zone': zone, 'instance': instance, 'iap_tunnel_args': iap_tunnel_args, } user_permission = user_permission_troubleshooter.UserPermissionTroubleshooter( **user_permission_args) user_permission() vpc_args = { 'project': project, 'zone': zone, 'instance': instance, 'iap_tunnel_args': iap_tunnel_args, } vpc = vpc_troubleshooter.VPCTroubleshooter(**vpc_args) vpc() vm_status_args = { 'project': project, 'zone': zone, 'instance': instance, } vm_status = vm_status_troubleshooter.VMStatusTroubleshooter(**vm_status_args) vm_status() vm_boot_args = { 'project': project, 'zone': zone, 'instance': instance, } vm_boot = vm_boot_troubleshooter.VMBootTroubleshooter(**vm_boot_args) vm_boot() @base.UniverseCompatible @base.ReleaseTracks(base.ReleaseTrack.GA) class Ssh(base.Command): """SSH into a virtual machine instance.""" category = base.TOOLS_CATEGORY enable_security_keys = False @classmethod def Args(cls, parser): """Set up arguments for this command. Args: parser: An argparse.ArgumentParser. """ ssh_utils.BaseSSHCLIHelper.Args(parser) AddCommandArg(parser) AddSSHArgs(parser) AddContainerArg(parser) AddTroubleshootArg(parser) if base_classes.SupportIAP(): iap_tunnel.AddHostBasedTunnelArgs(parser) flags.AddZoneFlag( parser, resource_type='instance', operation_type='connect to') ssh_utils.AddVerifyInternalIpArg(parser) routing_group = parser.add_mutually_exclusive_group() AddInternalIPArg(routing_group) if base_classes.SupportIAP(): iap_tunnel.AddSshTunnelArgs(parser, routing_group) def Run(self, args): """See ssh_utils.BaseSSHCLICommand.Run.""" on_prem = ( args.IsKnownAndSpecified('network') and args.IsKnownAndSpecified('region')) if on_prem: args.plain = True # These two lines are needed to ensure reauth is performed as needed, even # for on-prem, which doesn't use the resulting variables. holder = base_classes.ComputeApiHolder(self.ReleaseTrack()) client = holder.client ssh_helper = ssh_utils.BaseSSHCLIHelper() ssh_helper.Run(args) iap_tunnel_args = None if on_prem: user, ip = ssh_utils.GetUserAndInstance(args.user_host) remote = ssh.Remote(ip, user) if base_classes.SupportIAP(): iap_tunnel_args = iap_tunnel.CreateOnPremSshTunnelArgs( args, self.ReleaseTrack(), ip ) instance_address = ip internal_address = ip oslogin_state = ssh.OsloginState() else: user, instance_name = ssh_utils.GetUserAndInstance(args.user_host) instance_ref = instance_flags.SSH_INSTANCE_RESOLVER.ResolveResources( [instance_name], compute_scope.ScopeEnum.ZONE, args.zone, holder.resources, scope_lister=instance_flags.GetInstanceZoneScopeLister(client))[0] instance = ssh_helper.GetInstance(client, instance_ref) project = ssh_helper.GetProject(client, instance_ref.project) if args.strict_host_key_checking == 'no': host_keys = None else: host_keys = ssh_helper.GetHostKeysFromGuestAttributes( client, instance_ref, instance, project) if base_classes.SupportIAP(): iap_tunnel_args = iap_tunnel.CreateSshTunnelArgs( args, self.ReleaseTrack(), instance_ref, ssh_utils.GetExternalInterface(instance, no_raise=True), ) internal_address = ssh_utils.GetInternalIPAddress(instance) if args.troubleshoot: log.status.Print(TROUBLESHOOT_HEADER.format( instance_ref, args.zone or instance_ref.zone, datetime.datetime.now() )) RunTroubleshooting(project, args.zone or instance_ref.zone, instance, iap_tunnel_args) return if not host_keys and host_keys is not None: log.debug('Unable to retrieve host keys from instance metadata. ' 'Continuing.') expiration, expiration_micros = ssh_utils.GetSSHKeyExpirationFromArgs( args) if args.plain: oslogin_state = ssh.OsloginState(oslogin_enabled=False) else: public_key = ssh_helper.keys.GetPublicKey().ToEntry( include_comment=True) # If there is an '@' symbol in the user_host arg, the user is requesting # to connect as a specific user. This may get overridden by OS Login. username_requested = '@' in args.user_host oslogin_state = ssh.GetOsloginState( instance, project, user, public_key, expiration_micros, self.ReleaseTrack(), username_requested=username_requested, messages=holder.client.messages) user = oslogin_state.user log.debug(oslogin_state) if iap_tunnel_args: # IAP Tunnel only uses instance_address for the purpose of --ssh-flag # substitution. In this case, dest_addr doesn't do much, it just matches # against entries in the user's ssh_config file. It's best to use # something unique to avoid false positive matches, thus we use # HostKeyAlias. instance_address = internal_address dest_addr = ssh_utils.HostKeyAlias(instance) elif args.internal_ip: instance_address = internal_address dest_addr = instance_address else: instance_address = ssh_utils.GetExternalIPAddress(instance) dest_addr = instance_address remote = ssh.Remote(dest_addr, user) # identity_file_list will be None if security keys are not enabled. identity_file_list = ssh.WriteSecurityKeys(oslogin_state) identity_file = None cert_file = None options = None if not args.plain: if not identity_file_list: identity_file = ssh_helper.keys.key_file options = ssh_helper.GetConfig( ssh_utils.HostKeyAlias(instance), args.strict_host_key_checking, host_keys_to_add=host_keys, ) if oslogin_state.third_party_user or oslogin_state.require_certificates: cert_file = ssh.CertFileFromComputeInstance( project.name, instance_ref.zone, instance.id ) extra_flags = ssh.ParseAndSubstituteSSHFlags( args, remote, instance_address, internal_address ) remainder = [] if args.ssh_args: remainder.extend(args.ssh_args) # Transform args.command into arg list or None if no command command_list = args.command.split(' ') if args.command else None tty = containers.GetTty(args.container, command_list) remote_command = containers.GetRemoteCommand(args.container, command_list) # Do not include default port since that will prevent users from # specifying a custom port (b/121998342). cmd = ssh.SSHCommand( remote=remote, identity_file=identity_file, cert_file=cert_file, options=options, extra_flags=extra_flags, remote_command=remote_command, tty=tty, iap_tunnel_args=iap_tunnel_args, remainder=remainder, identity_list=identity_file_list, ) if args.dry_run: # Add quotes around any arguments that contain spaces. log.out.Print(' '.join('"{0}"'.format(arg) if ' ' in arg else arg for arg in cmd.Build(ssh_helper.env))) return # Raise errors if instance requires a security key but the local # envionment doesn't support them. This is after the 'dry-run' because # we want to allow printing the command regardless. if self.enable_security_keys: ssh_utils.ConfirmSecurityKeyStatus(oslogin_state) # TODO(b/35355795): Don't force connect in general. # At a minimum, avoid injecting 'y' if PuTTY will prompt for a password / # 2FA authentication method (since we know that won't work), or if the user # has disabled the property. prompt_for_password = ( args.plain and not any(f == '-i' or f.startswith('-i=') for f in extra_flags)) putty_force_connect = ( not prompt_for_password and not oslogin_state.oslogin_2fa_enabled and properties.VALUES.ssh.putty_force_connect.GetBool()) if args.plain or oslogin_state.oslogin_enabled: keys_newly_added = False else: keys_newly_added = ssh_helper.EnsureSSHKeyExists( client, remote.user, instance, project, expiration=expiration) if keys_newly_added: poller = ssh_utils.CreateSSHPoller(remote, identity_file, options, iap_tunnel_args, extra_flags=extra_flags) log.status.Print('Waiting for SSH key to propagate.') try: poller.Poll( ssh_helper.env, putty_force_connect=putty_force_connect) except retry.WaitException: raise ssh_utils.NetworkError() if args.internal_ip and not on_prem: ssh_helper.PreliminarilyVerifyInstance(instance.id, remote, identity_file, options, putty_force_connect) # Errors from SSH itself result in an ssh.CommandError being raised try: return_code = cmd.Run( ssh_helper.env, putty_force_connect=putty_force_connect) except ssh.CommandError as e: if not on_prem: log.status.Print(self.createRecommendMessage(args, instance_name, instance_ref, project)) raise e if cert_file: ssh.DeleteCertificateFile(project.name, instance_ref.zone, instance.id) if return_code: # This is the return code of the remote command. Problems with SSH itself # will result in ssh.CommandError being raised above. sys.exit(return_code) def createRecommendMessage(self, args, instance_name, instance_ref, project): release_track = ReleaseTrack.get(str(self.ReleaseTrack()).lower()) release_track = release_track + ' ' if release_track else '' zone_name = args.zone or instance_ref.zone project_name = project.name return RecommendMessage( release_track, project_name, zone_name, instance_name, args.ssh_key_file, args.force_key_file_overwrite, ) @base.UniverseCompatible @base.ReleaseTracks(base.ReleaseTrack.ALPHA, base.ReleaseTrack.BETA) class SshAlphaBeta(Ssh): """SSH into a virtual machine instance (Beta).""" enable_security_keys = True def _DetailedHelp(): """Construct help text based on the command release track.""" detailed_help = { 'brief': 'SSH into a virtual machine instance', 'DESCRIPTION': """\ *{command}* is a thin wrapper around the *ssh(1)* command that takes care of authentication and the translation of the instance name into an IP address. To use SSH to connect to a Windows VM, refer to this guide: https://cloud.google.com/compute/docs/connect/windows-ssh The default network comes preconfigured to allow ssh access to all VMs. If the default network was edited, or if not using the default network, you may need to explicitly enable ssh access by adding a firewall-rule: $ gcloud compute firewall-rules create --network=NETWORK default-allow-ssh --allow=tcp:22 *{command}* ensures that the user's public SSH key is present in the project's metadata. If the user does not have a public SSH key, one is generated using *ssh-keygen(1)* (if the `--quiet` flag is given, the generated key will have an empty passphrase). If the `--region` and `--network` flags are provided, then `--plain` and `--tunnel-through-iap` are implied and an IP address must be supplied instead of an instance name. This is most useful for connecting to on-prem resources. """, 'EXAMPLES': """\ To SSH into 'example-instance' in zone ``us-central1-a'', run: $ {command} example-instance --zone=us-central1-a You can also run a command on the virtual machine. For example, to get a snapshot of the guest's process tree, run: $ {command} example-instance --zone=us-central1-a --command="ps -ejH" When running a command on a virtual machine, a non-interactive shell will typically be used. (See the INVOCATION section of https://linux.die.net/man/1/bash for an overview.) That behavior can be overridden by specifying a shell to run the command, and passing the `-t` flag to SSH to allocate a pseudo-TTY. For example, to see the environment variables set during an interactive session, run: $ {command} example-instance --zone=us-central1-a --command="bash -i -c env" -- -t If you are using the Google Container-Optimized virtual machine image, you can SSH into one of your containers with: $ {command} example-instance --zone=us-central1-a --container=CONTAINER You can limit the allowed time to ssh. For example, to allow a key to be used through 2019: $ {command} example-instance --zone=us-central1-a --ssh-key-expiration="2020-01-01T00:00:00:00Z" Or alternatively, allow access for the next two minutes: $ {command} example-instance --zone=us-central1-a --ssh-key-expire-after=2m To use the IP address of your remote VM (eg, for on-prem), you must also specify the `--region` and `--network` flags: $ {command} 10.1.2.3 --region=us-central1 --network=default """, } return detailed_help SshAlphaBeta.detailed_help = _DetailedHelp() Ssh.detailed_help = _DetailedHelp()