1124 lines
41 KiB
Python
1124 lines
41 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2016 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.
|
|
"""Utilities for subcommands that need to SSH into virtual machine guests.
|
|
|
|
This module provides the following things:
|
|
Errors used by various SSH-based commands.
|
|
Various helper functions.
|
|
BaseSSHHelper: The primary purpose of the BaseSSHHelper class is to
|
|
get the instance and project information, determine whether the user's
|
|
SSH public key is in the metadata, determine if the SSH public key
|
|
needs to be added to the instance/project metadata, and then add the
|
|
key if necessary.
|
|
BaseSSHCLIHelper: An additional wrapper around BaseSSHHelper that adds
|
|
common flags needed by the various SSH-based commands.
|
|
"""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import unicode_literals
|
|
|
|
import base64
|
|
import binascii
|
|
import collections
|
|
import datetime
|
|
import json
|
|
import os
|
|
|
|
from googlecloudsdk.api_lib.compute import constants
|
|
from googlecloudsdk.api_lib.compute import metadata_utils
|
|
from googlecloudsdk.api_lib.compute import path_simplifier
|
|
from googlecloudsdk.api_lib.compute import utils
|
|
from googlecloudsdk.calliope import actions
|
|
from googlecloudsdk.calliope import arg_parsers
|
|
from googlecloudsdk.calliope import exceptions
|
|
from googlecloudsdk.command_lib.util.ssh import ssh
|
|
from googlecloudsdk.core import exceptions as core_exceptions
|
|
from googlecloudsdk.core import log
|
|
from googlecloudsdk.core import properties
|
|
from googlecloudsdk.core.console import console_io
|
|
from googlecloudsdk.core.console import progress_tracker
|
|
from googlecloudsdk.core.util import encoding
|
|
from googlecloudsdk.core.util import times
|
|
from googlecloudsdk.core.util.files import FileReader
|
|
from googlecloudsdk.core.util.files import FileWriter
|
|
import six
|
|
|
|
# The maximum amount of time to wait for a newly-added SSH key to
|
|
# propagate before giving up.
|
|
SSH_KEY_PROPAGATION_TIMEOUT_MS = 60 * 1000
|
|
|
|
_TROUBLESHOOTING_URL = (
|
|
'https://cloud.google.com/compute/docs/troubleshooting/troubleshooting-ssh')
|
|
|
|
GUEST_ATTRIBUTES_METADATA_KEY = 'enable-guest-attributes'
|
|
SUPPORTED_HOSTKEY_TYPES = ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256']
|
|
|
|
|
|
class UnallocatedIPAddressError(core_exceptions.Error):
|
|
"""An exception to be raised when a network interface's IP address is yet
|
|
|
|
to be allocated.
|
|
"""
|
|
|
|
|
|
class MissingExternalIPAddressError(core_exceptions.Error):
|
|
"""An exception to be raised when a network interface does not have an
|
|
|
|
external IP address.
|
|
"""
|
|
|
|
|
|
class MissingNetworkInterfaceError(core_exceptions.Error):
|
|
"""Network interface was not found."""
|
|
|
|
|
|
class CommandError(core_exceptions.Error):
|
|
"""Wraps ssh.CommandError, primarly for adding troubleshooting info."""
|
|
|
|
def __init__(self, original_error, message=None):
|
|
if message is None:
|
|
message = 'See {url} for troubleshooting hints.'.format(
|
|
url=_TROUBLESHOOTING_URL)
|
|
|
|
super(CommandError, self).__init__(
|
|
'{0}\n{1}'.format(original_error, message),
|
|
exit_code=original_error.exit_code)
|
|
|
|
|
|
class ArgumentError(core_exceptions.Error):
|
|
"""Invalid combinations of, or malformed, arguments."""
|
|
pass
|
|
|
|
|
|
class SetProjectMetadataError(core_exceptions.Error):
|
|
pass
|
|
|
|
|
|
class SecurityKeysNotSupportedError(core_exceptions.Error):
|
|
pass
|
|
|
|
|
|
class SecurityKeysNotPresentError(core_exceptions.Error):
|
|
pass
|
|
|
|
|
|
class NetworkError(core_exceptions.Error):
|
|
"""Indicates that an SSH connection couldn't be established right now."""
|
|
|
|
def __init__(self):
|
|
super(NetworkError, self).__init__(
|
|
'Could not SSH into the instance. It is possible that your SSH key '
|
|
'has not propagated to the instance yet. Try running this command '
|
|
'again. If you still cannot connect, verify that the firewall and '
|
|
'instance are set to accept ssh traffic.')
|
|
|
|
|
|
def GetExternalInterface(instance_resource, no_raise=False):
|
|
"""Returns the network interface of the instance with an external IP address.
|
|
|
|
Args:
|
|
instance_resource: An instance resource object.
|
|
no_raise: A boolean flag indicating whether or not to return None instead of
|
|
raising.
|
|
|
|
Raises:
|
|
UnallocatedIPAddressError: If the instance_resource's external IP address
|
|
has yet to be allocated.
|
|
MissingExternalIPAddressError: If no external IP address is found for the
|
|
instance_resource and no_raise is False.
|
|
|
|
Returns:
|
|
A network interface resource object or None if no_raise and a network
|
|
interface with an external IP address does not exist.
|
|
"""
|
|
no_ip = False
|
|
if instance_resource.networkInterfaces:
|
|
for network_interface in instance_resource.networkInterfaces:
|
|
access_configs = network_interface.accessConfigs
|
|
ipv6_access_configs = network_interface.ipv6AccessConfigs
|
|
if access_configs:
|
|
if access_configs[0].natIP:
|
|
return network_interface
|
|
elif not no_raise:
|
|
no_ip = True
|
|
if ipv6_access_configs:
|
|
if ipv6_access_configs[0].externalIpv6:
|
|
return network_interface
|
|
elif not no_raise:
|
|
no_ip = True
|
|
if no_ip:
|
|
raise UnallocatedIPAddressError(
|
|
'Instance [{0}] in zone [{1}] has not been allocated an external '
|
|
'IP address yet. Try rerunning this command later.'.format(
|
|
instance_resource.name,
|
|
path_simplifier.Name(instance_resource.zone)))
|
|
|
|
if no_raise:
|
|
return None
|
|
|
|
raise MissingExternalIPAddressError(
|
|
'Instance [{0}] in zone [{1}] does not have an external IP address, '
|
|
'so you cannot SSH into it. To add an external IP address to the '
|
|
'instance, use [gcloud compute instances add-access-config].'.format(
|
|
instance_resource.name, path_simplifier.Name(instance_resource.zone)))
|
|
|
|
|
|
def GetExternalIPAddress(instance_resource, no_raise=False):
|
|
"""Returns the external IP address of the instance.
|
|
|
|
Args:
|
|
instance_resource: An instance resource object.
|
|
no_raise: A boolean flag indicating whether or not to return None instead of
|
|
raising.
|
|
|
|
Raises:
|
|
UnallocatedIPAddressError: If the instance_resource's external IP address
|
|
has yet to be allocated.
|
|
MissingExternalIPAddressError: If no external IP address is found for the
|
|
instance_resource and no_raise is False.
|
|
|
|
Returns:
|
|
A string IPv4 address or IPv6 address if the IPv4 address does not exit
|
|
or None if no_raise is True and no external IP exists.
|
|
"""
|
|
network_interface = GetExternalInterface(instance_resource, no_raise=no_raise)
|
|
if network_interface:
|
|
if (hasattr(network_interface, 'accessConfigs')
|
|
and network_interface.accessConfigs):
|
|
return network_interface.accessConfigs[0].natIP
|
|
elif (hasattr(network_interface, 'ipv6AccessConfigs')
|
|
and network_interface.ipv6AccessConfigs):
|
|
return network_interface.ipv6AccessConfigs[0].externalIpv6
|
|
|
|
|
|
def GetInternalInterface(instance_resource):
|
|
"""Returns the a network interface of the instance.
|
|
|
|
Args:
|
|
instance_resource: An instance resource object.
|
|
|
|
Raises:
|
|
MissingNetworkInterfaceError: If instance has no network interfaces.
|
|
|
|
Returns:
|
|
A network interface resource object.
|
|
"""
|
|
if instance_resource.networkInterfaces:
|
|
return instance_resource.networkInterfaces[0]
|
|
raise MissingNetworkInterfaceError(
|
|
'Instance [{0}] in zone [{1}] has no network interfaces.'.format(
|
|
instance_resource.name,
|
|
path_simplifier.Name(instance_resource.zone)))
|
|
|
|
|
|
def GetInternalIPAddress(instance_resource):
|
|
"""Returns the internal IP address of the instance.
|
|
|
|
Args:
|
|
instance_resource: An instance resource object.
|
|
|
|
Raises:
|
|
ToolException: If instance has no network interfaces.
|
|
|
|
Returns:
|
|
A string IPv4 address or IPv6 address if there is no IPv4 address.
|
|
"""
|
|
interface = GetInternalInterface(instance_resource)
|
|
return interface.networkIP or interface.ipv6Address
|
|
|
|
|
|
def GetSSHKeyExpirationFromArgs(args):
|
|
"""Converts flags to an ssh key expiration in datetime and micros."""
|
|
if args.ssh_key_expiration:
|
|
# this argument is checked in ParseFutureDatetime to be sure that it
|
|
# is not already expired. I.e. the expiration should be in the future.
|
|
expiration = args.ssh_key_expiration
|
|
elif args.ssh_key_expire_after:
|
|
expiration = times.Now() + datetime.timedelta(
|
|
seconds=args.ssh_key_expire_after)
|
|
else:
|
|
return None, None
|
|
|
|
expiration_micros = times.GetTimeStampFromDateTime(expiration) * 1e6
|
|
return expiration, int(expiration_micros)
|
|
|
|
|
|
def _GetSSHKeyListFromMetadataEntry(metadata_entry):
|
|
"""Returns a list of SSH keys (without whitespace) from a metadata entry."""
|
|
keys = []
|
|
for line in metadata_entry.split('\n'):
|
|
line_strip = line.strip()
|
|
if line_strip:
|
|
keys.append(line_strip)
|
|
return keys
|
|
|
|
|
|
def _GetSSHKeysFromMetadata(metadata):
|
|
"""Returns the ssh-keys and legacy sshKeys metadata values.
|
|
|
|
This function will return all of the SSH keys in metadata, stored in
|
|
the default metadata entry ('ssh-keys') and the legacy entry ('sshKeys').
|
|
|
|
Args:
|
|
metadata: An instance or project metadata object.
|
|
|
|
Returns:
|
|
A pair of lists containing the SSH public keys in the default and
|
|
legacy metadata entries.
|
|
"""
|
|
ssh_keys = []
|
|
ssh_legacy_keys = []
|
|
|
|
if not metadata:
|
|
return ssh_keys, ssh_legacy_keys
|
|
|
|
for item in metadata.items:
|
|
if item.key == constants.SSH_KEYS_METADATA_KEY:
|
|
ssh_keys = _GetSSHKeyListFromMetadataEntry(item.value)
|
|
elif item.key == constants.SSH_KEYS_LEGACY_METADATA_KEY:
|
|
ssh_legacy_keys = _GetSSHKeyListFromMetadataEntry(item.value)
|
|
|
|
return ssh_keys, ssh_legacy_keys
|
|
|
|
|
|
def _MetadataHasGuestAttributesEnabled(metadata):
|
|
"""Returns true if the metadata has 'enable-guest-attributes' set to 'true'.
|
|
|
|
Args:
|
|
metadata: Instance or Project metadata
|
|
|
|
Returns:
|
|
True if Enabled, False if Disabled, None if key is not present.
|
|
"""
|
|
if not (metadata and metadata.items):
|
|
return None
|
|
matching_values = [item.value for item in metadata.items
|
|
if item.key == GUEST_ATTRIBUTES_METADATA_KEY]
|
|
|
|
if not matching_values:
|
|
return None
|
|
return matching_values[0].lower() == 'true'
|
|
|
|
|
|
def _SSHKeyExpiration(ssh_key):
|
|
"""Returns a datetime expiration time for an ssh key entry from metadata.
|
|
|
|
Args:
|
|
ssh_key: A single ssh key entry.
|
|
|
|
Returns:
|
|
None if no expiration set or a datetime object of the expiration (in UTC).
|
|
|
|
Raises:
|
|
ValueError: If the ssh key entry could not be parsed for expiration (invalid
|
|
format, missing expected entries, etc).
|
|
dateutil.DateTimeSyntaxError: The found expiration could not be parsed.
|
|
dateutil.DateTimeValueError: The found expiration could not be parsed.
|
|
"""
|
|
# Valid format of a key with expiration is:
|
|
# <user>:<protocol> <key> google-ssh {... "expireOn": "<iso-8601>" ...}
|
|
# 0 1 2 json @ 3+
|
|
key_parts = ssh_key.split()
|
|
if len(key_parts) < 4 or key_parts[2] != 'google-ssh':
|
|
return None
|
|
expiration_json = ' '.join(key_parts[3:])
|
|
expiration = json.loads(expiration_json)
|
|
try:
|
|
expireon = times.ParseDateTime(expiration['expireOn'])
|
|
except KeyError:
|
|
raise ValueError('Unable to find expireOn entry')
|
|
return times.LocalizeDateTime(expireon, times.UTC)
|
|
|
|
|
|
def _PrepareSSHKeysValue(ssh_keys):
|
|
"""Returns a string appropriate for the metadata.
|
|
|
|
Expired SSH keys are always removed.
|
|
Then Values are taken from the tail until either all values are taken or
|
|
_MAX_METADATA_VALUE_SIZE_IN_BYTES is reached, whichever comes first. The
|
|
selected values are then reversed. Only values at the head of the list will be
|
|
subject to removal.
|
|
|
|
Args:
|
|
ssh_keys: A list of keys. Each entry should be one key.
|
|
|
|
Returns:
|
|
A new-line-joined string of SSH keys.
|
|
"""
|
|
keys = []
|
|
bytes_consumed = 0
|
|
|
|
now = times.LocalizeDateTime(times.Now(), times.UTC)
|
|
|
|
for key in reversed(ssh_keys):
|
|
try:
|
|
expiration = _SSHKeyExpiration(key)
|
|
expired = expiration is not None and expiration < now
|
|
if expired:
|
|
continue
|
|
except (ValueError, times.DateTimeSyntaxError,
|
|
times.DateTimeValueError) as exc:
|
|
# Unable to get expiration, so treat it like it is unexpiring.
|
|
log.warning(
|
|
'Treating {0!r} as unexpiring, since unable to parse: {1}'.format(
|
|
key, exc))
|
|
|
|
num_bytes = len(key + '\n')
|
|
if bytes_consumed + num_bytes > constants.MAX_METADATA_VALUE_SIZE_IN_BYTES:
|
|
prompt_message = ('The following SSH key will be removed from your '
|
|
'project because your SSH keys metadata value has '
|
|
'reached its maximum allowed size of {0} bytes: {1}')
|
|
prompt_message = prompt_message.format(
|
|
constants.MAX_METADATA_VALUE_SIZE_IN_BYTES, key)
|
|
console_io.PromptContinue(message=prompt_message, cancel_on_no=True)
|
|
else:
|
|
keys.append(key)
|
|
bytes_consumed += num_bytes
|
|
|
|
keys.reverse()
|
|
return '\n'.join(keys)
|
|
|
|
|
|
def _AddSSHKeyToMetadataMessage(message_classes, user, public_key, metadata,
|
|
expiration=None, legacy=False):
|
|
"""Adds the public key material to the metadata if it's not already there.
|
|
|
|
Args:
|
|
message_classes: An object containing API message classes.
|
|
user: The username for the SSH key.
|
|
public_key: The SSH public key to add to the metadata.
|
|
metadata: The existing metadata.
|
|
expiration: If provided, a datetime after which the key is no longer valid.
|
|
legacy: If true, store the key in the legacy "sshKeys" metadata entry.
|
|
|
|
Returns:
|
|
An updated metadata API message.
|
|
"""
|
|
if expiration is None:
|
|
entry = '{user}:{public_key}'.format(
|
|
user=user, public_key=public_key.ToEntry(include_comment=True))
|
|
else:
|
|
# The client only supports a specific format. See
|
|
# https://github.com/GoogleCloudPlatform/compute-image-packages/blob/master/packages/python-google-compute-engine/google_compute_engine/accounts/accounts_daemon.py#L118
|
|
expire_on = times.FormatDateTime(expiration, '%Y-%m-%dT%H:%M:%S+0000',
|
|
times.UTC)
|
|
entry = '{user}:{public_key} google-ssh {jsondict}'.format(
|
|
user=user, public_key=public_key.ToEntry(include_comment=False),
|
|
# The json blob has strict encoding requirements by some systems.
|
|
# Order entries to meet requirements.
|
|
# Any spaces produces a Pantheon Invalid Key Required Format error:
|
|
# cs/java/com/google/developers/console/web/compute/angular/ssh_keys_editor_item.ng
|
|
jsondict=json.dumps(collections.OrderedDict([
|
|
('userName', user),
|
|
('expireOn', expire_on)])).replace(' ', ''))
|
|
|
|
ssh_keys, ssh_legacy_keys = _GetSSHKeysFromMetadata(metadata)
|
|
all_ssh_keys = ssh_keys + ssh_legacy_keys
|
|
log.debug('Current SSH keys in project: {0}'.format(all_ssh_keys))
|
|
|
|
if entry in all_ssh_keys:
|
|
return metadata
|
|
|
|
if legacy:
|
|
metadata_key = constants.SSH_KEYS_LEGACY_METADATA_KEY
|
|
updated_ssh_keys = ssh_legacy_keys
|
|
else:
|
|
metadata_key = constants.SSH_KEYS_METADATA_KEY
|
|
updated_ssh_keys = ssh_keys
|
|
updated_ssh_keys.append(entry)
|
|
return metadata_utils.ConstructMetadataMessage(
|
|
message_classes=message_classes,
|
|
metadata={metadata_key: _PrepareSSHKeysValue(updated_ssh_keys)},
|
|
existing_metadata=metadata)
|
|
|
|
|
|
def _MetadataHasBlockProjectSshKeys(metadata):
|
|
"""Return true if the metadata has 'block-project-ssh-keys' set and 'true'."""
|
|
if not (metadata and metadata.items):
|
|
return False
|
|
matching_values = [item.value for item in metadata.items
|
|
if item.key == constants.SSH_KEYS_BLOCK_METADATA_KEY]
|
|
if not matching_values:
|
|
return False
|
|
return matching_values[0].lower() == 'true'
|
|
|
|
|
|
class BaseSSHHelper(object):
|
|
"""Helper class for subcommands that need to connect to instances using SSH.
|
|
|
|
Clients can call EnsureSSHKeyIsInProject() to make sure that the
|
|
user's public SSH key is placed in the project metadata before
|
|
proceeding.
|
|
|
|
Attributes:
|
|
keys: ssh.Keys, the public/private key pair.
|
|
env: ssh.Environment, the current environment, used by subclasses.
|
|
"""
|
|
|
|
keys = None
|
|
|
|
@staticmethod
|
|
def Args(parser):
|
|
"""Args is called by calliope to gather arguments for this command.
|
|
|
|
Please add arguments in alphabetical order except for no- or a clear-
|
|
pair for that argument which can follow the argument itself.
|
|
Args:
|
|
parser: An argparse parser that you can use to add arguments that go
|
|
on the command line after this command. Positional arguments are
|
|
allowed.
|
|
"""
|
|
parser.add_argument(
|
|
'--force-key-file-overwrite',
|
|
action='store_true',
|
|
default=None,
|
|
help="""\
|
|
If enabled, the gcloud command-line tool will regenerate and overwrite
|
|
the files associated with a broken SSH key without asking for
|
|
confirmation in both interactive and non-interactive environments.
|
|
|
|
If disabled, the files associated with a broken SSH key will not be
|
|
regenerated and will fail in both interactive and non-interactive
|
|
environments.""")
|
|
|
|
# Last line empty to preserve spacing between last paragraph and calliope
|
|
# attachment "Use --no-force-key-file-overwrite to disable."
|
|
parser.add_argument(
|
|
'--ssh-key-file',
|
|
help="""\
|
|
The path to the SSH key file. By default, this is ``{0}''.
|
|
""".format(ssh.Keys.DEFAULT_KEY_FILE))
|
|
|
|
def Run(self, args):
|
|
"""Sets up resources to be used by concrete subclasses.
|
|
|
|
Subclasses must call this in their Run() before continuing.
|
|
|
|
Args:
|
|
args: argparse.Namespace, arguments that this command was invoked with.
|
|
|
|
Raises:
|
|
ssh.CommandNotFoundError: SSH is not supported.
|
|
"""
|
|
|
|
self.keys = ssh.Keys.FromFilename(args.ssh_key_file)
|
|
self.env = ssh.Environment.Current()
|
|
self.env.RequireSSH()
|
|
|
|
def GetInstance(self, client, instance_ref):
|
|
"""Fetch an instance based on the given instance_ref."""
|
|
request = (
|
|
client.apitools_client.instances,
|
|
'Get',
|
|
client.messages.ComputeInstancesGetRequest(
|
|
instance=instance_ref.Name(),
|
|
project=instance_ref.project,
|
|
zone=instance_ref.zone,
|
|
),
|
|
)
|
|
|
|
return client.MakeRequests([request])[0]
|
|
|
|
def GetProject(self, client, project):
|
|
"""Returns the project object.
|
|
|
|
Args:
|
|
client: The compute client.
|
|
project: str, the project we are requesting or None for value from
|
|
from properties
|
|
|
|
Returns:
|
|
The project object
|
|
"""
|
|
return client.MakeRequests(
|
|
[(client.apitools_client.projects, 'Get',
|
|
client.messages.ComputeProjectsGetRequest(
|
|
project=project or
|
|
properties.VALUES.core.project.Get(required=True),))])[0]
|
|
|
|
def GetHostKeysFromGuestAttributes(self, client, instance_ref,
|
|
instance=None, project=None):
|
|
"""Get host keys from guest attributes.
|
|
|
|
Args:
|
|
client: The compute client.
|
|
instance_ref: The instance object.
|
|
instance: The object representing the instance we are connecting to. If
|
|
either project or instance is None, metadata won't be checked to
|
|
determine if Guest Attributes are enabled.
|
|
project: The object representing the current project. If either project
|
|
or instance is None, metadata won't be checked to determine if
|
|
Guest Attributes are enabled.
|
|
|
|
Returns:
|
|
A dictionary of host keys, with the type as the key and the host key
|
|
as the value, or None if Guest Attributes is not enabled.
|
|
"""
|
|
if instance and project:
|
|
# Instance metadata has priority.
|
|
guest_attributes_enabled = _MetadataHasGuestAttributesEnabled(
|
|
instance.metadata)
|
|
if guest_attributes_enabled is None:
|
|
project_metadata = project.commonInstanceMetadata
|
|
guest_attributes_enabled = _MetadataHasGuestAttributesEnabled(
|
|
project_metadata)
|
|
if not guest_attributes_enabled:
|
|
return None
|
|
|
|
requests = [(client.apitools_client.instances,
|
|
'GetGuestAttributes',
|
|
client.messages.ComputeInstancesGetGuestAttributesRequest(
|
|
instance=instance_ref.Name(),
|
|
project=instance_ref.project,
|
|
queryPath='hostkeys/',
|
|
zone=instance_ref.zone))]
|
|
|
|
try:
|
|
hostkeys = client.MakeRequests(requests)[0]
|
|
except exceptions.ToolException as e:
|
|
if ('The resource \'hostkeys/\' of type \'Guest Attribute\' was not '
|
|
'found.') in six.text_type(e):
|
|
hostkeys = None
|
|
else:
|
|
raise e
|
|
|
|
hostkey_dict = {}
|
|
|
|
if hostkeys is not None:
|
|
for item in hostkeys.queryValue.items:
|
|
if (item.namespace == 'hostkeys'
|
|
and item.key in SUPPORTED_HOSTKEY_TYPES):
|
|
# Truncate key value at any whitespace (newlines specifically can
|
|
# be a security issue).
|
|
key_value = item.value.split()[0]
|
|
|
|
# Verify that key value is a base64 string
|
|
try:
|
|
decoded_key = base64.b64decode(key_value)
|
|
encoded_key = encoding.Decode(base64.b64encode(decoded_key))
|
|
except (TypeError, binascii.Error):
|
|
encoded_key = ''
|
|
|
|
if key_value == encoded_key:
|
|
hostkey_dict[item.key] = key_value
|
|
|
|
return hostkey_dict
|
|
|
|
def WriteHostKeysToKnownHosts(self, known_hosts, host_keys, host_key_alias):
|
|
"""Writes host keys to known hosts file.
|
|
|
|
Only writes keys to known hosts file if there are no existing keys for
|
|
the host.
|
|
|
|
Args:
|
|
known_hosts: obj, known_hosts file object.
|
|
host_keys: dict, dictionary of host keys.
|
|
host_key_alias: str, alias for host key entries.
|
|
"""
|
|
host_key_entries = []
|
|
for key_type, key in host_keys.items():
|
|
host_key_entry = '{0} {1}'.format(key_type, key)
|
|
host_key_entries.append(host_key_entry)
|
|
host_key_entries.sort()
|
|
new_keys_added = known_hosts.AddMultiple(
|
|
host_key_alias, host_key_entries, overwrite=False)
|
|
if new_keys_added:
|
|
log.status.Print('Writing {0} keys to {1}'
|
|
.format(len(host_key_entries), known_hosts.file_path))
|
|
if host_key_entries and not new_keys_added:
|
|
log.status.Print('Existing host keys found in {0}'
|
|
.format(known_hosts.file_path))
|
|
known_hosts.Write()
|
|
|
|
def _SetProjectMetadata(self, client, new_metadata):
|
|
"""Sets the project metadata to the new metadata."""
|
|
errors = []
|
|
client.MakeRequests(
|
|
requests=[
|
|
(client.apitools_client.projects,
|
|
'SetCommonInstanceMetadata',
|
|
client.messages.ComputeProjectsSetCommonInstanceMetadataRequest(
|
|
metadata=new_metadata,
|
|
project=properties.VALUES.core.project.Get(
|
|
required=True),
|
|
))],
|
|
errors_to_collect=errors)
|
|
if errors:
|
|
utils.RaiseException(
|
|
errors,
|
|
SetProjectMetadataError,
|
|
error_message='Could not add SSH key to project metadata:')
|
|
|
|
def SetProjectMetadata(self, client, new_metadata):
|
|
"""Sets the project metadata to the new metadata with progress tracker."""
|
|
with progress_tracker.ProgressTracker('Updating project ssh metadata'):
|
|
self._SetProjectMetadata(client, new_metadata)
|
|
|
|
def _SetInstanceMetadata(self, client, instance, new_metadata):
|
|
"""Sets the instance metadata to the new metadata."""
|
|
errors = []
|
|
# API wants just the zone name, not the full URL
|
|
zone = instance.zone.split('/')[-1]
|
|
client.MakeRequests(
|
|
requests=[
|
|
(client.apitools_client.instances,
|
|
'SetMetadata',
|
|
client.messages.ComputeInstancesSetMetadataRequest(
|
|
instance=instance.name,
|
|
metadata=new_metadata,
|
|
project=properties.VALUES.core.project.Get(
|
|
required=True),
|
|
zone=zone
|
|
))],
|
|
errors_to_collect=errors)
|
|
if errors:
|
|
utils.RaiseToolException(
|
|
errors,
|
|
error_message=(
|
|
'Could not add SSH key to instance metadata, refer'
|
|
' https://cloud.google.com/compute/docs/access#granting_users_ssh_access_to_vm_instances'
|
|
' for granting users SSH access to VM instances:'
|
|
),
|
|
)
|
|
|
|
def SetInstanceMetadata(self, client, instance, new_metadata):
|
|
"""Sets the instance metadata to the new metadata with progress tracker."""
|
|
with progress_tracker.ProgressTracker('Updating instance ssh metadata'):
|
|
self._SetInstanceMetadata(client, instance, new_metadata)
|
|
|
|
def EnsureSSHKeyIsInInstance(self, client, user, instance, expiration,
|
|
legacy=False):
|
|
"""Ensures that the user's public SSH key is in the instance metadata.
|
|
|
|
Args:
|
|
client: The compute client.
|
|
user: str, the name of the user associated with the SSH key in the
|
|
metadata
|
|
instance: Instance, ensure the SSH key is in the metadata of this instance
|
|
expiration: datetime, If not None, the point after which the key is no
|
|
longer valid.
|
|
legacy: If the key is not present in metadata, add it to the legacy
|
|
metadata entry instead of the default entry.
|
|
|
|
Returns:
|
|
bool, True if the key was newly added, False if it was in the metadata
|
|
already
|
|
"""
|
|
public_key = self.keys.GetPublicKey()
|
|
new_metadata = _AddSSHKeyToMetadataMessage(
|
|
client.messages, user, public_key, instance.metadata,
|
|
expiration=expiration, legacy=legacy)
|
|
has_new_metadata = new_metadata != instance.metadata
|
|
if has_new_metadata:
|
|
self.SetInstanceMetadata(client, instance, new_metadata)
|
|
return has_new_metadata
|
|
|
|
def EnsureSSHKeyIsInProject(self, client, user, project=None,
|
|
expiration=None):
|
|
"""Ensures that the user's public SSH key is in the project metadata.
|
|
|
|
Args:
|
|
client: The compute client.
|
|
user: str, the name of the user associated with the SSH key in the
|
|
metadata
|
|
project: Project, the project SSH key will be added to
|
|
expiration: datetime, If not None, the point after which the key is no
|
|
longer valid.
|
|
|
|
Returns:
|
|
bool, True if the key was newly added, False if it was in the metadata
|
|
already
|
|
"""
|
|
public_key = self.keys.GetPublicKey()
|
|
if not project:
|
|
project = self.GetProject(client, None)
|
|
existing_metadata = project.commonInstanceMetadata
|
|
new_metadata = _AddSSHKeyToMetadataMessage(
|
|
client.messages, user, public_key, existing_metadata,
|
|
expiration=expiration)
|
|
if new_metadata != existing_metadata:
|
|
self.SetProjectMetadata(client, new_metadata)
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def EnsureSSHKeyExists(self, compute_client, user, instance, project,
|
|
expiration):
|
|
"""Controller for EnsureSSHKey* variants.
|
|
|
|
Sends the key to the project metadata or instance metadata,
|
|
and signals whether the key was newly added.
|
|
|
|
Args:
|
|
compute_client: The compute client.
|
|
user: str, The user name.
|
|
instance: Instance, the instance to connect to.
|
|
project: Project, the project instance is in.
|
|
expiration: datetime, If not None, the point after which the key is no
|
|
longer valid.
|
|
|
|
|
|
Returns:
|
|
bool, True if the key was newly added.
|
|
"""
|
|
# There are two kinds of metadata: project-wide metadata and per-instance
|
|
# metadata. There are five SSH-key related metadata keys:
|
|
#
|
|
# * project['ssh-keys']: shared project-wide list of keys.
|
|
# * project['sshKeys']: legacy, shared project-wide list of keys.
|
|
# * instance['block-project-ssh-keys']: bool, when true indicates that
|
|
# instance keys should replace project keys rather than being added
|
|
# to them.
|
|
# * instance['ssh-keys']: instance specific list of keys.
|
|
# * instance['sshKeys']: legacy, instance specific list of keys. When
|
|
# present, instance keys override project keys as if
|
|
# instance['block-project-ssh-keys'] was true.
|
|
#
|
|
# SSH-like commands work by copying a relevant SSH key to
|
|
# the appropriate metadata value. The VM grabs keys from the metadata as
|
|
# follows (pseudo-Python):
|
|
#
|
|
# def GetAllSshKeys(project, instance):
|
|
# if 'sshKeys' in instance.metadata:
|
|
# return (instance.metadata['sshKeys'] +
|
|
# instance.metadata['ssh-keys'])
|
|
# elif instance.metadata['block-project-ssh-keys'] == 'true':
|
|
# return instance.metadata['ssh-keys']
|
|
# else:
|
|
# return (instance.metadata['ssh-keys'] +
|
|
# project.metadata['ssh-keys'] +
|
|
# project.metadata['sshKeys']) # Legacy Project Keys
|
|
#
|
|
_, ssh_legacy_keys = _GetSSHKeysFromMetadata(instance.metadata)
|
|
if ssh_legacy_keys:
|
|
# If we add a key to project-wide metadata but the per-instance
|
|
# 'sshKeys' metadata exists, we won't be able to ssh in because the VM
|
|
# won't check the project-wide metadata. To avoid this, if the instance
|
|
# has per-instance SSH key metadata, we add the key there instead.
|
|
keys_newly_added = self.EnsureSSHKeyIsInInstance(
|
|
compute_client, user, instance, expiration, legacy=True)
|
|
elif _MetadataHasBlockProjectSshKeys(instance.metadata):
|
|
# If the instance 'ssh-keys' metadata overrides the project-wide
|
|
# 'ssh-keys' metadata, we should put our key there.
|
|
keys_newly_added = self.EnsureSSHKeyIsInInstance(
|
|
compute_client, user, instance, expiration)
|
|
else:
|
|
# Otherwise, try to add to the project-wide metadata. If we don't have
|
|
# permissions to do that, add to the instance 'ssh-keys' metadata.
|
|
try:
|
|
keys_newly_added = self.EnsureSSHKeyIsInProject(
|
|
compute_client, user, project, expiration)
|
|
except SetProjectMetadataError:
|
|
log.info('Could not set project metadata:', exc_info=True)
|
|
# If we can't write to the project metadata, it may be because of a
|
|
# permissions problem (we could inspect this exception object further
|
|
# to make sure, but because we only get a string back this would be
|
|
# fragile). If that's the case, we want to try the writing to instance
|
|
# metadata. We prefer this to the per-instance override of the
|
|
# project metadata.
|
|
log.info('Attempting to set instance metadata.')
|
|
keys_newly_added = self.EnsureSSHKeyIsInInstance(
|
|
compute_client, user, instance, expiration)
|
|
return keys_newly_added
|
|
|
|
def GetConfig(self, host_key_alias, strict_host_key_checking=None,
|
|
host_keys_to_add=None):
|
|
"""Returns a dict of default `ssh-config(5)` options on the OpenSSH format.
|
|
|
|
Args:
|
|
host_key_alias: str, Alias of the host key in the known_hosts file.
|
|
strict_host_key_checking: str or None, whether to enforce strict host key
|
|
checking. If None, it will be determined by existence of host_key_alias
|
|
in the known hosts file. Accepted strings are 'yes', 'ask' and 'no'.
|
|
host_keys_to_add: dict, A dictionary of host keys to add to the known
|
|
hosts file.
|
|
|
|
Returns:
|
|
Dict with OpenSSH options.
|
|
"""
|
|
config = {}
|
|
known_hosts = ssh.KnownHosts.FromDefaultFile()
|
|
config['UserKnownHostsFile'] = known_hosts.file_path
|
|
# Ensure our SSH key trumps any ssh_agent
|
|
config['IdentitiesOnly'] = 'yes'
|
|
config['CheckHostIP'] = 'no'
|
|
|
|
if not strict_host_key_checking:
|
|
if known_hosts.ContainsAlias(host_key_alias) or host_keys_to_add:
|
|
strict_host_key_checking = 'yes'
|
|
else:
|
|
strict_host_key_checking = 'no'
|
|
if host_keys_to_add:
|
|
self.WriteHostKeysToKnownHosts(
|
|
known_hosts, host_keys_to_add, host_key_alias)
|
|
|
|
config['StrictHostKeyChecking'] = strict_host_key_checking
|
|
config['HostKeyAlias'] = host_key_alias
|
|
# Don't hash the hostkey alias names.
|
|
config['HashKnownHosts'] = 'no'
|
|
return config
|
|
|
|
|
|
def AddVerifyInternalIpArg(parser):
|
|
parser.add_argument(
|
|
'--verify-internal-ip',
|
|
action=actions.StoreBooleanProperty(
|
|
properties.VALUES.ssh.verify_internal_ip),
|
|
hidden=True,
|
|
help='Whether or not `gcloud` should perform an initial SSH connection '
|
|
'to verify an instance ID is correct when connecting via its internal '
|
|
'IP. Without this check, `gcloud` will simply connect to the internal '
|
|
'IP of the desired instance, which may be wrong if the desired instance '
|
|
'is in a different subnet but happens to share the same internal IP as '
|
|
'an instance in the current subnet. Defaults to True.')
|
|
|
|
|
|
def AddSSHKeyExpirationArgs(parser):
|
|
"""Additional flags to handle expiring SSH keys."""
|
|
group = parser.add_mutually_exclusive_group()
|
|
|
|
def ParseFutureDatetime(s):
|
|
"""Parses a string value into a future Datetime object."""
|
|
dt = arg_parsers.Datetime.Parse(s)
|
|
if dt < times.Now():
|
|
raise arg_parsers.ArgumentTypeError(
|
|
'Date/time must be in the future: {0}'.format(s))
|
|
return dt
|
|
|
|
group.add_argument(
|
|
'--ssh-key-expiration',
|
|
type=ParseFutureDatetime,
|
|
help="""\
|
|
The time when the ssh key will be valid until, such as
|
|
"2017-08-29T18:52:51.142Z." This is only valid if the instance is not
|
|
using OS Login. See $ gcloud topic datetimes for information on time
|
|
formats.
|
|
""")
|
|
group.add_argument(
|
|
'--ssh-key-expire-after',
|
|
type=arg_parsers.Duration(lower_bound='1s'),
|
|
help="""\
|
|
The maximum length of time an SSH key is valid for once created and
|
|
installed, e.g. 2m for 2 minutes. See $ gcloud topic datetimes for
|
|
information on duration formats.
|
|
""")
|
|
|
|
|
|
class BaseSSHCLIHelper(BaseSSHHelper):
|
|
"""Helper class for subcommands that use ssh or scp."""
|
|
|
|
@staticmethod
|
|
def Args(parser):
|
|
"""Args is called by calliope to gather arguments for this command.
|
|
|
|
Please add arguments in alphabetical order except for no- or a clear-
|
|
pair for that argument which can follow the argument itself.
|
|
|
|
Args:
|
|
parser: An argparse parser that you can use to add arguments that go
|
|
on the command line after this command. Positional arguments are
|
|
allowed.
|
|
"""
|
|
super(BaseSSHCLIHelper, BaseSSHCLIHelper).Args(parser)
|
|
|
|
parser.add_argument(
|
|
'--dry-run',
|
|
action='store_true',
|
|
help=('Print the equivalent scp/ssh command that would be run to '
|
|
'stdout, instead of executing it.'))
|
|
|
|
parser.add_argument(
|
|
'--plain',
|
|
action='store_true',
|
|
help="""\
|
|
Suppress the automatic addition of *ssh(1)*/*scp(1)* flags. This flag
|
|
is useful if you want to take care of authentication yourself or
|
|
use specific ssh/scp features.
|
|
""")
|
|
|
|
parser.add_argument(
|
|
'--strict-host-key-checking',
|
|
choices=['yes', 'no', 'ask'],
|
|
help="""\
|
|
Override the default behavior of StrictHostKeyChecking for the
|
|
connection. By default, StrictHostKeyChecking is set to 'no' the first
|
|
time you connect to an instance, and will be set to 'yes' for all
|
|
subsequent connections.
|
|
""")
|
|
|
|
AddSSHKeyExpirationArgs(parser)
|
|
|
|
def Run(self, args):
|
|
super(BaseSSHCLIHelper, self).Run(args)
|
|
if not args.plain:
|
|
self.keys.EnsureKeysExist(args.force_key_file_overwrite,
|
|
allow_passphrase=True)
|
|
|
|
def PreliminarilyVerifyInstance(self, instance_id, remote, identity_file,
|
|
options, putty_force_connect=False):
|
|
"""Verify the instance's identity by connecting and running a command.
|
|
|
|
Args:
|
|
instance_id: str, id of the compute instance.
|
|
remote: ssh.Remote, remote to connect to.
|
|
identity_file: str, optional key file.
|
|
options: dict, optional ssh options.
|
|
putty_force_connect: bool, whether to inject 'y' into the prompts for
|
|
`plink`, which is insecure and not recommended. It serves legacy
|
|
compatibility purposes for existing usages only; DO NOT SET THIS IN NEW
|
|
CODE.
|
|
|
|
Raises:
|
|
ssh.CommandError: The ssh command failed.
|
|
core_exceptions.NetworkIssueError: The instance id does not match.
|
|
"""
|
|
if options.get('StrictHostKeyChecking') == 'yes':
|
|
log.debug('Skipping internal IP verification in favor of strict host '
|
|
'key checking.')
|
|
return
|
|
|
|
if not properties.VALUES.ssh.verify_internal_ip.GetBool():
|
|
log.warning(
|
|
'Skipping internal IP verification connection and connecting to [{}] '
|
|
'in the current subnet. This may be the wrong host if the instance '
|
|
'is in a different subnet!'.format(remote.host))
|
|
return
|
|
|
|
metadata_id_url = (
|
|
'http://metadata.google.internal/computeMetadata/v1/instance/id')
|
|
# Exit codes 255 and 1 are taken by OpenSSH and PuTTY.
|
|
# 23 chosen by fair dice roll.
|
|
remote_command = [
|
|
'[ `curl "{}" -H "Metadata-Flavor: Google" -q` = {} ] || exit 23'
|
|
.format(metadata_id_url, instance_id)]
|
|
cmd = ssh.SSHCommand(remote, identity_file=identity_file,
|
|
options=options, remote_command=remote_command)
|
|
# Open the platform-specific null device for stdin and stdout
|
|
# for the subprocess.
|
|
null_in = FileReader(os.devnull)
|
|
null_out = FileWriter(os.devnull)
|
|
null_err = FileWriter(os.devnull)
|
|
return_code = cmd.Run(
|
|
self.env,
|
|
putty_force_connect=putty_force_connect,
|
|
explicit_output_file=null_out,
|
|
explicit_error_file=null_err,
|
|
explicit_input_file=null_in)
|
|
if return_code == 0:
|
|
return
|
|
elif return_code == 23:
|
|
raise core_exceptions.NetworkIssueError(
|
|
'Established connection with host {} but was unable to '
|
|
'confirm ID of the instance.'.format(remote.host))
|
|
raise ssh.CommandError(cmd, return_code=return_code)
|
|
|
|
|
|
def HostKeyAlias(instance):
|
|
return 'compute.{0}'.format(instance.id)
|
|
|
|
|
|
def GetUserAndInstance(user_host):
|
|
"""Returns pair consiting of user name and instance name."""
|
|
parts = user_host.split('@')
|
|
if len(parts) == 1:
|
|
user = ssh.GetDefaultSshUsername(warn_on_account_user=True)
|
|
instance = parts[0]
|
|
return user, instance
|
|
if len(parts) == 2:
|
|
return parts
|
|
raise ArgumentError(
|
|
'Expected argument of the form [USER@]INSTANCE; received [{0}].'
|
|
.format(user_host))
|
|
|
|
|
|
def CreateSSHPoller(remote, identity_file, options, iap_tunnel_args,
|
|
extra_flags=None, port=None):
|
|
"""Creates and returns an SSH poller."""
|
|
|
|
ssh_poller_args = {'remote': remote,
|
|
'identity_file': identity_file,
|
|
'options': options,
|
|
'iap_tunnel_args': iap_tunnel_args,
|
|
'extra_flags': extra_flags,
|
|
'max_wait_ms': SSH_KEY_PROPAGATION_TIMEOUT_MS}
|
|
|
|
# Do not include default port since that will prevent users from
|
|
# specifying a custom port (b/121998342).
|
|
if port:
|
|
ssh_poller_args['port'] = six.text_type(port)
|
|
|
|
return ssh.SSHPoller(**ssh_poller_args)
|
|
|
|
|
|
def ConfirmSecurityKeyStatus(oslogin_state):
|
|
"""Check the OS Login security key state and take approprate action.
|
|
|
|
If OS Login security keys are not enabled, continue.
|
|
When security keys are enabled:
|
|
- if no security keys are configured in the user's account, show an error.
|
|
- if the local SSH client doesn't support them, show an error.
|
|
- if the user is using Putty, show an error.
|
|
- if we cannnot determine if the local client supports security keys, show
|
|
a warning and continue.
|
|
|
|
Args:
|
|
oslogin_state: An OsloginState object.
|
|
|
|
Raises:
|
|
SecurityKeysNotPresentError: If no security keys are registered in the
|
|
user's account.
|
|
SecurityKeysNotSupportedError: If the user's SSH client does not support
|
|
security keys.
|
|
|
|
Returns:
|
|
None if no errors are raised.
|
|
"""
|
|
# If OS login security keys are not enabled, continue.
|
|
if not oslogin_state.security_keys_enabled:
|
|
return
|
|
|
|
# If security keys are enabled, but no security keys are registered in the
|
|
# user's account, raise an error.
|
|
if not oslogin_state.security_keys:
|
|
raise SecurityKeysNotPresentError(
|
|
'Instance requires security key for connection, but no security keys '
|
|
'are registered in Google account.')
|
|
|
|
# If security keys are enabled, and the local SSH client supports security
|
|
# keys, all is good, so continue.
|
|
if oslogin_state.ssh_security_key_support:
|
|
return
|
|
|
|
# If we cannot determine if the local client supports security keys, show
|
|
# a warning and continue.
|
|
if oslogin_state.ssh_security_key_support is None:
|
|
log.warning('Instance requires security key for connection, but cannot '
|
|
'determine if the SSH client supports security keys. The '
|
|
'connection may fail.')
|
|
return
|
|
|
|
# If we are on Windows using PuTTY, raise an error.
|
|
if oslogin_state.environment == 'putty':
|
|
raise SecurityKeysNotSupportedError(
|
|
'Instance requires security key for connection, but security keys '
|
|
'are not supported on Windows using the PuTTY client.')
|
|
|
|
# If the local SSH client does not support security keys, raise an error.
|
|
raise SecurityKeysNotSupportedError(
|
|
'Instance requires security key for connection, but security keys are '
|
|
'not supported by the installed SSH version. OpenSSH 8.4 or higher '
|
|
'is required.')
|