openvoxserver-ca 3.0.0.pre.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.github/dependabot.yml +17 -0
  3. data/.github/release.yml +41 -0
  4. data/.github/workflows/gem_release.yaml +106 -0
  5. data/.github/workflows/prepare_release.yml +28 -0
  6. data/.github/workflows/release.yml +28 -0
  7. data/.github/workflows/unit_tests.yaml +45 -0
  8. data/.gitignore +14 -0
  9. data/.rspec +2 -0
  10. data/.travis.yml +16 -0
  11. data/CHANGELOG.md +15 -0
  12. data/CODEOWNERS +4 -0
  13. data/CODE_OF_CONDUCT.md +74 -0
  14. data/CONTRIBUTING.md +15 -0
  15. data/Gemfile +20 -0
  16. data/LICENSE +202 -0
  17. data/README.md +118 -0
  18. data/Rakefile +30 -0
  19. data/bin/console +14 -0
  20. data/bin/setup +8 -0
  21. data/exe/puppetserver-ca +10 -0
  22. data/lib/puppetserver/ca/action/clean.rb +109 -0
  23. data/lib/puppetserver/ca/action/delete.rb +286 -0
  24. data/lib/puppetserver/ca/action/enable.rb +140 -0
  25. data/lib/puppetserver/ca/action/generate.rb +330 -0
  26. data/lib/puppetserver/ca/action/import.rb +196 -0
  27. data/lib/puppetserver/ca/action/list.rb +253 -0
  28. data/lib/puppetserver/ca/action/migrate.rb +97 -0
  29. data/lib/puppetserver/ca/action/prune.rb +289 -0
  30. data/lib/puppetserver/ca/action/revoke.rb +108 -0
  31. data/lib/puppetserver/ca/action/setup.rb +188 -0
  32. data/lib/puppetserver/ca/action/sign.rb +146 -0
  33. data/lib/puppetserver/ca/certificate_authority.rb +418 -0
  34. data/lib/puppetserver/ca/cli.rb +145 -0
  35. data/lib/puppetserver/ca/config/puppet.rb +309 -0
  36. data/lib/puppetserver/ca/config/puppetserver.rb +84 -0
  37. data/lib/puppetserver/ca/errors.rb +40 -0
  38. data/lib/puppetserver/ca/host.rb +176 -0
  39. data/lib/puppetserver/ca/local_certificate_authority.rb +304 -0
  40. data/lib/puppetserver/ca/logger.rb +49 -0
  41. data/lib/puppetserver/ca/stub.rb +17 -0
  42. data/lib/puppetserver/ca/utils/cli_parsing.rb +67 -0
  43. data/lib/puppetserver/ca/utils/config.rb +61 -0
  44. data/lib/puppetserver/ca/utils/file_system.rb +109 -0
  45. data/lib/puppetserver/ca/utils/http_client.rb +232 -0
  46. data/lib/puppetserver/ca/utils/inventory.rb +84 -0
  47. data/lib/puppetserver/ca/utils/signing_digest.rb +27 -0
  48. data/lib/puppetserver/ca/version.rb +5 -0
  49. data/lib/puppetserver/ca/x509_loader.rb +170 -0
  50. data/lib/puppetserver/ca.rb +7 -0
  51. data/openvoxserver-ca.gemspec +31 -0
  52. data/tasks/spec.rake +15 -0
  53. data/tasks/vox.rake +19 -0
  54. metadata +154 -0
