Class: Puppetserver::Ca::CertificateAuthority

Inherits:
Object
  • Object
show all
Includes:
Utils
Defined in:
lib/puppetserver/ca/certificate_authority.rb

Constant Summary collapse

UNITMAP =

Taken from puppet/lib/settings/duration_settings.rb

{
  # 365 days isn't technically a year, but is sufficient for most purposes
  "y" => 365 * 24 * 60 * 60,
  "d" => 24 * 60 * 60,
  "h" => 60 * 60,
  "m" => 60,
  "s" => 1
}
REVOKE_BODY =
JSON.dump({ desired_state: 'revoked' })

Instance Method Summary collapse

Constructor Details

#initialize(logger, settings) ⇒ CertificateAuthority

Returns a new instance of CertificateAuthority.



23
24
25
26
27
28
# File 'lib/puppetserver/ca/certificate_authority.rb', line 23

def initialize(logger, settings)
  @logger = logger
  @client = HttpClient.new(@logger, settings)
  @ca_server = settings[:ca_server]
  @ca_port = settings[:ca_port]
end

Instance Method Details

#check_clean(certname, result) ⇒ Object

logs the action and returns a status symbol 👑



354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/puppetserver/ca/certificate_authority.rb', line 354

def check_clean(certname, result)
  case result.code
  when '200', '204'
    @logger.inform "Cleaned files related to #{certname}"
    return :success
  when '404'
    @logger.err 'Error:'
    @logger.err "    Could not find files to clean for #{certname}"
    return :not_found
  else
    @logger.err 'Error:'
    @logger.err "    When attempting to clean certificate '#{certname}', received:"
    @logger.err "      code: #{result.code}"
    @logger.err "      body: #{result.body.to_s}" if result.body
    return :error
  end
end

#check_revocation(certname, result) ⇒ Object

possibly logs the action, always returns a status symbol 👑



335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/puppetserver/ca/certificate_authority.rb', line 335

def check_revocation(certname, result)
  case result.code
  when '200', '204'
    @logger.inform "Certificate for #{certname} has been revoked"
    return :success
  when '409'
    return :invalid
  when '404'
    return :not_found
  else
    @logger.err 'Error:'
    @logger.err "    When attempting to revoke certificate '#{certname}', received:"
    @logger.err "      code: #{result.code}"
    @logger.err "      body: #{result.body.to_s}" if result.body
    return :error
  end
end

#clean_certs(certnames) ⇒ Boolean

Make an HTTP request to CA to clean the named certificates

Parameters:

  • certnames (Array)

    the name of the certificate(s) to have cleaned

Returns:

  • (Boolean)

    whether all certificate cleaning and revocation was successful



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
# File 'lib/puppetserver/ca/certificate_authority.rb', line 299

def clean_certs(certnames)
  url = make_ca_url('certificate_status')

  results = @client.with_connection(url) do |connection|
    certnames.map do |certname|
      url.resource_name = certname
      revoke_result = connection.put(REVOKE_BODY, url)
      revoked = check_revocation(certname, revoke_result)

      cleaned = nil
      unless revoked == :error
        clean_result = connection.delete(url)
        cleaned = check_clean(certname, clean_result)
      end

      if revoked == :error || cleaned != :success
        :error

      # If we get passed the first conditional we know that
      # cleaned must == :success and revoked must be one of
      # :invalid, :not_found, or :success. We'll treat both
      # :not_found and :success of revocation here as successes.
      # However we'll treat invalid's specially.
      elsif revoked == :invalid
        :invalid

      else
        :success
      end
    end
  end

  return results.reduce {|prev, curr| worst_result(prev, curr) }
end

#get(resource_type, resource_name, query = {}) ⇒ Struct

Make an HTTP GET request to CA

Parameters:

  • resource_type (String)

    the resource type of url

  • resource_name (String)

    the resource name of url

Returns:

  • (Struct)

    an instance of the Result struct with :code, :body



