File: state_machine.rb

package info (click to toggle)
puppet-agent 8.10.0-6
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 27,404 kB
  • sloc: ruby: 286,820; sh: 492; xml: 116; makefile: 88; cs: 68
file content (605 lines) | stat: -rw-r--r-- 21,856 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
# frozen_string_literal: true

require_relative '../../puppet/ssl'
require_relative '../../puppet/util/pidlock'

# This class implements a state machine for bootstrapping a host's CA and CRL
# bundles, private key and signed client certificate. Each state has a frozen
# SSLContext that it uses to make network connections. If a state makes progress
# bootstrapping the host, then the state will generate a new frozen SSLContext
# and pass that to the next state. For example, the NeedCACerts state will load
# or download a CA bundle, and generate a new SSLContext containing those CA
# certs. This way we're sure about which SSLContext is being used during any
# phase of the bootstrapping process.
#
# @api private
class Puppet::SSL::StateMachine
  class SSLState
    attr_reader :ssl_context

    def initialize(machine, ssl_context)
      @machine = machine
      @ssl_context = ssl_context
      @cert_provider = machine.cert_provider
      @ssl_provider = machine.ssl_provider
    end

    def to_error(message, cause)
      detail = Puppet::Error.new(message)
      detail.set_backtrace(cause.backtrace)
      Error.new(@machine, message, detail)
    end

    def log_error(message)
      # When running daemonized we set stdout to /dev/null, so write to the log instead
      if Puppet[:daemonize]
        Puppet.err(message)
      else
        $stdout.puts(message)
      end
    end
  end

  # Load existing CA certs or download them. Transition to NeedCRLs.
  #
  class NeedCACerts < SSLState
    def initialize(machine)
      super(machine, nil)
      @ssl_context = @ssl_provider.create_insecure_context
    end

    def next_state
      Puppet.debug("Loading CA certs")

      force_crl_refresh = false

      cacerts = @cert_provider.load_cacerts
      if cacerts
        next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)

        now = Time.now
        last_update = @cert_provider.ca_last_update
        if needs_refresh?(now, last_update)
          # If we refresh the CA, then we need to force the CRL to be refreshed too,
          # since if there is a new CA in the chain, then we need its CRL to check
          # the full chain for revocation status.
          next_ctx, force_crl_refresh = refresh_ca(next_ctx, last_update)
        end
      else
        route = @machine.session.route_to(:ca, ssl_context: @ssl_context)
        _, pem = route.get_certificate(Puppet::SSL::CA_NAME, ssl_context: @ssl_context)
        if @machine.ca_fingerprint
          actual_digest = @machine.digest_as_hex(pem)
          expected_digest = @machine.ca_fingerprint.scan(/../).join(':').upcase
          if actual_digest == expected_digest
            Puppet.info(_("Verified CA bundle with digest (%{digest_type}) %{actual_digest}") %
                        { digest_type: @machine.digest, actual_digest: actual_digest })
          else
            e = Puppet::Error.new(_("CA bundle with digest (%{digest_type}) %{actual_digest} did not match expected digest %{expected_digest}") % { digest_type: @machine.digest, actual_digest: actual_digest, expected_digest: expected_digest })
            return Error.new(@machine, e.message, e)
          end
        end

        cacerts = @cert_provider.load_cacerts_from_pem(pem)
        # verify cacerts before saving
        next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)
        @cert_provider.save_cacerts(cacerts)
      end

      NeedCRLs.new(@machine, next_ctx, force_crl_refresh)
    rescue OpenSSL::X509::CertificateError => e
      Error.new(@machine, e.message, e)
    rescue Puppet::HTTP::ResponseError => e
      if e.response.code == 404
        to_error(_('CA certificate is missing from the server'), e)
      else
        to_error(_('Could not download CA certificate: %{message}') % { message: e.message }, e)
      end
    end

    private

    def needs_refresh?(now, last_update)
      return true if last_update.nil?

      ca_ttl = Puppet[:ca_refresh_interval]
      return false unless ca_ttl

      now.to_i > last_update.to_i + ca_ttl
    end

    def refresh_ca(ssl_ctx, last_update)
      Puppet.info(_("Refreshing CA certificate"))

      # return the next_ctx containing the updated ca
      next_ctx = [download_ca(ssl_ctx, last_update), true]

      # After a successful refresh, update ca_last_update
      @cert_provider.ca_last_update = Time.now

      next_ctx
    rescue Puppet::HTTP::ResponseError => e
      if e.response.code == 304
        Puppet.info(_("CA certificate is unmodified, using existing CA certificate"))
      else
        Puppet.info(_("Failed to refresh CA certificate, using existing CA certificate: %{message}") % { message: e.message })
      end

      # return the original ssl_ctx
      [ssl_ctx, false]
    rescue Puppet::HTTP::HTTPError => e
      Puppet.warning(_("Failed to refresh CA certificate, using existing CA certificate: %{message}") % { message: e.message })

      # return the original ssl_ctx
      [ssl_ctx, false]
    end

    def download_ca(ssl_ctx, last_update)
      route = @machine.session.route_to(:ca, ssl_context: ssl_ctx)
      _, pem = route.get_certificate(Puppet::SSL::CA_NAME, if_modified_since: last_update, ssl_context: ssl_ctx)
      cacerts = @cert_provider.load_cacerts_from_pem(pem)
      # verify cacerts before saving
      next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)
      @cert_provider.save_cacerts(cacerts)

      Puppet.info("Refreshed CA certificate: #{@machine.digest_as_hex(pem)}")

      next_ctx
    end
  end

  # If revocation is enabled, load CRLs or download them, using the CA bundle
  # from the previous state. Transition to NeedKey. Even if Puppet[:certificate_revocation]
  # is leaf or chain, disable revocation when downloading the CRL, since 1) we may
  # not have one yet or 2) the connection will fail if NeedCACerts downloaded a new CA
  # for which we don't have a CRL
  #
  class NeedCRLs < SSLState
    attr_reader :force_crl_refresh

    def initialize(machine, ssl_context, force_crl_refresh = false)
      super(machine, ssl_context)
      @force_crl_refresh = force_crl_refresh
    end

    def next_state
      Puppet.debug("Loading CRLs")

      case Puppet[:certificate_revocation]
      when :chain, :leaf
        crls = @cert_provider.load_crls
        if crls
          next_ctx = @ssl_provider.create_root_context(cacerts: ssl_context[:cacerts], crls: crls)

          now = Time.now
          last_update = @cert_provider.crl_last_update
          if needs_refresh?(now, last_update)
            next_ctx = refresh_crl(next_ctx, last_update)
          end
        else
          next_ctx = download_crl(@ssl_context, nil)
        end
      else
        Puppet.info("Certificate revocation is disabled, skipping CRL download")
        next_ctx = @ssl_provider.create_root_context(cacerts: ssl_context[:cacerts], crls: [])
      end

      NeedKey.new(@machine, next_ctx)
    rescue OpenSSL::X509::CRLError => e
      Error.new(@machine, e.message, e)
    rescue Puppet::HTTP::ResponseError => e
      if e.response.code == 404
        to_error(_('CRL is missing from the server'), e)
      else
        to_error(_('Could not download CRLs: %{message}') % { message: e.message }, e)
      end
    end

    private

    def needs_refresh?(now, last_update)
      return true if @force_crl_refresh || last_update.nil?

      crl_ttl = Puppet[:crl_refresh_interval]
      return false unless crl_ttl

      now.to_i > last_update.to_i + crl_ttl
    end

    def refresh_crl(ssl_ctx, last_update)
      Puppet.info(_("Refreshing CRL"))

      # return the next_ctx containing the updated crl
      next_ctx = download_crl(ssl_ctx, last_update)

      # After a successful refresh, update crl_last_update
      @cert_provider.crl_last_update = Time.now

      next_ctx
    rescue Puppet::HTTP::ResponseError => e
      if e.response.code == 304
        Puppet.info(_("CRL is unmodified, using existing CRL"))
      else
        Puppet.info(_("Failed to refresh CRL, using existing CRL: %{message}") % { message: e.message })
      end

      # return the original ssl_ctx
      ssl_ctx
    rescue Puppet::HTTP::HTTPError => e
      Puppet.warning(_("Failed to refresh CRL, using existing CRL: %{message}") % { message: e.message })

      # return the original ssl_ctx
      ssl_ctx
    end

    def download_crl(ssl_ctx, last_update)
      route = @machine.session.route_to(:ca, ssl_context: ssl_ctx)
      _, pem = route.get_certificate_revocation_list(if_modified_since: last_update, ssl_context: ssl_ctx)
      crls = @cert_provider.load_crls_from_pem(pem)
      # verify crls before saving
      next_ctx = @ssl_provider.create_root_context(cacerts: ssl_ctx[:cacerts], crls: crls)
      @cert_provider.save_crls(crls)

      Puppet.info("Refreshed CRL: #{@machine.digest_as_hex(pem)}")

      next_ctx
    end
  end

  # Load or generate a private key. If the key exists, try to load the client cert
  # and transition to Done. If the cert is mismatched or otherwise fails valiation,
  # raise an error. If the key doesn't exist yet, generate one, and save it. If the
  # cert doesn't exist yet, transition to NeedSubmitCSR.
  #
  class NeedKey < SSLState
    def next_state
      Puppet.debug(_("Loading/generating private key"))

      password = @cert_provider.load_private_key_password
      key = @cert_provider.load_private_key(Puppet[:certname], password: password)
      if key
        cert = @cert_provider.load_client_cert(Puppet[:certname])
        if cert
          next_ctx = @ssl_provider.create_context(
            cacerts: @ssl_context.cacerts, crls: @ssl_context.crls, private_key: key, client_cert: cert
          )
          if needs_refresh?(cert)
            return NeedRenewedCert.new(@machine, next_ctx, key)
          else
            return Done.new(@machine, next_ctx)
          end
        end
      else
        if Puppet[:key_type] == 'ec'
          Puppet.info _("Creating a new EC SSL key for %{name} using curve %{curve}") % { name: Puppet[:certname], curve: Puppet[:named_curve] }
          key = OpenSSL::PKey::EC.generate(Puppet[:named_curve])
        else
          Puppet.info _("Creating a new RSA SSL key for %{name}") % { name: Puppet[:certname] }
          key = OpenSSL::PKey::RSA.new(Puppet[:keylength].to_i)
        end

        @cert_provider.save_private_key(Puppet[:certname], key, password: password)
      end

      NeedSubmitCSR.new(@machine, @ssl_context, key)
    end

    private

    def needs_refresh?(cert)
      cert_ttl = Puppet[:hostcert_renewal_interval]
      return false unless cert_ttl

      Time.now.to_i >= (cert.not_after.to_i - cert_ttl)
    end
  end

  # Base class for states with a private key.
  #
  class KeySSLState < SSLState
    attr_reader :private_key

    def initialize(machine, ssl_context, private_key)
      super(machine, ssl_context)
      @private_key = private_key
    end
  end

  # Generate and submit a CSR using the CA cert bundle and optional CRL bundle
  # from earlier states. If the request is submitted, proceed to NeedCert,
  # otherwise Wait. This could be due to the server already having a CSR
  # for this host (either the same or different CSR content), having a
  # signed certificate, or a revoked certificate.
  #
  class NeedSubmitCSR < KeySSLState
    def next_state
      Puppet.debug(_("Generating and submitting a CSR"))

      csr = @cert_provider.create_request(Puppet[:certname], @private_key)
      route = @machine.session.route_to(:ca, ssl_context: @ssl_context)
      route.put_certificate_request(Puppet[:certname], csr, ssl_context: @ssl_context)
      @cert_provider.save_request(Puppet[:certname], csr)
      NeedCert.new(@machine, @ssl_context, @private_key)
    rescue Puppet::HTTP::ResponseError => e
      if e.response.code == 400
        NeedCert.new(@machine, @ssl_context, @private_key)
      else
        to_error(_("Failed to submit the CSR, HTTP response was %{code}") % { code: e.response.code }, e)
      end
    end
  end

  # Attempt to load or retrieve our signed cert.
  #
  class NeedCert < KeySSLState
    def next_state
      Puppet.debug(_("Downloading client certificate"))

      route = @machine.session.route_to(:ca, ssl_context: @ssl_context)
      cert = OpenSSL::X509::Certificate.new(
        route.get_certificate(Puppet[:certname], ssl_context: @ssl_context)[1]
      )
      Puppet.info _("Downloaded certificate for %{name} from %{url}") % { name: Puppet[:certname], url: route.url }
      # verify client cert before saving
      next_ctx = @ssl_provider.create_context(
        cacerts: @ssl_context.cacerts, crls: @ssl_context.crls, private_key: @private_key, client_cert: cert
      )
      @cert_provider.save_client_cert(Puppet[:certname], cert)
      @cert_provider.delete_request(Puppet[:certname])
      Done.new(@machine, next_ctx)
    rescue Puppet::SSL::SSLError => e
      Error.new(@machine, e.message, e)
    rescue OpenSSL::X509::CertificateError => e
      Error.new(@machine, _("Failed to parse certificate: %{message}") % { message: e.message }, e)
    rescue Puppet::HTTP::ResponseError => e
      if e.response.code == 404
        Puppet.info(_("Certificate for %{certname} has not been signed yet") % { certname: Puppet[:certname] })
        $stdout.puts _("Couldn't fetch certificate from CA server; you might still need to sign this agent's certificate (%{name}).") % { name: Puppet[:certname] }
        Wait.new(@machine)
      else
        to_error(_("Failed to retrieve certificate for %{certname}: %{message}") %
                 { certname: Puppet[:certname], message: e.message }, e)
      end
    end
  end

  # Class to renew a client/host certificate automatically.
  #
  class NeedRenewedCert < KeySSLState
    def next_state
      Puppet.debug(_("Renewing client certificate"))

      route = @machine.session.route_to(:ca, ssl_context: @ssl_context)
      cert = OpenSSL::X509::Certificate.new(
        route.post_certificate_renewal(@ssl_context)[1]
      )

      # verify client cert before saving
      next_ctx = @ssl_provider.create_context(
        cacerts: @ssl_context.cacerts, crls: @ssl_context.crls, private_key: @private_key, client_cert: cert
      )
      @cert_provider.save_client_cert(Puppet[:certname], cert)

      Puppet.info(_("Renewed client certificate: %{cert_digest}, not before '%{not_before}', not after '%{not_after}'") % { cert_digest: @machine.digest_as_hex(cert.to_pem), not_before: cert.not_before, not_after: cert.not_after })

      Done.new(@machine, next_ctx)
    rescue Puppet::HTTP::ResponseError => e
      if e.response.code == 404
        Puppet.info(_("Certificate autorenewal has not been enabled on the server."))
      else
        Puppet.warning(_("Failed to automatically renew certificate: %{code} %{reason}") % { code: e.response.code, reason: e.response.reason })
      end
      Done.new(@machine, @ssl_context)
    rescue => e
      Puppet.warning(_("Unable to automatically renew certificate: %{message}") % { message: e.message })
      Done.new(@machine, @ssl_context)
    end
  end

  # We cannot make progress, so wait if allowed to do so, or exit.
  #
  class Wait < SSLState
    def initialize(machine)
      super(machine, nil)
    end

    def next_state
      time = @machine.waitforcert
      if time < 1
        log_error(_("Exiting now because the waitforcert setting is set to 0."))
        exit(1)
      elsif Time.now.to_i > @machine.wait_deadline
        log_error(_("Couldn't fetch certificate from CA server; you might still need to sign this agent's certificate (%{name}). Exiting now because the maxwaitforcert timeout has been exceeded.") % { name: Puppet[:certname] })
        exit(1)
      else
        Puppet.info(_("Will try again in %{time} seconds.") % { time: time })

        # close http/tls and session state before sleeping
        Puppet.runtime[:http].close
        @machine.session = Puppet.runtime[:http].create_session

        @machine.unlock
        Kernel.sleep(time)
        NeedLock.new(@machine)
      end
    end
  end

  # Acquire the ssl lock or return LockFailure causing us to exit.
  #
  class NeedLock < SSLState
    def initialize(machine)
      super(machine, nil)
    end

    def next_state
      if @machine.lock
        # our ssl directory may have been cleaned while we were
        # sleeping, start over from the top
        NeedCACerts.new(@machine)
      elsif @machine.waitforlock < 1
        LockFailure.new(@machine, _("Another puppet instance is already running and the waitforlock setting is set to 0; exiting"))
      elsif Time.now.to_i >= @machine.waitlock_deadline
        LockFailure.new(@machine, _("Another puppet instance is already running and the maxwaitforlock timeout has been exceeded; exiting"))
      else
        Puppet.info _("Another puppet instance is already running; waiting for it to finish")
        Puppet.info _("Will try again in %{time} seconds.") % { time: @machine.waitforlock }
        Kernel.sleep @machine.waitforlock

        # try again
        self
      end
    end
  end

  # We failed to acquire the lock, so exit
  #
  class LockFailure < SSLState
    attr_reader :message

    def initialize(machine, message)
      super(machine, nil)
      @message = message
    end
  end

  # We cannot make progress due to an error.
  #
  class Error < SSLState
    attr_reader :message, :error

    def initialize(machine, message, error)
      super(machine, nil)
      @message = message
      @error = error
    end

    def next_state
      Puppet.log_exception(@error, @message)
      Wait.new(@machine)
    end
  end

  # We have a CA bundle, optional CRL bundle, a private key and matching cert
  # that chains to one of the root certs in our bundle.
  #
  class Done < SSLState; end

  attr_reader :waitforcert, :wait_deadline, :waitforlock, :waitlock_deadline, :cert_provider, :ssl_provider, :ca_fingerprint, :digest
  attr_accessor :session

  # Construct a state machine to manage the SSL initialization process. By
  # default, if the state machine encounters an exception, it will log the
  # exception and wait for `waitforcert` seconds and retry, restarting from the
  # beginning of the state machine.
  #
  # However, if `onetime` is true, then the state machine will raise the first
  # error it encounters, instead of waiting. Otherwise, if `waitforcert` is 0,
  # then then state machine will exit instead of wait.
  #
  # @param waitforcert [Integer] how many seconds to wait between attempts
  # @param maxwaitforcert [Integer] maximum amount of seconds to wait for the
  #   server to sign the certificate request
  # @param waitforlock [Integer] how many seconds to wait between attempts for
  #   acquiring the ssl lock
  # @param maxwaitforlock [Integer] maximum amount of seconds to wait for an
  #   already running process to release the ssl lock
  # @param onetime [Boolean] whether to run onetime
  # @param lockfile [Puppet::Util::Pidlock] lockfile to protect against
  #   concurrent modification by multiple processes
  # @param cert_provider [Puppet::X509::CertProvider] cert provider to use
  #   to load and save X509 objects.
  # @param ssl_provider [Puppet::SSL::SSLProvider] ssl provider to use
  #   to construct ssl contexts.
  # @param digest [String] digest algorithm to use for certificate fingerprinting
  # @param ca_fingerprint [String] optional fingerprint to verify the
  #   downloaded CA bundle
  def initialize(waitforcert: Puppet[:waitforcert],
                 maxwaitforcert: Puppet[:maxwaitforcert],
                 waitforlock: Puppet[:waitforlock],
                 maxwaitforlock: Puppet[:maxwaitforlock],
                 onetime: Puppet[:onetime],
                 cert_provider: Puppet::X509::CertProvider.new,
                 ssl_provider: Puppet::SSL::SSLProvider.new,
                 lockfile: Puppet::Util::Pidlock.new(Puppet[:ssl_lockfile]),
                 digest: 'SHA256',
                 ca_fingerprint: Puppet[:ca_fingerprint])
    @waitforcert = waitforcert
    @wait_deadline = Time.now.to_i + maxwaitforcert
    @waitforlock = waitforlock
    @waitlock_deadline = Time.now.to_i + maxwaitforlock
    @onetime = onetime
    @cert_provider = cert_provider
    @ssl_provider = ssl_provider
    @lockfile = lockfile
    @digest = digest
    @ca_fingerprint = ca_fingerprint
    @session = Puppet.runtime[:http].create_session
  end

  # Run the state machine for CA certs and CRLs.
  #
  # @return [Puppet::SSL::SSLContext] initialized SSLContext
  # @raise [Puppet::Error] If we fail to generate an SSLContext
  # @api private
  def ensure_ca_certificates
    final_state = run_machine(NeedLock.new(self), NeedKey)
    final_state.ssl_context
  end

  # Run the state machine for client certs.
  #
  # @return [Puppet::SSL::SSLContext] initialized SSLContext
  # @raise [Puppet::Error] If we fail to generate an SSLContext
  # @api private
  def ensure_client_certificate
    final_state = run_machine(NeedLock.new(self), Done)
    ssl_context = final_state.ssl_context
    @ssl_provider.print(ssl_context, @digest)
    ssl_context
  end

  def lock
    @lockfile.lock
  end

  def unlock
    @lockfile.unlock
  end

  def digest_as_hex(str)
    Puppet::SSL::Digest.new(digest, str).to_hex
  end

  private

  def run_machine(state, stop)
    loop do
      state = run_step(state)

      case state
      when stop
        break
      when LockFailure
        raise Puppet::Error, state.message
      when Error
        if @onetime
          Puppet.log_exception(state.error)
          raise state.error
        end
      else
        # fall through
      end
    end

    state
  ensure
    @lockfile.unlock if @lockfile.locked?
  end

  def run_step(state)
    state.next_state
  rescue => e
    state.to_error(e.message, e)
  end
end