# -*- coding: utf-8 -*- # # Copyright 2015 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 resetting a password in a Windows instance.""" from __future__ import absolute_import from __future__ import division from __future__ import unicode_literals import json import textwrap from apitools.base.py import encoding from googlecloudsdk.api_lib.compute import base_classes from googlecloudsdk.api_lib.compute import constants from googlecloudsdk.api_lib.compute import metadata_utils from googlecloudsdk.api_lib.compute import openssl_encryption_utils from googlecloudsdk.api_lib.compute import utils from googlecloudsdk.calliope import base from googlecloudsdk.command_lib.compute.instances import flags as instance_flags from googlecloudsdk.command_lib.util import gaia from googlecloudsdk.command_lib.util import time_util from googlecloudsdk.core import log from googlecloudsdk.core import properties from googlecloudsdk.core.console import console_io from googlecloudsdk.core.util import encoding as core_encoding from googlecloudsdk.core.util import files # This will only succeed on Windows machines. try: # pylint: disable=g-import-not-at-top from googlecloudsdk.api_lib.compute import windows_encryption_utils except ImportError: windows_encryption_utils = None EXPIRATION_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S+0000' WINDOWS_PASSWORD_TIMEOUT_SEC = 30 RSA_KEY_EXPIRATION_TIME_SEC = 300 METADATA_KEY = 'windows-keys' OLD_METADATA_KEYS = ['gce-initial-windows-user', 'gce-initial-windows-password'] POLLING_SEC = 2 TIMEOUT_ERROR = textwrap.dedent("""\ Did not receive password in a reasonable amount of time. Please try again. If this persists, confirm that the clock on your local system is correct. Current UTC time on your system: [{0}]""") NOT_READY_ERROR = textwrap.dedent(""" The instance may not be ready for use. This can occur if the instance was recently created or if the instance is not running Windows. Please wait a few minutes and try again.""") OLD_WINDOWS_BUILD_ERROR = textwrap.dedent(""" This Windows instance appears to be too old and does not support the reset-windows-password command. Please run the following command and look for the keys "gce-initial-windows-user" and "gce-initial-windows-password" in the metadata: [gcloud compute instances describe {0} --zone {1}] Alternatively, you can recreate the instance and update it to take advantage of reset-windows-password. More information can be found here: https://cloud.google.com/compute/docs/operating-systems/windows#upgrade_existing_instances """) MACHINE_USERNAME_SAME_ERROR = textwrap.dedent(""" User [{0}] cannot be created on instance [{1}]. The user name and instance name must differ on Windows instances. Please use the "--user" flag to select a different username for this instance.""") NO_IP_WARNING = textwrap.dedent("""\ Instance [{0}] does not appear to have an external IP address, so it will not be able to accept external connections. To add an external IP address to the instance, use gcloud compute instances add-access-config.""") OLD_KEYS_WARNING = textwrap.dedent("""\ Instance [{0}] appears to have been created with an older version of gcloud (or another tool that is still setting legacy credentials for Windows instances) and the metadata for this instance contains insecure (and likely invalid) authentication credentials. It is recommended that they be removed with the following command: [gcloud compute instances remove-metadata {1} --zone {2} --keys {3}] """) RESET_PASSWORD_WARNING = textwrap.dedent(""" This command creates an account and sets an initial password for the user [{0}] if the account does not already exist. If the account already exists, resetting the password can cause the LOSS OF ENCRYPTED DATA secured with the current password, including files and stored passwords. For more information, see: https://cloud.google.com/compute/docs/operating-systems/windows#reset""") @base.DefaultUniverseOnly @base.ReleaseTracks(base.ReleaseTrack.GA, base.ReleaseTrack.BETA, base.ReleaseTrack.ALPHA) class ResetWindowsPassword(base.UpdateCommand): """Reset and return a password for a Windows machine instance. *{command}* allows a user to reset and retrieve a password for a Windows virtual machine instance. If the Windows account does not exist, this command will cause the account to be created and the password for that new account will be returned. For Windows instances that are running a domain controller, running this command creates a new domain user if the user does not exist, or resets the password if the user does exist. It is not possible to use this command to create a local user on a domain-controller instance. NOTE: When resetting passwords or adding a new user to a Domain Controller in this way, the user will be added to the built in Admin Group on the Domain Controller. This will give the user wide reaching permissions. If this is not the desired outcome then Active Directory Users and Computers should be used instead. For all other instances, including domain-joined instances, running this command creates a local user or resets the password for a local user. WARNING: Resetting a password for an existing user can cause the loss of data encrypted with the current Windows password, such as encrypted files or stored passwords. The user running this command must have write permission for the Google Compute Engine project containing the Windows instance. ## EXAMPLES To reset the password for user 'foo' on a Windows instance 'my-instance' in zone 'us-central1-f', run: $ {command} my-instance --zone=us-central1-f --user=foo """ category = base.TOOLS_CATEGORY @staticmethod def Args(parser): parser.display_info.AddFormat('[private]text') parser.add_argument( '--user', help="""\ ``USER'' specifies the username to get the password for. If omitted, the username is derived from your authenticated account email address. """) instance_flags.INSTANCE_ARG.AddArgument(parser) def GetGetRequest(self, client, instance_ref): return (client.apitools_client.instances, 'Get', client.messages.ComputeInstancesGetRequest(**instance_ref.AsDict())) def GetSetRequest(self, client, instance_ref, replacement): return (client.apitools_client.instances, 'SetMetadata', client.messages.ComputeInstancesSetMetadataRequest( metadata=replacement.metadata, **instance_ref.AsDict())) def CreateReference(self, client, resources, args): return instance_flags.INSTANCE_ARG.ResolveAsResource( args, resources, scope_lister=instance_flags.GetInstanceZoneScopeLister(client)) def Modify(self, client, existing): new_object = encoding.CopyProtoMessage(existing) existing_metadata = getattr(existing, 'metadata', None) new_metadata = metadata_utils.ConstructMetadataMessage( message_classes=client.messages, metadata={ METADATA_KEY: self._UpdateWindowsKeysValue(existing_metadata)}, existing_metadata=existing_metadata) new_object.metadata = new_metadata return new_object def _ConstructWindowsKeyEntry(self, user, modulus, exponent, email): """Return a JSON formatted entry for 'windows-keys'.""" expire_str = time_util.CalculateExpiration(RSA_KEY_EXPIRATION_TIME_SEC) windows_key_data = {'userName': user, 'modulus': core_encoding.Decode(modulus), 'exponent': core_encoding.Decode(exponent), 'email': email, 'expireOn': expire_str} windows_key_entry = json.dumps(windows_key_data, sort_keys=True) return windows_key_entry def _UpdateWindowsKeysValue(self, existing_metadata): """Returns a string appropriate for the metadata. Values are removed if they have expired and non-expired keys are removed from the head of the list only if the total key size is greater than MAX_METADATA_VALUE_SIZE_IN_BYTES. Args: existing_metadata: The existing metadata for the instance to be updated. Returns: A new-line-joined string of Windows keys. """ # Get existing keys from metadata. windows_keys = [] self.old_metadata_keys = [] for item in existing_metadata.items: if item.key == METADATA_KEY: windows_keys = [key.strip() for key in item.value.split('\n') if key] if item.key in OLD_METADATA_KEYS: self.old_metadata_keys.append(item.key) # Append new key. windows_keys.append(self.windows_key_entry) # Remove expired and excess key entries. keys = [] bytes_consumed = 0 for key in reversed(windows_keys): # Keys should be removed in FIFO order. num_bytes = len(key + '\n') key_expired = False # Try to determine if key is expired. Ignore any errors. try: key_data = json.loads(key) if time_util.IsExpired(key_data['expireOn']): key_expired = True # Errors should come in two forms: Invalid JSON (ValueError) or missing # 'expireOn' key (KeyError). except (ValueError, KeyError): pass if key_expired: log.debug('The following Windows key has expired and will be removed ' 'from your project: {0}'.format(key)) elif (bytes_consumed + num_bytes > constants.MAX_METADATA_VALUE_SIZE_IN_BYTES): log.debug('The following Windows key will be removed from your project ' 'because your windows keys metadata value has reached its ' 'maximum allowed size of {0} bytes: {1}' .format(constants.MAX_METADATA_VALUE_SIZE_IN_BYTES, key)) else: keys.append(key) bytes_consumed += num_bytes log.debug('Number of Windows Keys: {0}'.format(len(keys))) keys.reverse() return '\n'.join(keys) def _GetSerialPortOutput(self, client, instance_ref, port=4): """Returns the serial port output for self.instance_ref.""" request = (client.apitools_client.instances, 'GetSerialPortOutput', client.messages.ComputeInstancesGetSerialPortOutputRequest( port=port, **instance_ref.AsDict())) objects = client.MakeRequests([request]) return objects[0].contents def _GetEncryptedPasswordFromSerialPort(self, client, instance_ref, search_modulus): """Returns the decrypted password from the data in the serial port.""" encrypted_password_data = {} start_time = time_util.CurrentTimeSec() count = 1 agent_ready = False while not encrypted_password_data: log.debug('Get Serial Port Output, Try {0}'.format(count)) if (time_util.CurrentTimeSec() > (start_time + WINDOWS_PASSWORD_TIMEOUT_SEC)): raise utils.TimeoutError( TIMEOUT_ERROR.format(time_util.CurrentDatetimeUtc())) serial_port_output = self._GetSerialPortOutput( client, instance_ref, port=4).split('\n') for line in reversed(serial_port_output): try: encrypted_password_dict = json.loads(line) # Sometimes the serial port output only contains a partial entry. except ValueError: continue modulus = encrypted_password_dict.get('modulus') if modulus or encrypted_password_dict.get('ready'): agent_ready = True # Ignore any output that doesn't contain an encrypted password. if not encrypted_password_dict.get('encryptedPassword'): continue if (core_encoding.Decode(search_modulus) == core_encoding.Decode( modulus)): encrypted_password_data = encrypted_password_dict break if not agent_ready: if self.old_metadata_keys: message = OLD_WINDOWS_BUILD_ERROR.format(instance_ref.instance, instance_ref.zone) raise utils.WrongInstanceTypeError(message) else: message = NOT_READY_ERROR raise utils.InstanceNotReadyError(message) time_util.Sleep(POLLING_SEC) count += 1 encrypted_password = encrypted_password_data['encryptedPassword'] return encrypted_password def Run(self, args): holder = base_classes.ComputeApiHolder(self.ReleaseTrack()) client = holder.client start = time_util.CurrentTimeSec() # Set up Encryption utilities. openssl_executable = files.FindExecutableOnPath('openssl') if windows_encryption_utils: crypt = windows_encryption_utils.WinCrypt() elif openssl_executable: crypt = openssl_encryption_utils.OpensslCrypt(openssl_executable) else: raise utils.MissingDependencyError( 'Your platform does not support OpenSSL.') # Get Authenticated email address and default username. email = properties.VALUES.core.account.GetOrFail() if args.user: user = args.user else: user = gaia.MapGaiaEmailToDefaultAccountName(email) if args.instance_name == user: raise utils.InvalidUserError( MACHINE_USERNAME_SAME_ERROR.format(user, args.instance_name)) # Warn user (This warning doesn't show for non-interactive sessions). message = RESET_PASSWORD_WARNING.format(user) prompt_string = ('Would you like to set or reset the password for [{0}]' .format(user)) console_io.PromptContinue( message=message, prompt_string=prompt_string, cancel_on_no=True) log.status.Print('Resetting and retrieving password for [{0}] on [{1}]' .format(user, args.instance_name)) # Get Encryption Keys. key = crypt.GetKeyPair() modulus, exponent = crypt.GetModulusExponentFromPublicKey( crypt.GetPublicKey(key)) # Create Windows key entry. self.windows_key_entry = self._ConstructWindowsKeyEntry( user, modulus, exponent, email) # Call ReadWriteCommad.Run() which will fetch the instance and update # the metadata (using the data in self.windows_key_entry). instance_ref = self.CreateReference(client, holder.resources, args) get_request = self.GetGetRequest(client, instance_ref) objects = client.MakeRequests([get_request]) new_object = self.Modify(client, objects[0]) # If existing object is equal to the proposed object or if # Modify() returns None, then there is no work to be done, so we # print the resource and return. if objects[0] == new_object: log.status.Print( 'No change requested; skipping update for [{0}].'.format( objects[0].name)) return objects updated_instance = client.MakeRequests( [self.GetSetRequest(client, instance_ref, new_object)])[0] # Retrieve and Decrypt the password from the serial console. enc_password = self._GetEncryptedPasswordFromSerialPort( client, instance_ref, modulus) password = crypt.DecryptMessage(key, enc_password) if not isinstance(password, str): password = core_encoding.Decode(password) # Get External IP address. try: access_configs = updated_instance.networkInterfaces[0].accessConfigs external_ip_address = access_configs[0].natIP except (KeyError, IndexError) as _: log.warning(NO_IP_WARNING.format(updated_instance.name)) external_ip_address = None # Check for old Windows credentials. if self.old_metadata_keys: log.warning(OLD_KEYS_WARNING.format(instance_ref.instance, instance_ref.instance, instance_ref.zone, ','.join(self.old_metadata_keys))) log.info('Total Elapsed Time: {0}' .format(time_util.CurrentTimeSec() - start)) # The connection info resource. connection_info = {'username': user, 'password': password, 'ip_address': external_ip_address} return connection_info