410
411
412
413
414
415
# File 'lib/puppetserver/ca/certificate_authority.rb', line 410

def get(resource_type, resource_name, query = {})
  url = make_ca_url(resource_type, resource_name, query)
  @client.with_connection(url) do |connection|
    connection.get(url)
  end
end

#get_certificate(certname) ⇒ Object

Returns nil for errors, else the result of the GET request



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/puppetserver/ca/certificate_authority.rb', line 387

def get_certificate(certname)
  result = get('certificate', certname)

  case result.code
  when '200'
    return result
  when '404'
    @logger.err 'Error:'
    @logger.err "    Signed certificate #{certname} could not be found on the CA"
    return nil
  else
    @logger.err 'Error:'
    @logger.err "    When attempting to download certificate '#{certname}', received:"
    @logger.err "      code: #{result.code}"
    @logger.err "      body: #{result.body.to_s}" if result.body
    return nil
  end
end

#get_certificate_statuses(query = {}) ⇒ Object

Returns nil for errors, else the result of the GET request



373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/puppetserver/ca/certificate_authority.rb', line 373

def get_certificate_statuses(query = {})
  result = get('certificate_statuses', 'any_key', query)

  unless result.code == '200'
    @logger.err 'Error:'
    @logger.err "    code: #{result.code}"
    @logger.err "    body: #{result.body}" if result.body
    return nil
  end

  result
end

#make_ca_url(resource_type = nil, certname = nil, query = {}) ⇒ Object

Returns a URI-like wrapper around CA specific urls



52
53
54
# File 'lib/puppetserver/ca/certificate_authority.rb', line 52

def make_ca_url(resource_type = nil, certname = nil, query = {})
  HttpClient::URL.new('https', @ca_server, @ca_port, 'puppet-ca', 'v1', resource_type, certname, query)
end

#post(resource_type:, resource_name: nil, body:, type:, headers: {}) ⇒ Boolean

Make an HTTP POST request to CA

Parameters:

  • endpoint (String)

    the endpoint to post to for the url

  • body (JSON/String)

    body of the post request

  • type (Symbol)

    type of error processing to perform on result

Returns:

  • (Boolean)

    whether all requests were successful



148
149
150
151
152
153
154
# File 'lib/puppetserver/ca/certificate_authority.rb', line 148

def post(resource_type:, resource_name: nil, body:, type:, headers: {})
  url = make_ca_url(resource_type, resource_name)
  results = @client.with_connection(url) do |connection|
    result = connection.post(body, url, headers)
    process_results(type, nil, result)
  end
end

#process_bulk_sign_result_data(result) ⇒ Object

Handle the result data from the /sign and /sign/all endpoints



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
# File 'lib/puppetserver/ca/certificate_authority.rb', line 157

def process_bulk_sign_result_data(result)
  data = JSON.parse(result.body)
  signed = data.dig('signed') || []
  no_csr = data.dig('no-csr') || []
  signing_errors = data.dig('signing-errors') || []

  if !signed.empty?
    @logger.inform "Successfully signed the following certificate requests:"
    signed.each { |s| @logger.inform "  #{s}" }
  end

  @logger.err 'Error:' if !no_csr.empty? || !signing_errors.empty?
  if !no_csr.empty?
    @logger.err '    No certificate request found for the following nodes when attempting to sign:'
    no_csr.each { |s| @logger.err "      #{s}" }
  end
  if !signing_errors.empty?
    @logger.err '    Error encountered when attempting to sign the certificate request for the following nodes:'
    signing_errors.each { |s| @logger.err "      #{s}" }
  end
  if no_csr.empty? && signing_errors.empty?
    @logger.err 'No waiting certificate requests to sign.' if signed.empty?
    return signed.empty? ? :no_requests : :success
  else
    return :error
  end
end

#process_results(type, certname, result) ⇒ Object

logs the action and returns true/false for success



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
# File 'lib/puppetserver/ca/certificate_authority.rb', line 186