@@ -0,0 +1,418 @@
1
+ require 'json'
2
+
3
+ require 'puppetserver/ca/utils/http_client'
4
+
5
+ module Puppetserver
6
+ module Ca
7
+ class CertificateAuthority
8
+
9
+ include Puppetserver::Ca::Utils
10
+
11
+ # Taken from puppet/lib/settings/duration_settings.rb
12
+ UNITMAP = {
13
+ # 365 days isn't technically a year, but is sufficient for most purposes
14
+ "y" => 365 * 24 * 60 * 60,
15
+ "d" => 24 * 60 * 60,
16
+ "h" => 60 * 60,
17
+ "m" => 60,
18
+ "s" => 1
19
+ }
20
+
21
+ REVOKE_BODY = JSON.dump({ desired_state: 'revoked' })
22
+
23
+ def initialize(logger, settings)
24
+ @logger = logger
25
+ @client = HttpClient.new(@logger, settings)
26
+ @ca_server = settings[:ca_server]
27
+ @ca_port = settings[:ca_port]
28
+ end
29
+
30
+ def server_has_bulk_signing_endpoints
31
+ url = HttpClient::URL.new('https', @ca_server, @ca_port, 'status', 'v1', 'services')
32
+ result = @client.with_connection(url) do |connection|
33
+ connection.get(url)
34
+ end
35
+ version = process_results(:server_version, nil, result)
36
+ return version >= Gem::Version.new('8.4.0')
37
+ end
38
+
39
+ def worst_result(previous_result, current_result)
40
+ %i{success invalid not_found error}.each do |state|
41
+ if previous_result == state
42
+ return current_result
43
+ elsif current_result == state
44
+ return previous_result
45
+ else
46
+ next
47
+ end
48
+ end
49
+ end
50
+
51
+ # Returns a URI-like wrapper around CA specific urls
52
+ def make_ca_url(resource_type = nil, certname = nil, query = {})
53
+ HttpClient::URL.new('https', @ca_server, @ca_port, 'puppet-ca', 'v1', resource_type, certname, query)
54
+ end
55
+
56
+ def process_ttl_input(ttl)
57
+ match = /^(\d+)(s|m|h|d|y)?$/.match(ttl)
58
+ if match
59
+ if match[2]
60
+ match[1].to_i * UNITMAP[match[2]].to_i
61
+ else
62
+ ttl
63
+ end
64
+ else
65
+ @logger.err "Error:"
66
+ @logger.err " '#{ttl}' is an invalid ttl value"
67
+ @logger.err "Value should match regex \"^(\d+)(s|m|h|d|y)?$\""
68
+ nil
69
+ end
70
+ end
71
+
72
+ def sign_all
73
+ return post(resource_type: 'sign',
74
+ resource_name: 'all',
75
+ body: '{}',
76
+ type: :sign_all)
77
+ end
78
+
79
+ def sign_bulk(certnames)
80
+ return post(resource_type: 'sign',
81
+ body: "{\"certnames\":#{certnames}}",
82
+ type: :sign_bulk
83
+ )
84
+ end
85
+
86
+ def sign_certs(certnames, ttl=nil)
87
+ results = []
88
+ if ttl
89
+ lifetime = process_ttl_input(ttl)
90
+ return false if lifetime.nil?
91
+ body = JSON.dump({ desired_state: 'signed', cert_ttl: lifetime})
92
+ results = put(certnames,
93
+ resource_type: 'certificate_status',
94
+ body: body,
95
+ type: :sign)
96
+ else
97
+ results = put(certnames,
98
+ resource_type: 'certificate_status',
99
+ body: JSON.dump({ desired_state: 'signed' }),
100
+ type: :sign)
101
+ end
102
+
103
+
104
+ results.all? { |result| result == :success }
105
+ end
106
+
107
+ def revoke_certs(certnames)
108
+ results = put(certnames,
109
+ resource_type: 'certificate_status',
110
+ body: REVOKE_BODY,
111
+ type: :revoke)
112
+
113
+ results.reduce { |prev, curr| worst_result(prev, curr) }
114
+ end
115
+
116
+ def submit_certificate_request(certname, csr)
117
+ results = put([certname],
118
+ resource_type: 'certificate_request',
119
+ body: csr.to_pem,
120
+ headers: {'Content-Type' => 'text/plain'},
121
+ type: :submit)
122
+
123
+ results.all? { |result| result == :success }
124
+ end
125
+
126
+ # Make an HTTP PUT request to CA
127
+ # @param resource_type [String] the resource type of url
128
+ # @param certnames [Array] array of certnames
129
+ # @param body [JSON/String] body of the put request
130
+ # @param type [Symbol] type of error processing to perform on result
131
+ # @return [Boolean] whether all requests were successful
132
+ def put(certnames, resource_type:, body:, type:, headers: {})
133
+ url = make_ca_url(resource_type)
134
+ results = @client.with_connection(url) do |connection|
135
+ certnames.map do |certname|
136
+ url.resource_name = certname
137
+ result = connection.put(body, url, headers)
138
+ process_results(type, certname, result)
139
+ end
140
+ end
141
+ end
142
+
143
+ # Make an HTTP POST request to CA
144
+ # @param endpoint [String] the endpoint to post to for the url
145
+ # @param body [JSON/String] body of the post request
146
+ # @param type [Symbol] type of error processing to perform on result
147
+ # @return [Boolean] whether all requests were successful
148
+ def post(resource_type:, resource_name: nil, body:, type:, headers: {})
149
+ url = make_ca_url(resource_type, resource_name)
150
+ results = @client.with_connection(url) do |connection|
151
+ result = connection.post(body, url, headers)
152
+ process_results(type, nil, result)
153
+ end
154
+ end
155
+
156
+ # Handle the result data from the /sign and /sign/all endpoints
157
+ def process_bulk_sign_result_data(result)
158
+ data = JSON.parse(result.body)
159
+ signed = data.dig('signed') || []
160
+ no_csr = data.dig('no-csr') || []
161
+ signing_errors = data.dig('signing-errors') || []
162
+
163
+ if !signed.empty?
164
+ @logger.inform "Successfully signed the following certificate requests:"
165
+ signed.each { |s| @logger.inform " #{s}" }
166
+ end
167
+
168
+ @logger.err 'Error:' if !no_csr.empty? || !signing_errors.empty?
169
+ if !no_csr.empty?
170
+ @logger.err ' No certificate request found for the following nodes when attempting to sign:'
171
+ no_csr.each { |s| @logger.err " #{s}" }
172
+ end
173
+ if !signing_errors.empty?
174
+ @logger.err ' Error encountered when attempting to sign the certificate request for the following nodes:'
175
+ signing_errors.each { |s| @logger.err " #{s}" }
176
+ end
177
+ if no_csr.empty? && signing_errors.empty?
178
+ @logger.err 'No waiting certificate requests to sign.' if signed.empty?
179
+ return signed.empty? ? :no_requests : :success
180
+ else
181
+ return :error
182
+ end
183
+ end
184
+
185
+ # logs the action and returns true/false for success
186
+ def process_results(type, certname, result)
187
+ case type
188
+ when :sign
189
+ case result.code
190
+ when '204'
191
+ @logger.inform "Successfully signed certificate request for #{certname}"
192
+ return :success
193
+ when '404'
194
+ @logger.err 'Error:'
195
+ @logger.err " Could not find certificate request for #{certname}"
196
+ return :not_found
197
+ else
198
+ @logger.err 'Error:'
199
+ @logger.err " When attempting to sign certificate request '#{certname}', received"
200
+ @logger.err " code: #{result.code}"
201
+ @logger.err " body: #{result.body.to_s}" if result.body
202
+ return :error
203
+ end
204
+ when :sign_all
205
+ if result.code == '200'
206
+ if !result.body
207
+ @logger.err 'Error:'
208
+ @logger.err ' Response from /sign/all endpoint did not include a body. Unable to verify certificate requests were signed.'
209
+ return :error
210
+ end
211
+ begin
212
+ return process_bulk_sign_result_data(result)
213
+ rescue JSON::ParserError
214
+ @logger.err 'Error:'
215
+ @logger.err ' Unable to parse the response from the /sign/all endpoint.'
216
+ @logger.err " body #{result.body.to_s}"
217
+ return :error
218
+ end
219
+ else
220
+ @logger.err 'Error:'
221
+ @logger.err ' When attempting to sign all certificate requests, received:'
222
+ @logger.err " code: #{result.code}"
223
+ @logger.err " body: #{result.body.to_s}" if result.body
224
+ return :error
225
+ end
226
+ when :sign_bulk
227
+ if result.code == '200'
228
+ if !result.body
229
+ @logger.err 'Error:'
230
+ @logger.err ' Response from /sign endpoint did not include a body. Unable to verify certificate requests were signed.'
231
+ return :error
232
+ end
233
+ begin
234
+ return process_bulk_sign_result_data(result)
235
+ rescue JSON::ParserError
236
+ @logger.err 'Error:'
237
+ @logger.err ' Unable to parse the response from the /sign endpoint.'
238
+ @logger.err " body #{result.body.to_s}"
239
+ return :error
240
+ end
241
+ else
242
+ @logger.err 'Error:'
243
+ @logger.err ' When attempting to sign certificate requests, received:'
244
+ @logger.err " code: #{result.code}"
245
+ @logger.err " body: #{result.body.to_s}" if result.body
246
+ return :error
247
+ end
248
+ when :revoke
249
+ case result.code
250
+ when '200', '204'
251
+ @logger.inform "Certificate for #{certname} has been revoked"
252
+ return :success
253
+ when '404'
254
+ @logger.err 'Error:'
255
+ @logger.err " Could not find certificate for #{certname}"
256
+ return :not_found
257
+ when '409'
258
+ @logger.err 'Error:'
259
+ @logger.err " Could not revoke unsigned csr for #{certname}"
260
+ return :invalid
261
+ else
262
+ @logger.err 'Error:'
263
+ @logger.err " When attempting to revoke certificate '#{certname}', received:"
264
+ @logger.err " code: #{result.code}"
265
+ @logger.err " body: #{result.body.to_s}" if result.body
266
+ return :error
267
+ end
268
+ when :submit
269
+ case result.code
270
+ when '200', '204'
271
+ @logger.inform "Successfully submitted certificate request for #{certname}"
272
+ return :success
273
+ else
274
+ @logger.err 'Error:'
275
+ @logger.err " When attempting to submit certificate request for '#{certname}', received:"
276
+ @logger.err " code: #{result.code}"
277
+ @logger.err " body: #{result.body.to_s}" if result.body
278
+ return :error
279
+ end
280
+ when :server_version
281
+ if result.code == '200' && result.body
282
+ begin
283
+ data = JSON.parse(result.body)
284
+ version_str = data.dig('ca','service_version')
285
+ return Gem::Version.new(version_str.match('^\d+\.\d+\.\d+')[0])
286
+ rescue JSON::ParserError, NoMethodError
287
+ # If we get bad JSON, version_str is nil, or the matcher doesn't match,
288
+ # fall through to returning a version of 0.
289
+ end
290
+ end
291
+ @logger.debug 'Could not detect server version. Defaulting to legacy signing endpoints.'
292
+ return Gem::Version.new(0)
293
+ end
294
+ end
295
+
296
+ # Make an HTTP request to CA to clean the named certificates
297
+ # @param certnames [Array] the name of the certificate(s) to have cleaned
298
+ # @return [Boolean] whether all certificate cleaning and revocation was successful
299
+ def clean_certs(certnames)
300
+ url = make_ca_url('certificate_status')
301
+
302
+ results = @client.with_connection(url) do |connection|
303
+ certnames.map do |certname|
304
+ url.resource_name = certname
305
+ revoke_result = connection.put(REVOKE_BODY, url)
306
+ revoked = check_revocation(certname, revoke_result)
307
+
308
+ cleaned = nil
309
+ unless revoked == :error
310
+ clean_result = connection.delete(url)
311
+ cleaned = check_clean(certname, clean_result)
312
+ end
313
+
314
+ if revoked == :error || cleaned != :success
315
+ :error
316
+
317
+ # If we get passed the first conditional we know that
318
+ # cleaned must == :success and revoked must be one of
319
+ # :invalid, :not_found, or :success. We'll treat both
320
+ # :not_found and :success of revocation here as successes.
321
+ # However we'll treat invalid's specially.
322
+ elsif revoked == :invalid
323
+ :invalid
324
+
325
+ else
326
+ :success
327
+ end
328
+ end
329
+ end
330
+
331
+ return results.reduce {|prev, curr| worst_result(prev, curr) }
332
+ end
333
+
334
+ # possibly logs the action, always returns a status symbol 👑
335
+ def check_revocation(certname, result)
336
+ case result.code
337
+ when '200', '204'
338
+ @logger.inform "Certificate for #{certname} has been revoked"
339
+ return :success
340
+ when '409'
341
+ return :invalid
342
+ when '404'
343
+ return :not_found
344
+ else
345
+ @logger.err 'Error:'
346
+ @logger.err " When attempting to revoke certificate '#{certname}', received:"
347
+ @logger.err " code: #{result.code}"
348
+ @logger.err " body: #{result.body.to_s}" if result.body
349
+ return :error
350
+ end
351
+ end
352
+
353
+ # logs the action and returns a status symbol 👑
354
+ def check_clean(certname, result)
355
+ case result.code
356
+ when '200', '204'
357
+ @logger.inform "Cleaned files related to #{certname}"
358
+ return :success
359
+ when '404'
360
+ @logger.err 'Error:'
361
+ @logger.err " Could not find files to clean for #{certname}"
362
+ return :not_found
363
+ else
364
+ @logger.err 'Error:'
365
+ @logger.err " When attempting to clean certificate '#{certname}', received:"
366
+ @logger.err " code: #{result.code}"
367
+ @logger.err " body: #{result.body.to_s}" if result.body
368
+ return :error
369
+ end
370
+ end
371
+
372
+ # Returns nil for errors, else the result of the GET request
373
+ def get_certificate_statuses(query = {})
374
+ result = get('certificate_statuses', 'any_key', query)
375
+
376
+ unless result.code == '200'
377
+ @logger.err 'Error:'
378
+ @logger.err " code: #{result.code}"
379
+ @logger.err " body: #{result.body}" if result.body
380
+ return nil
381
+ end
382
+
383
+ result
384
+ end
385
+
386
+ # Returns nil for errors, else the result of the GET request
387
+ def get_certificate(certname)
388
+ result = get('certificate', certname)
389
+
390
+ case result.code
391
+ when '200'
392
+ return result
393
+ when '404'
394
+ @logger.err 'Error:'
395
+ @logger.err " Signed certificate #{certname} could not be found on the CA"
396
+ return nil
397
+ else
398
+ @logger.err 'Error:'
399
+ @logger.err " When attempting to download certificate '#{certname}', received:"
400
+ @logger.err " code: #{result.code}"
401
+ @logger.err " body: #{result.body.to_s}" if result.body
402
+ return nil
403
+ end
404
+ end
405
+
406
+ # Make an HTTP GET request to CA
407
+ # @param resource_type [String] the resource type of url
408
+ # @param resource_name [String] the resource name of url
409
+ # @return [Struct] an instance of the Result struct with :code, :body
410
+ def get(resource_type, resource_name, query = {})
411
+ url = make_ca_url(resource_type, resource_name, query)
412
+ @client.with_connection(url) do |connection|
413
+ connection.get(url)
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,145 @@
1
+ require 'optparse'
2
+
3
+ require 'puppetserver/ca/action/clean'
4
+ require 'puppetserver/ca/action/delete'
5
+ require 'puppetserver/ca/action/generate'
6
+ require 'puppetserver/ca/action/import'
7
+ require 'puppetserver/ca/action/enable'
8
+ require 'puppetserver/ca/action/list'
9
+ require 'puppetserver/ca/action/revoke'
10
+ require 'puppetserver/ca/action/setup'
11
+ require 'puppetserver/ca/action/sign'
12
+ require 'puppetserver/ca/action/prune'
13
+ require 'puppetserver/ca/action/migrate'
14
+ require 'puppetserver/ca/errors'
15
+ require 'puppetserver/ca/logger'
16
+ require 'puppetserver/ca/utils/cli_parsing'
17
+ require 'puppetserver/ca/version'
18
+
19
+
20
+ module Puppetserver
21
+ module Ca
22
+ class Cli
23
+ BANNER= <<-BANNER
24
+ Usage: puppetserver ca <action> [options]
25
+
26
+ Manage the Private Key Infrastructure for
27
+ Puppet Server's built-in Certificate Authority
28
+ BANNER
29
+
30
+ ADMIN_ACTIONS = {
31
+ 'delete' => Action::Delete,
32
+ 'import' => Action::Import,
33
+ 'setup' => Action::Setup,
34
+ 'enable' => Action::Enable,
35
+ 'migrate' => Action::Migrate,
36
+ 'prune' => Action::Prune
37
+ }
38
+
39
+ MAINT_ACTIONS = {
40
+ 'clean' => Action::Clean,
41
+ 'generate' => Action::Generate,
42
+ 'list' => Action::List,
43
+ 'revoke' => Action::Revoke,
44
+ 'sign' => Action::Sign
45
+ }
46
+
47
+ VALID_ACTIONS = ADMIN_ACTIONS.merge(MAINT_ACTIONS).sort.to_h
48
+
49
+ ACTION_LIST = "\nAvailable Actions:\n\n" +
50
+ " Certificate Actions (requires a running Puppet Server):\n\n" +
51
+ MAINT_ACTIONS.map do |action, cls|
52
+ " #{action}\t#{cls::SUMMARY}"
53
+ end.join("\n") + "\n\n" +
54
+ " Administrative Actions (requires Puppet Server to be stopped):\n\n" +
55
+ ADMIN_ACTIONS.map do |action, cls|
56
+ " #{action}\t#{cls::SUMMARY}"
57
+ end.join("\n")
58
+
59
+ ACTION_OPTIONS = "\nAction Options:\n" +
60
+ VALID_ACTIONS.map do |action, cls|
61
+ action_summary = cls.parser.summarize.
62
+ select{|line| line =~ /^\s*--/ }.
63
+ reject{|line| line =~ /--help|--version/ }
64
+ summary = action_summary.empty? ? ' N/A' : action_summary.join('')
65
+
66
+ " #{action}:\n" + summary
67
+ end.join("\n")
68
+
69
+
70
+ def self.run(cli_args = ARGV, out = STDOUT, err = STDERR)
71
+ parser, general_options, unparsed = parse_general_inputs(cli_args)
72
+ level = general_options.delete('verbose') ? :debug : :info
73
+
74
+ logger = Puppetserver::Ca::Logger.new(level, out, err)
75
+
76
+ if general_options['version']
77
+ logger.inform Puppetserver::Ca::VERSION
78
+ return 0
79
+ end
80
+
81
+ action_argument = unparsed.shift
82
+ action_class = VALID_ACTIONS[action_argument]
83
+
84
+ if general_options['help']
85
+ if action_class
86
+ logger.inform action_class.parser.help
87
+ else
88
+ logger.inform parser.help
89
+ end
90
+
91
+ return 0
92
+ end
93
+
94
+ if action_class
95
+ action = action_class.new(logger)
96
+ input, exit_code = action.parse(unparsed)
97
+
98
+ if exit_code
99
+ return exit_code
100
+ else
101
+ begin
102
+ return action.run(input)
103
+ rescue Puppetserver::Ca::Error => e
104
+ logger.err "Fatal error when running action '#{action_argument}'"
105
+ logger.err " Error: " + e.message
106
+
107
+ return 1
108
+ end
109
+ end
110
+ else
111
+ logger.warn "Unknown action: #{action_argument}"
112
+ logger.warn parser.help
113
+ return 1
114
+ end
115
+ end
116
+
117
+ def self.parse_general_inputs(inputs)
118
+ parsed = {}
119
+ general_parser = OptionParser.new do |opts|
120
+ opts.banner = BANNER
121
+ opts.separator ACTION_LIST
122
+ opts.separator "\nGeneral Options:"
123
+
124
+ opts.on('--help', 'Display this general help output') do |help|
125
+ parsed['help'] = true
126
+ end
127
+ opts.on('--version', 'Display the version') do |v|
128
+ parsed['version'] = true
129
+ end
130
+ opts.on('--verbose', 'Display low-level information') do |verbose|
131
+ parsed['verbose'] = true
132
+ end
133
+
134
+ opts.separator ACTION_OPTIONS
135
+ opts.separator "\nSee `puppetserver ca <action> --help` for detailed info"
136
+
137
+ end
138
+
139
+ all,_,_,_ = Utils::CliParsing.parse_without_raising(general_parser, inputs)
140
+
141
+ return general_parser, parsed, all
142
+ end
143
+ end
144
+ end
145
+ end