Class: Kitchen::Driver::Gce::WindowsPassword

Inherits:
Object
  • Object
show all
Defined in:
lib/kitchen/driver/gce/windows_password.rb

Overview

Resets the password of a Windows account on a GCE instance.

Google does not expose Windows credentials through the API. Instead, an agent inside the guest watches the instance's windows-keys metadata for an RSA public key, resets the named account, and writes the new password back to the instance's serial port, encrypted with that key.

This class performs the client half of that exchange: it publishes a freshly generated public key, watches the serial port for the matching response, and decrypts the password with the private key, which never leaves this process.

It borrows the driver's authorised API client, project, zone and operation handling rather than establishing its own.

Examples:

WindowsPassword.new(driver,
  instance_name: "tk-win-1",
  email: "user@example.com",
  username: "Administrator").new_password #=> "hR2$k9..."

See Also:

Constant Summary collapse

DEFAULT_TIMEOUT =

Seconds to wait for the in-guest agent when no timeout is configured.

Returns:

  • (Integer)

    the default timeout

120
SERIAL_PORT =

Serial port the Windows agent writes its response to.

Returns:

  • (Integer)

    the serial port number

4
METADATA_KEY =

Instance metadata key the agent watches for a public key.

Returns:

  • (String)

    the metadata key

"windows-keys".freeze
KEY_SIZE =

Size, in bits, of the RSA key generated for the exchange.

Returns:

  • (Integer)

    the key size

2048
KEY_TTL =

How long, in seconds, the published key remains valid.

Returns:

  • (Integer)

    the key lifetime

300

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(driver, instance_name:, email:, username: nil, timeout: nil) ⇒ WindowsPassword

Returns a new instance of WindowsPassword.

Parameters:

  • driver (Kitchen::Driver::Gce)

    driver supplying the API client, project, zone, operation handling and logging

  • instance_name (String)

    the GCE instance to reset a password on

  • email (String)

    email address of the GCE user making the request

  • username (String, nil) (defaults to: nil)

    the Windows account, defaulting to Administrator

  • timeout (Integer, nil) (defaults to: nil)

    seconds to wait for the in-guest agent, defaulting to DEFAULT_TIMEOUT

Raises:

  • (ArgumentError)

    if the instance name or email is missing



101
102
103
104
105
106
107
108
109
110
# File 'lib/kitchen/driver/gce/windows_password.rb', line 101

def initialize(driver, instance_name:, email:, username: nil, timeout: nil)
  raise ArgumentError, "Instance name not specified" if instance_name.nil?
  raise ArgumentError, "Email address of GCE user not specified" if email.nil?

  @driver        = driver
  @instance_name = instance_name
  @email         = email
  @username      = username || "Administrator"
  @timeout       = (timeout || DEFAULT_TIMEOUT).to_i
end

Instance Attribute Details

#driverKitchen::Driver::Gce (readonly)

Returns the driver this request runs through.

Returns:



78
79
80
# File 'lib/kitchen/driver/gce/windows_password.rb', line 78

def driver
  @driver
end

#emailString (readonly)

Returns the email address of the GCE user making the request.

Returns:

  • (String)

    the email address of the GCE user making the request



84
85
86
# File 'lib/kitchen/driver/gce/windows_password.rb', line 84

def email
  @email
end

#instance_nameString (readonly)

Returns the GCE instance whose password is being reset.

Returns:

  • (String)

    the GCE instance whose password is being reset



81
82
83
# File 'lib/kitchen/driver/gce/windows_password.rb', line 81

def instance_name
  @instance_name
end

#timeoutInteger (readonly)

Returns seconds to wait for the in-guest agent.

Returns:

  • (Integer)

    seconds to wait for the in-guest agent



90
91
92
# File 'lib/kitchen/driver/gce/windows_password.rb', line 90

def timeout
  @timeout
end

#usernameString (readonly)

Returns the Windows account being reset.

Returns:

  • (String)

    the Windows account being reset



87
88
89
# File 'lib/kitchen/driver/gce/windows_password.rb', line 87

def username
  @username
end

Instance Method Details

#await_responseHash

Polls the serial port until the agent answers our key.

Returns:

  • (Hash)

    the agent's response

Raises:

  • (Timeout::Error)

    if the agent does not respond in time



184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/kitchen/driver/gce/windows_password.rb', line 184

def await_response
  Timeout.timeout(timeout) do
    loop do
      response = response_from_serial_port
      return response unless response.nil?

      driver.debug("No password response yet for #{instance_name}, waiting...")
      sleep driver.refresh_rate
    end
  end
rescue Timeout::Error
  raise Timeout::Error, "Timed out after #{timeout} seconds waiting for the GCE agent " \
                        "to reset the password for #{username} on #{instance_name}"
end

#decrypt(response) ⇒ String

Decrypts the password from the agent's response.

OpenSSL hands back binary-tagged bytes. The agent sends UTF-8, so the result is retagged rather than left as ASCII-8BIT, which would other- wise be written into the state file as a binary blob.

Parameters:

  • response (Hash)

    the agent's response

Returns:

  • (String)

    the plaintext password, encoded as UTF-8

Raises:

  • (RuntimeError)

    if the agent reported a failed reset



247
248
249
250
251
252
253
254
255
256
# File 'lib/kitchen/driver/gce/windows_password.rb', line 247

def decrypt(response)
  unless response["passwordFound"]
    raise "The GCE agent could not reset the password for #{username} on #{instance_name}"
  end

  private_key.private_decrypt(
    Base64.strict_decode64(response["encryptedPassword"]),
    OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
  ).force_encoding(Encoding::UTF_8)
