File: state_machine.rb

package info (click to toggle)
puppet-agent 7.23.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 19,092 kB
  • sloc: ruby: 245,074; sh: 456; makefile: 38; xml: 33
file content (474 lines) | stat: -rw-r--r-- 17,443 bytes parent folder | download
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
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")

      cacerts = @cert_provider.load_cacerts
      if cacerts
        next_ctx = @ssl_provider.create_root_context(cacerts: cacerts, revocation: false)
      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 = Puppet::SSL::Digest.new(@machine.digest, pem).to_hex
          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)
    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
  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
    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)

          crl_ttl = Puppet[:crl_refresh_interval]
          if crl_ttl
            last_update = @cert_provider.crl_last_update
            now = Time.now
            if last_update.nil? || now.to_i > last_update.to_i + crl_ttl
              # set last updated time first, then make a best effort to refresh
              @cert_provider.crl_last_update = now
              next_ctx = refresh_crl(next_ctx, last_update)
            end
          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 refresh_crl(ssl_ctx, last_update)
      Puppet.info(_("Refreshing CRL"))

      # return the next_ctx containing the updated crl
      download_crl(ssl_ctx, last_update)
    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)

      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
          )
          return Done.new(@machine, next_ctx)
        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
  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.response.message}, e)
      end
    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 CA certs and CRLs.
  #
  # @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

  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