def process_results(type, certname, result)
  case type
  when :sign
    case result.code
    when '204'
      @logger.inform "Successfully signed certificate request for #{certname}"
      return :success
    when '404'
      @logger.err 'Error:'
      @logger.err "    Could not find certificate request for #{certname}"
      return :not_found
    else
      @logger.err 'Error:'
      @logger.err "    When attempting to sign certificate request '#{certname}', received"
      @logger.err "      code: #{result.code}"
      @logger.err "      body: #{result.body.to_s}" if result.body
      return :error
    end
  when :sign_all
    if result.code == '200'
      if !result.body
        @logger.err 'Error:'
        @logger.err '    Response from /sign/all endpoint did not include a body. Unable to verify certificate requests were signed.'
        return :error
      end
      begin
        return process_bulk_sign_result_data(result)
      rescue JSON::ParserError
        @logger.err 'Error:'
        @logger.err '    Unable to parse the response from the /sign/all endpoint.'
        @logger.err "      body #{result.body.to_s}"
        return :error
      end
    else
      @logger.err 'Error:'
      @logger.err '    When attempting to sign all certificate requests, received:'
      @logger.err "      code: #{result.code}"
      @logger.err "      body: #{result.body.to_s}" if result.body
      return :error
    end
  when :sign_bulk
    if result.code == '200'
      if !result.body
        @logger.err 'Error:'
        @logger.err '    Response from /sign endpoint did not include a body. Unable to verify certificate requests were signed.'
        return :error
      end
      begin
        return process_bulk_sign_result_data(result)
      rescue JSON::ParserError
        @logger.err 'Error:'
        @logger.err '    Unable to parse the response from the /sign endpoint.'
        @logger.err "      body #{result.body.to_s}"
        return :error
      end
    else
      @logger.err 'Error:'
      @logger.err '    When attempting to sign certificate requests, received:'
      @logger.err "      code: #{result.code}"
      @logger.err "      body: #{result.body.to_s}" if result.body
      return :error
    end
  when :revoke
    case result.code
    when '200', '204'
      @logger.inform "Certificate for #{certname} has been revoked"
      return :success
    when '404'
      @logger.err 'Error:'
      @logger.err "    Could not find certificate for #{certname}"
      return :not_found
    when '409'
      @logger.err 'Error:'
      @logger.err "    Could not revoke unsigned csr for #{certname}"
      return :invalid
    else
      @logger.err 'Error:'
      @logger.err "    When attempting to revoke certificate '#{certname}', received:"
      @logger.err "      code: #{result.code}"
      @logger.err "      body: #{result.body.to_s}" if result.body
      return :error
    end
  when :submit
    case result.code
    when '200', '204'
      @logger.inform "Successfully submitted certificate request for #{certname}"
      return :success
    else
      @logger.err 'Error:'
      @logger.err "    When attempting to submit certificate request for '#{certname}', received:"
      @logger.err "      code: #{result.code}"
      @logger.err "      body: #{result.body.to_s}" if result.body
      return :error
    end
  when :server_version
    if result.code == '200' && result.body
      begin
        data = JSON.parse(result.body)
        version_str = data.dig('ca','service_version')
        return Gem::Version.new(version_str.match('^\d+\.\d+\.\d+')[0])
      rescue JSON::ParserError, NoMethodError
        # If we get bad JSON, version_str is nil, or the matcher doesn't match,
        # fall through to returning a version of 0.
      end
    end
    @logger.debug 'Could not detect server version. Defaulting to legacy signing endpoints.'
    return Gem::Version.new(0)
  end
end

#process_ttl_input(ttl) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/puppetserver/ca/certificate_authority.rb', line 56

def process_ttl_input(ttl)
  match = /^(\d+)(s|m|h|d|y)?$/.match(ttl)
  if match
    if match[2]
      match[1].to_i * UNITMAP[match[2]].to_i
    else
      ttl
    end
  else
    @logger.err "Error:"
    @logger.err " '#{ttl}' is an invalid ttl value"
    @logger.err "Value should match regex \"^(\d+)(s|m|h|d|y)?$\""
    nil
  end