end

#expirationString

When the published key stops being valid.

Returns:

  • (String)

    an RFC 3339 timestamp



176
177
178
# File 'lib/kitchen/driver/gce/windows_password.rb', line 176

def expiration
  (Time.now + KEY_TTL).to_datetime.rfc3339
end

#exponentString

The public key's exponent, as the agent expects it.

Returns:

  • (String)

    the Base64-encoded big-endian exponent



282
283
284
# File 'lib/kitchen/driver/gce/windows_password.rb', line 282

def exponent
  @exponent ||= Base64.strict_encode64(public_key.e.to_s(2))
end

#instance_metadataGoogle::Apis::ComputeV1::Metadata

The instance's current metadata.

Returns:

  • (Google::Apis::ComputeV1::Metadata)

    the metadata

Raises:

  • (RuntimeError)

    if the instance cannot be found



144
145
146
147
148
# File 'lib/kitchen/driver/gce/windows_password.rb', line 144

def 
  driver.server_instance(instance_name).
rescue Google::Apis::ClientError
  raise "Unable to locate instance #{instance_name} in project #{driver.project}, zone #{driver.zone}"
end

#key_metadata_itemGoogle::Apis::ComputeV1::Metadata::Item

The metadata entry describing the key exchange request.

Returns:

  • (Google::Apis::ComputeV1::Metadata::Item)

    the entry



153
154
155
156
157
158
# File 'lib/kitchen/driver/gce/windows_password.rb', line 153

def 
  Google::Apis::ComputeV1::Metadata::Item.new(
    key: METADATA_KEY,
    value: key_request.to_json
  )
end

#key_requestHash

The payload the in-guest agent reads to perform the reset.

Returns:

  • (Hash)

    the request, in the shape the agent expects



163
164
165
166
167
168
169
170
171
# File 'lib/kitchen/driver/gce/windows_password.rb', line 163

def key_request
  {
    "userName" => username,
    "modulus" => modulus,
    "exponent" => exponent,
    "email" => email,
    "expireOn" => expiration,
  }
end

#modulusString

The public key's modulus, as the agent expects it.

Returns:

  • (String)

    the Base64-encoded big-endian modulus



275
276
277
# File 'lib/kitchen/driver/gce/windows_password.rb', line 275

def modulus
  @modulus ||= Base64.strict_encode64(public_key.n.to_s(2))
end

#new_passwordString

Runs the full exchange and returns the new password.

Returns:

  • (String)

    the plaintext password the agent generated

Raises:

  • (RuntimeError)

    if the instance is missing, or the agent reports that it could not reset the password

  • (Timeout::Error)

    if the agent does not respond within #timeout



118
119
120
121
# File 'lib/kitchen/driver/gce/windows_password.rb', line 118

def new_password
  publish_public_key
  decrypt(await_response)
end

#parse_event(line) ⇒ Hash?

Parses one line of serial port output.

Parameters:

  • line (String)

    the line to parse

Returns:

  • (Hash, nil)

    the parsed object, or nil if the line is not a JSON object



231
232
233
234
235
236
# File 'lib/kitchen/driver/gce/windows_password.rb', line 231

def parse_event(line)
  event = JSON.parse(line.strip)
  event.is_a?(Hash) ? event : nil
rescue JSON::ParserError
  nil
end

#private_keyOpenSSL::PKey::RSA

The private key for this exchange, which never leaves the process.

Returns:

  • (OpenSSL::PKey::RSA)

    the key pair



261
262
263
# File 'lib/kitchen/driver/gce/windows_password.rb', line 261

def private_key
  @private_key ||= OpenSSL::PKey::RSA.new(KEY_SIZE)
end

#public_keyOpenSSL::PKey::RSA

The public half of #private_key.

Returns:

  • (OpenSSL::PKey::RSA)

    the public key



268
269
270
# File 'lib/kitchen/driver/gce/windows_password.rb', line 268

def public_key
  private_key.public_key
end

#publish_public_keyvoid

This method returns an undefined value.

Replaces the instance's windows-keys metadata with our public key, leaving every other metadata entry untouched, and waits for the update to take effect.



128
129
130
131
132
133
134
135
136
137
138
# File 'lib/kitchen/driver/gce/windows_password.rb', line 128

def publish_public_key
   = 
  items    = Array(.items).reject { |item| item.key == METADATA_KEY }
  items << 
  .items = items

  driver.debug("Publishing a Windows password key to #{instance_name} for #{username}")
  driver.wait_for_operation(
    driver.connection.(driver.project, driver.zone, instance_name, )
  )
end

#response_from_serial_portHash?

Scans the serial port output for a response matching our key.

The port carries arbitrary boot logging, so every line that is not JSON, or is JSON but not an object, is skipped. The newest lines are examined first.

Returns:

  • (Hash, nil)

    the matching response, or nil if none is present



206
207
208
209
210
211
212
213
214
215
# File 'lib/kitchen/driver/gce/windows_password.rb', line 206

def response_from_serial_port
  serial_port_output.to_s.lines.reverse_each do |line|
    event = parse_event(line)
    next if event.nil?

    return event if event["modulus"] == modulus && event["exponent"] == exponent
  end

  nil
end

#serial_port_outputString?

The current contents of the instance's serial port.

Returns:

  • (String, nil)

    the serial port output



220
221
222
223
224
# File 'lib/kitchen/driver/gce/windows_password.rb', line 220

def serial_port_output
  driver.connection.get_instance_serial_port_output(
    driver.project, driver.zone, instance_name, port: SERIAL_PORT
  ).contents
end