424 lines
16 KiB
Python
424 lines
16 KiB
Python
# -*- 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
|