end

#put(certnames, resource_type:, body:, type:, headers: {}) ⇒ Boolean

Make an HTTP PUT request to CA

Parameters:

  • resource_type (String)

    the resource type of url

  • certnames (Array)

    array of certnames

  • body (JSON/String)

    body of the put request

  • type (Symbol)

    type of error processing to perform on result

Returns:

  • (Boolean)

    whether all requests were successful



132
133
134
135
136
137
138
139
140
141
# File 'lib/puppetserver/ca/certificate_authority.rb', line 132

def put(certnames, resource_type:, body:, type:, headers: {})
  url = make_ca_url(resource_type)
  results = @client.with_connection(url) do |connection|
    certnames.map do |certname|
      url.resource_name = certname
      result = connection.put(body, url, headers)
      process_results(type, certname, result)
    end
  end
end

#revoke_certs(certnames) ⇒ Object



107
108
109
110
111
112
113
114
# File 'lib/puppetserver/ca/certificate_authority.rb', line 107

def revoke_certs(certnames)
  results = put(certnames,
              resource_type: 'certificate_status',
              body: REVOKE_BODY,
              type: :revoke)

  results.reduce { |prev, curr| worst_result(prev, curr) }
end

#server_has_bulk_signing_endpointsObject



30
31
32
33
34
35
36
37
# File 'lib/puppetserver/ca/certificate_authority.rb', line 30

def server_has_bulk_signing_endpoints
  url = HttpClient::URL.new('https', @ca_server, @ca_port, 'status', 'v1', 'services')
  result = @client.with_connection(url) do |connection|
    connection.get(url)
  end
  version = process_results(:server_version, nil, result)
  return version >= Gem::Version.new('8.4.0')
end

#sign_allObject



72
73
74
75
76
77
# File 'lib/puppetserver/ca/certificate_authority.rb', line 72

def sign_all
  return post(resource_type: 'sign',
    resource_name: 'all',
    body: '{}',
    type: :sign_all)
end

#sign_bulk(certnames) ⇒ Object



79
80
81
82
83
84
# File 'lib/puppetserver/ca/certificate_authority.rb', line 79

def sign_bulk(certnames)
  return post(resource_type: 'sign',
    body: "{\"certnames\":#{certnames}}",
    type: :sign_bulk
  )
end

#sign_certs(certnames, ttl = nil) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/puppetserver/ca/certificate_authority.rb', line 86

def sign_certs(certnames, ttl=nil)
  results = []
  if ttl
    lifetime = process_ttl_input(ttl)
    return false if lifetime.nil?
    body = JSON.dump({ desired_state: 'signed', cert_ttl: lifetime})
    results = put(certnames,
      resource_type: 'certificate_status',
      body: body,
      type: :sign)
  else
    results = put(certnames,
                  resource_type: 'certificate_status',
                  body: JSON.dump({ desired_state: 'signed' }),
                  type: :sign)
  end


  results.all? { |result| result == :success }
end

#submit_certificate_request(certname, csr) ⇒ Object



116
117
118
119
120
121
122
123
124
# File 'lib/puppetserver/ca/certificate_authority.rb', line 116

def submit_certificate_request(certname, csr)
  results = put([certname],
              resource_type: 'certificate_request',
              body: csr.to_pem,
              headers: {'Content-Type' => 'text/plain'},
              type: :submit)

  results.all? { |result| result == :success }
end

#worst_result(previous_result, current_result) ⇒ Object



39
40
41
42
43
44
45
46
47
48
49
# File 'lib/puppetserver/ca/certificate_authority.rb', line 39

def worst_result(previous_result, current_result)
  %i{success invalid not_found error}.each do |state|
    if previous_result == state
      return current_result
    elsif current_result == state
      return previous_result
    else
      next
    end
  end
end