choria-mcorpc-support 2.21.1 → 2.23.1

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mcollective.rb +1 -2
  3. data/lib/mcollective/agent/bolt_tasks.ddl +253 -0
  4. data/lib/mcollective/agent/bolt_tasks.json +365 -0
  5. data/lib/mcollective/agent/bolt_tasks.rb +178 -0
  6. data/lib/mcollective/agent/choria_util.ddl +152 -0
  7. data/lib/mcollective/agent/choria_util.json +244 -0
  8. data/lib/mcollective/agent/rpcutil.ddl +8 -4
  9. data/lib/mcollective/agent/rpcutil.json +333 -0
  10. data/lib/mcollective/agent/scout.ddl +169 -0
  11. data/lib/mcollective/agent/scout.json +224 -0
  12. data/lib/mcollective/agents.rb +7 -6
  13. data/lib/mcollective/aggregate.rb +4 -4
  14. data/lib/mcollective/aggregate/average.rb +2 -2
  15. data/lib/mcollective/aggregate/base.rb +2 -2
  16. data/lib/mcollective/aggregate/result.rb +3 -3
  17. data/lib/mcollective/aggregate/result/collection_result.rb +2 -2
  18. data/lib/mcollective/aggregate/result/numeric_result.rb +2 -2
  19. data/lib/mcollective/aggregate/sum.rb +2 -2
  20. data/lib/mcollective/aggregate/summary.rb +3 -4
  21. data/lib/mcollective/application.rb +57 -21
  22. data/lib/mcollective/application/choria.rb +189 -0
  23. data/lib/mcollective/application/completion.rb +6 -6
  24. data/lib/mcollective/application/facts.rb +11 -11
  25. data/lib/mcollective/application/federation.rb +237 -0
  26. data/lib/mcollective/application/find.rb +4 -4
  27. data/lib/mcollective/application/help.rb +3 -3
  28. data/lib/mcollective/application/inventory.rb +3 -341
  29. data/lib/mcollective/application/ping.rb +5 -51
  30. data/lib/mcollective/application/playbook.rb +207 -0
  31. data/lib/mcollective/application/plugin.rb +106 -106
  32. data/lib/mcollective/application/rpc.rb +3 -108
  33. data/lib/mcollective/application/tasks.rb +425 -0
  34. data/lib/mcollective/applications.rb +11 -10
  35. data/lib/mcollective/audit/choria.rb +33 -0
  36. data/lib/mcollective/cache.rb +2 -4
  37. data/lib/mcollective/client.rb +11 -10
  38. data/lib/mcollective/config.rb +21 -34
  39. data/lib/mcollective/connector/base.rb +2 -1
  40. data/lib/mcollective/connector/nats.ddl +9 -0
  41. data/lib/mcollective/connector/nats.rb +450 -0
  42. data/lib/mcollective/data.rb +8 -3
  43. data/lib/mcollective/data/agent_data.rb +1 -1
  44. data/lib/mcollective/data/base.rb +6 -5
  45. data/lib/mcollective/data/bolt_task_data.ddl +90 -0
  46. data/lib/mcollective/data/bolt_task_data.rb +32 -0
  47. data/lib/mcollective/data/collective_data.rb +1 -1
  48. data/lib/mcollective/data/fact_data.rb +6 -6
  49. data/lib/mcollective/data/fstat_data.rb +2 -4
  50. data/lib/mcollective/data/result.rb +7 -2
  51. data/lib/mcollective/ddl/agentddl.rb +5 -17
  52. data/lib/mcollective/ddl/base.rb +10 -13
  53. data/lib/mcollective/discovery.rb +24 -39
  54. data/lib/mcollective/discovery/choria.ddl +11 -0
  55. data/lib/mcollective/discovery/choria.rb +223 -0
  56. data/lib/mcollective/discovery/flatfile.rb +7 -8
  57. data/lib/mcollective/discovery/mc.rb +2 -2
  58. data/lib/mcollective/discovery/stdin.rb +17 -18
  59. data/lib/mcollective/exceptions.rb +13 -0
  60. data/lib/mcollective/facts/base.rb +9 -9
  61. data/lib/mcollective/facts/yaml_facts.rb +12 -12
  62. data/lib/mcollective/generators.rb +3 -3
  63. data/lib/mcollective/generators/agent_generator.rb +3 -4
  64. data/lib/mcollective/generators/base.rb +14 -15
  65. data/lib/mcollective/generators/data_generator.rb +5 -6
  66. data/lib/mcollective/log.rb +2 -2
  67. data/lib/mcollective/logger/base.rb +3 -2
  68. data/lib/mcollective/logger/console_logger.rb +10 -10
  69. data/lib/mcollective/logger/file_logger.rb +7 -7
  70. data/lib/mcollective/logger/syslog_logger.rb +11 -15
  71. data/lib/mcollective/message.rb +8 -39
  72. data/lib/mcollective/monkey_patches.rb +2 -4
  73. data/lib/mcollective/optionparser.rb +2 -1
  74. data/lib/mcollective/pluginmanager.rb +3 -5
  75. data/lib/mcollective/pluginpackager.rb +1 -3
  76. data/lib/mcollective/pluginpackager/agent_definition.rb +3 -8
  77. data/lib/mcollective/pluginpackager/forge_packager.rb +7 -9
  78. data/lib/mcollective/pluginpackager/standard_definition.rb +1 -2
  79. data/lib/mcollective/registration/base.rb +18 -16
  80. data/lib/mcollective/rpc.rb +2 -4
  81. data/lib/mcollective/rpc/actionrunner.rb +16 -18
  82. data/lib/mcollective/rpc/agent.rb +26 -43
  83. data/lib/mcollective/rpc/audit.rb +1 -0
  84. data/lib/mcollective/rpc/client.rb +67 -85
  85. data/lib/mcollective/rpc/helpers.rb +55 -62
  86. data/lib/mcollective/rpc/progress.rb +2 -2
  87. data/lib/mcollective/rpc/reply.rb +17 -19
  88. data/lib/mcollective/rpc/request.rb +7 -5
  89. data/lib/mcollective/rpc/result.rb +6 -8
  90. data/lib/mcollective/rpc/stats.rb +49 -58
  91. data/lib/mcollective/security/base.rb +13 -56
  92. data/lib/mcollective/security/choria.rb +765 -0
  93. data/lib/mcollective/shell.rb +9 -4
  94. data/lib/mcollective/signer/base.rb +28 -0
  95. data/lib/mcollective/signer/choria.rb +185 -0
  96. data/lib/mcollective/ssl.rb +8 -6
  97. data/lib/mcollective/util.rb +58 -55
  98. data/lib/mcollective/util/bolt_support.rb +176 -0
  99. data/lib/mcollective/util/bolt_support/plan_runner.rb +167 -0
  100. data/lib/mcollective/util/bolt_support/task_result.rb +94 -0
  101. data/lib/mcollective/util/bolt_support/task_results.rb +128 -0
  102. data/lib/mcollective/util/choria.rb +946 -0
  103. data/lib/mcollective/util/indifferent_hash.rb +12 -0
  104. data/lib/mcollective/util/natswrapper.rb +242 -0
  105. data/lib/mcollective/util/playbook.rb +435 -0
  106. data/lib/mcollective/util/playbook/data_stores.rb +201 -0
  107. data/lib/mcollective/util/playbook/data_stores/base.rb +99 -0
  108. data/lib/mcollective/util/playbook/data_stores/consul_data_store.rb +88 -0
  109. data/lib/mcollective/util/playbook/data_stores/environment_data_store.rb +33 -0
  110. data/lib/mcollective/util/playbook/data_stores/etcd_data_store.rb +42 -0
  111. data/lib/mcollective/util/playbook/data_stores/file_data_store.rb +106 -0
  112. data/lib/mcollective/util/playbook/data_stores/shell_data_store.rb +103 -0
  113. data/lib/mcollective/util/playbook/inputs.rb +265 -0
  114. data/lib/mcollective/util/playbook/nodes.rb +207 -0
  115. data/lib/mcollective/util/playbook/nodes/mcollective_nodes.rb +86 -0
  116. data/lib/mcollective/util/playbook/nodes/pql_nodes.rb +40 -0
  117. data/lib/mcollective/util/playbook/nodes/shell_nodes.rb +55 -0
  118. data/lib/mcollective/util/playbook/nodes/terraform_nodes.rb +65 -0
  119. data/lib/mcollective/util/playbook/nodes/yaml_nodes.rb +47 -0
  120. data/lib/mcollective/util/playbook/playbook_logger.rb +47 -0
  121. data/lib/mcollective/util/playbook/puppet_logger.rb +51 -0
  122. data/lib/mcollective/util/playbook/report.rb +152 -0
  123. data/lib/mcollective/util/playbook/task_result.rb +55 -0
  124. data/lib/mcollective/util/playbook/tasks.rb +196 -0
  125. data/lib/mcollective/util/playbook/tasks/base.rb +45 -0
  126. data/lib/mcollective/util/playbook/tasks/graphite_event_task.rb +64 -0
  127. data/lib/mcollective/util/playbook/tasks/mcollective_task.rb +356 -0
  128. data/lib/mcollective/util/playbook/tasks/shell_task.rb +93 -0
  129. data/lib/mcollective/util/playbook/tasks/slack_task.rb +105 -0
  130. data/lib/mcollective/util/playbook/tasks/webhook_task.rb +136 -0
  131. data/lib/mcollective/util/playbook/template_util.rb +98 -0
  132. data/lib/mcollective/util/playbook/uses.rb +169 -0
  133. data/lib/mcollective/util/tasks_support.rb +751 -0
  134. data/lib/mcollective/util/tasks_support/cli.rb +260 -0
  135. data/lib/mcollective/util/tasks_support/default_formatter.rb +138 -0
  136. data/lib/mcollective/util/tasks_support/json_formatter.rb +108 -0
  137. data/lib/mcollective/validator.rb +6 -1
  138. data/lib/mcollective/validator/bolt_task_name_validator.ddl +7 -0
  139. data/lib/mcollective/validator/bolt_task_name_validator.rb +11 -0
  140. data/lib/mcollective/validator/length_validator.rb +1 -3
  141. metadata +65 -6
  142. data/lib/mcollective/application/describe_filter.rb +0 -87
  143. data/lib/mcollective/matcher.rb +0 -220
  144. data/lib/mcollective/matcher/parser.rb +0 -128
  145. data/lib/mcollective/matcher/scanner.rb +0 -241
@@ -0,0 +1,765 @@
1
+ require "base64"
2
+ require "openssl"
3
+ require "yaml"
4
+
5
+ require_relative "../util/choria"
6
+ require_relative "../util/indifferent_hash"
7
+
8
+ module MCollective
9
+ module Security
10
+ class Choria < Base
11
+ def initialize
12
+ super
13
+
14
+ # Stores lists of requests that came from legacy choria clients so they
15
+ # can be encoded appropriately for them on reply
16
+ #
17
+ # This has to be an expiring entity since not all requests make
18
+ # replies
19
+ #
20
+ # See issue 288 for background on this, this can be removed once we hit
21
+ # 1.0.0 along with the calls to the methods using this
22
+ Cache.setup(:choria_security, 3600)
23
+ end
24
+
25
+ def choria
26
+ @_choria ||= Util::Choria.new(false)
27
+ end
28
+
29
+ # Encodes a request on behalf of the MCollective Client code
30
+ #
31
+ # The request is turned into a `choria:request:1` message and then encoded in
32
+ # a `choria:secure:request:1` message prior to being serialized
33
+ #
34
+ # @param sender [String] the sender identity, typically @config.identity
35
+ # @param msg [Object] message to be sent, there really is no actual standard to these, any Ruby Object
36
+ # @param requestid [String] a UUID representing the message to be sent
37
+ # @param filter [Hash] the MCollective filter used for routing this request
38
+ # @param target_agent [String] the destination agent name
39
+ # @param target_collective [String] the sub collective to publish this message in
40
+ # @param ttl [Fixnum] how long this message is valid for
41
+ # @return [String] serialized message to be transmitted over the wire
42
+ def encoderequest(sender, msg, requestid, filter, target_agent, target_collective, ttl=60)
43
+ request = empty_request
44
+ request["message"] = serialize(msg, default_serializer)
45
+ request["envelope"]["requestid"] = requestid
46
+ request["envelope"]["filter"] = filter
47
+ request["envelope"]["agent"] = target_agent
48
+ request["envelope"]["collective"] = target_collective
49
+ request["envelope"]["ttl"] = ttl
50
+ request["envelope"]["callerid"] = callerid
51
+
52
+ serialized_request = serialize(request, default_serializer)
53
+
54
+ secure_request = {
55
+ "protocol" => "choria:secure:request:1",
56
+ "message" => serialized_request,
57
+ "signature" => "insecure",
58
+ "pubcert" => "insecure"
59
+ }
60
+
61
+ sign_secure_request!(secure_request)
62
+
63
+ serialize(secure_request)
64
+ end
65
+
66
+ # Signs a secure request
67
+ #
68
+ # @param secure_request the secure request to sign and embed certificates into
69
+ def sign_secure_request!(secure_request)
70
+ request_signer.sign_secure_request!(secure_request)
71
+ end
72
+
73
+ # The class the implements signing the requests
74
+ #
75
+ def request_signer
76
+ PluginManager.loadclass("MCollective::Signer::%s" % @config.pluginconf.fetch("choria.security.request_signer.plugin", "choria").capitalize)
77
+ PluginManager["choria_signer_plugin"]
78
+ end
79
+
80
+ # Encodes a reply to a earlier received message
81
+ #
82
+ # The reply is turned into a `choria:reply:1` and then encoded in
83
+ # a `choria:secure:reply:1` before being serialized
84
+ #
85
+ # @param sender_agent [String] the agent sending the message
86
+ # @param msg [Object] the message to send
87
+ # @param requestid [String] the requestid the message is a reply to
88
+ # @param requestcallerid [String] the callerid of the requestor
89
+ # @return [String] serialized message to be transmitted over the wire
90
+ def encodereply(sender_agent, msg, requestid, requestcallerid=nil)
91
+ reply = empty_reply
92
+ reply["envelope"]["requestid"] = requestid
93
+ reply["envelope"]["agent"] = sender_agent
94
+
95
+ if legacy_request?(requestid)
96
+ reply["message"] = msg
97
+ legacy_processed!(requestid)
98
+ else
99
+ reply["message"] = serialize(msg, default_serializer)
100
+ end
101
+
102
+ serialized_reply = serialize(reply, default_serializer)
103
+
104
+ serialize(
105
+ "protocol" => "choria:secure:reply:1",
106
+ "message" => serialized_reply,
107
+ "hash" => hash(serialized_reply)
108
+ )
109
+ end
110
+
111
+ # Decodes a message and validates it's security
112
+ #
113
+ # This will delegate the actual checking of messages to {#decode_request} and {#decode_reply}.
114
+ #
115
+ # @see MCollective::Security::Message#decode!
116
+ # @param message [Message] the message holding unverified/validated payload
117
+ # @raise [SecurityValidationFailed] when the message does not pass security checks
118
+ # @return [void]
119
+ def decodemsg(message)
120
+ secure_payload = deserialize(message.payload)
121
+
122
+ case secure_payload["protocol"]
123
+ when "choria:secure:request:1"
124
+ decode_request(message, secure_payload)
125
+
126
+ when "choria:secure:reply:1"
127
+ decode_reply(secure_payload)
128
+
129
+ else
130
+ Log.debug("Unknown protocol in message:\n%s" % secure_payload.pretty_inspect)
131
+ raise(SecurityValidationFailed, "Received an unknown protocol '%s' message, ignoring" % secure_payload["protocol"])
132
+ end
133
+ end
134
+
135
+ # Validates a received request is in the correct format and passes security checks
136
+ #
137
+ # During this the YAML encoded `message` held will be deserialized
138
+ #
139
+ # @param message [Message]
140
+ # @param secure_payload [Hash] A choria:secure:request:1 message
141
+ # @raise [SecurityValidationFailed] when the message does not pass security checks
142
+ # @return [Hash] a legacy MCollective request structure, see {#to_legacy_request}
143
+ def decode_request(message, secure_payload)
144
+ request = deserialize(secure_payload["message"], default_serializer)
145
+
146
+ unless valid_protocol?(request, "choria:request:1", empty_request) || valid_protocol?(request, "mcollective:request:3", empty_request)
147
+ raise(SecurityValidationFailed, "Unknown request body format received. Expected choria:request:1 or mcollective:request:3, cannot continue")
148
+ end
149
+
150
+ cache_client_pubcert(request["envelope"], secure_payload["pubcert"]) if @initiated_by == :node
151
+
152
+ validrequest?(secure_payload, request)
153
+
154
+ should_process_msg?(message, request["envelope"]["requestid"])
155
+
156
+ if request["message"].is_a?(String)
157
+ # non json based things like 'mco ping' that just sends 'ping' will fail on JSON serialize
158
+ # while yaml would not fail and just return the string
159
+ #
160
+ # So we ensure the message is left as it was should json deserialize fail, tbh this a train wreck
161
+ # but it's how the original mcollective was designed, definitely need a bit of a rethink there as
162
+ # at core its not compatible with this JSON stuff as is
163
+ begin
164
+ request["message"] = deserialize(request["message"], default_serializer)
165
+ rescue # rubocop:disable Lint/SuppressedException
166
+ end
167
+ else
168
+ record_legacy_request(request)
169
+ end
170
+
171
+ to_legacy_request(request)
172
+ end
173
+
174
+ # Records the fact that a request is from a legacy client
175
+ #
176
+ # @param request [Hash] decoded request
177
+ def record_legacy_request(request)
178
+ Cache.write(:choria_security, request["envelope"]["requestid"], true) if request["envelope"] && request["envelope"]["requestid"]
179
+ end
180
+
181
+ # Determines if a specific requestid was a previously seen legacy request
182
+ #
183
+ # @param requestid [String]
184
+ # @return [Boolean]
185
+ def legacy_request?(requestid)
186
+ !!Cache.read(:choria_security, requestid)
187
+ rescue
188
+ false
189
+ end
190
+
191
+ # Mark a request as processed and mark it for removal from the cache
192
+ #
193
+ # @param requestid [String]
194
+ def legacy_processed!(requestid)
195
+ Cache.invalidate!(:choria_security, requestid)
196
+ end
197
+
198
+ # Validates a received reply is in the correct format and passes security checks
199
+ #
200
+ # During this the YAML encoded `message` held will be deserialized
201
+ #
202
+ # @note right now no actual security checks are done on replies
203
+ # @param secure_payload [Hash] a choria:secure:reply:1 message
204
+ # @raise [SecurityValidationFailed] when the message does not pass security checks
205
+ # @return [Hash] a legacy MCollective reply structure, see {#to_legacy_reply}
206
+ def decode_reply(secure_payload)
207
+ reply = deserialize(secure_payload["message"], default_serializer)
208
+
209
+ if reply["message"].is_a?(String)
210
+ # non json based things like 'mco ping' that just sends 'ping' will fail on JSON serialize
211
+ # while yaml would not fail and just return the string
212
+ #
213
+ # So we ensure the message is left as it was should json deserialize fail, tbh this a train wreck
214
+ # but it's how the original mcollective was designed, definitely need a bit of a rethink there as
215
+ # at core its not compatible with this JSON stuff as is
216
+ begin
217
+ reply["message"] = deserialize(reply["message"], default_serializer)
218
+ rescue # rubocop:disable Lint/SuppressedException
219
+ end
220
+ end
221
+
222
+ unless valid_protocol?(reply, "choria:reply:1", empty_reply) || valid_protocol?(reply, "mcollective:reply:3", empty_reply)
223
+ raise(SecurityValidationFailed, "Unknown reply body format received. Expected choria:reply:1 or mcollective:reply:3, cannot continue")
224
+ end
225
+
226
+ to_legacy_reply(reply)
227
+ end
228
+
229
+ # Verifies the request by checking it's been signed with the cached certificate of the claimed callerid
230
+ #
231
+ # @param secure_payload [Hash] a choria:secure:request:1 message
232
+ # @param request [Hash] a choria:request:1 message
233
+ # @return [Boolean]
234
+ # @raise [SecurityValidationFailed] when the message cannot be decoded
235
+ def validrequest?(secure_payload, request)
236
+ return true if $choria_unsafe_disable_protocol_security # rubocop:disable Style/GlobalVars
237
+
238
+ callerid = request["envelope"]["callerid"]
239
+
240
+ if verify_signature(secure_payload["message"], secure_payload["signature"], callerid, true)
241
+ Log.info("Received valid request %s from %s" % [request["envelope"]["requestid"], callerid])
242
+ @stats.validated
243
+ else
244
+ @stats.unvalidated
245
+ raise(SecurityValidationFailed, "Received an invalid signature in message from %s" % callerid)
246
+ end
247
+
248
+ true
249
+ end
250
+
251
+ # Checks the structure of a message is well formed
252
+ #
253
+ # @todo this really should be json schema or even better protobufs
254
+ # @param body [Hash] a choria:request:1 or choria:reply:1
255
+ # @param protocol [String] the expected protocol
256
+ # @param template [Hash] a template message to check against {#empty_reply} or {#empty_request}
257
+ # @return [Boolean]
258
+ def valid_protocol?(body, protocol, template)
259
+ unless body.is_a?(Hash)
260
+ Log.warn("Body from the message should be a Hash")
261
+ return false
262
+ end
263
+
264
+ unless body["protocol"] == protocol
265
+ Log.warn("Unknown message protocol, should be %s" % protocol)
266
+ return false
267
+ end
268
+
269
+ unless body.include?("envelope")
270
+ Log.warn("No envelope found in the message")
271
+ return false
272
+ end
273
+
274
+ envelope = body["envelope"]
275
+
276
+ unless envelope.is_a?(Hash)
277
+ Log.warn("Envelope in message is not a hash")
278
+ return false
279
+ end
280
+
281
+ valid_envelope = template["envelope"].keys
282
+
283
+ unless (envelope.keys - valid_envelope).empty?
284
+ Log.warn("Envelope does not have the correct keys, only %s allowed" % valid_envelope.join(", "))
285
+ return false
286
+ end
287
+
288
+ unless body.include?("message")
289
+ Log.warn("Body has no message")
290
+ return false
291
+ end
292
+
293
+ true
294
+ end
295
+
296
+ # Parse a comma seperated list into a Regex spanning the list
297
+ #
298
+ # @param list [String] comma seperated list of strings and regex
299
+ # @param default [String,Regexp] what to do for empty lists
300
+ # @return [Regexp]
301
+ def comma_sep_list_to_regex(list, default)
302
+ matchlist = list.split(",").map do |item|
303
+ item.strip!
304
+
305
+ if item =~ /^\/(.+)\/$/
306
+ Regexp.new($1)
307
+ else
308
+ item
309
+ end
310
+ end.compact
311
+
312
+ matchlist << default if matchlist.empty?
313
+
314
+ Regexp.union(matchlist.compact.uniq)
315
+ end
316
+
317
+ # Calculate a Regex that will match the entire privileged user list
318
+ #
319
+ # Defaults to match /\.privileged.mcollective$/ othwerwise whatever is specified,
320
+ # in the comma seperated config item `choria.security.privileged_users`
321
+ #
322
+ # @example specific certs and the default
323
+ #
324
+ # plugin.choria.security.privileged_users = bob, /\.privileged.mcollective$/
325
+ #
326
+ # @return [Regexp]
327
+ def privilegeduser_regex
328
+ users = @config.pluginconf.fetch("choria.security.privileged_users", "")
329
+
330
+ comma_sep_list_to_regex(users, /\.privileged\.mcollective$/)
331
+ end
332
+
333
+ # Search the cache directory for certificates matching the privileged user list
334
+ #
335
+ # @return [Array<String>] list of full paths to privileged certs
336
+ def privilegeduser_certs
337
+ match = privilegeduser_regex
338
+ dir = server_public_cert_dir
339
+
340
+ certs = Dir.entries(dir).grep(/pem$/).select do |cert|
341
+ File.basename(cert, ".pem").match(match)
342
+ end
343
+
344
+ certs.map {|cert| File.join(dir, cert) }
345
+ rescue Errno::ENOENT
346
+ []
347
+ end
348
+
349
+ # Calculate a Regex that will match the entire cert whitelist
350
+ #
351
+ # Defaults to match /\.mcollective$/ othwerwise whatever is specified,
352
+ # in the comma seperated config item `choria.security.cert_whitelist`
353
+ #
354
+ # @example specific certs and the default
355
+ #
356
+ # plugin.choria.security.certname_whitelist = bob,/\.mcollective$/
357
+ #
358
+ # @return [Regexp]
359
+ def certname_whitelist_regex
360
+ whitelist = @config.pluginconf.fetch("choria.security.certname_whitelist", "")
361
+
362
+ comma_sep_list_to_regex(whitelist, /\.mcollective$/)
363
+ end
364
+
365
+ # Determines if a certificate should be cached
366
+ #
367
+ # This checks the cert is valid against our CA, it's name etc
368
+ #
369
+ # @todo support white/black lists
370
+ # @param pubcert [String] PEM encoded X509 cert text
371
+ # @param callerid [String] callerid who sent this cert
372
+ # @return [Boolean]
373
+ def should_cache_certname?(pubcert, callerid)
374
+ callerid_certname = certname_from_callerid(callerid)
375
+ certname = choria.valid_certificate?(pubcert, callerid_certname)
376
+ valid_regex = certname_whitelist_regex
377
+
378
+ unless certname
379
+ Log.warn("Received a certificate for '%s' that is not signed by a known CA, discarding" % callerid_certname)
380
+ return false
381
+ end
382
+
383
+ # this cert is allowed to set callerids != certname, so check it here and log callerid
384
+ if certname =~ privilegeduser_regex
385
+ Log.warn("Allowing cache of privileged user certname %s from callerid %s" % [certname, callerid])
386
+ return true
387
+ end
388
+
389
+ unless certname == callerid_certname
390
+ Log.warn("Received a certificate called '%s' that does not match the received callerid of '%s'" % [certname, callerid_certname])
391
+ return false
392
+ end
393
+
394
+ unless certname =~ valid_regex
395
+ Log.warn("Received certificate name '%s' does not match %s" % [certname, valid_regex])
396
+ return false
397
+ end
398
+
399
+ true
400
+ end
401
+
402
+ # Metadata about a pubcert based on the envelope
403
+ #
404
+ # @param envelope [Hash] the envelope from a choria:request:1
405
+ # @param pubcert [String] PEM encoded X509 public certificate
406
+ # @return [Hash]
407
+ def client_pubcert_metadata(envelope, pubcert)
408
+ cert = choria.parse_pubcert(pubcert).first
409
+
410
+ {
411
+ "create_time" => current_timestamp,
412
+ "senderid" => envelope["senderid"],
413
+ "requestid" => envelope["requestid"],
414
+ "certinfo" => {
415
+ "issuer" => cert.issuer.to_s,
416
+ "not_after" => Integer(cert.not_after),
417
+ "not_before" => Integer(cert.not_before),
418
+ "serial" => cert.serial.to_s,
419
+ "subject" => cert.subject.to_s,
420
+ "version" => cert.version,
421
+ "signature_algorithm" => cert.signature_algorithm
422
+ }
423
+ }
424
+ end
425
+
426
+ # Mutex used for locking write access to the pubcert cache
427
+ #
428
+ # @return [Mutex]
429
+ def client_cache_mutex
430
+ @_client_cache_mutex ||= Mutex.new
431
+ end
432
+
433
+ # Caches the public certificate of a sender
434
+ #
435
+ # If there is not yet a cached certificate for the callerid a new one
436
+ # is saved after first checking it against our CA
437
+ #
438
+ # @param envelope [Hash] the envelope from a choria:request:1
439
+ # @param pubcert [String] a X509 public certificate in PEM format
440
+ # @return [Boolean] true when the cert was cached, false when already cached
441
+ # @raise [StandardError] when an invalid cert was received
442
+ def cache_client_pubcert(envelope, pubcert)
443
+ return false if $choria_unsafe_disable_protocol_security # rubocop:disable Style/GlobalVars
444
+
445
+ client_cache_mutex.synchronize do
446
+ callerid = envelope["callerid"]
447
+ certfile = public_certfile(callerid)
448
+ certmetadata = public_cert_metadatafile(callerid)
449
+
450
+ if File.exist?(certfile)
451
+ Log.debug("Already have a cert from %s in %s" % [callerid, certfile])
452
+
453
+ false
454
+ else
455
+ raise("Received an invalid certificate for %s" % callerid) unless should_cache_certname?(pubcert, callerid)
456
+
457
+ Log.info("Saving verified pubcert for %s in %s" % [callerid, certfile])
458
+
459
+ File.open(certfile, "w") do |f|
460
+ f.print(pubcert)
461
+ end
462
+
463
+ File.open(certmetadata, "w") do |f|
464
+ f.print(client_pubcert_metadata(envelope, pubcert).to_json)
465
+ end
466
+
467
+ true
468
+ end
469
+ end
470
+ end
471
+
472
+ # Determines the path to a cached certificate for a caller
473
+ #
474
+ # @param callerid [String] the callerid to find a cert for
475
+ # @return [String] path to the pem file
476
+ def public_certfile(callerid)
477
+ "%s/%s.pem" % [server_public_cert_dir, certname_from_callerid(callerid)]
478
+ end
479
+
480
+ def public_cert_metadatafile(callerid)
481
+ public_certfile(callerid).gsub(/\.pem$/, ".json")
482
+ end
483
+
484
+ # Parses our callerids and return the certname
485
+ #
486
+ # @param id [String] the callerid to parse
487
+ # @return [String] the certificate name
488
+ # @raise [StandardError] when a unexpected format id is received
489
+ def certname_from_callerid(id)
490
+ if id =~ /^choria=([\w.\-]+)/
491
+ $1
492
+ else
493
+ raise("Received a callerid in an unexpected format: %s" % id)
494
+ end
495
+ end
496
+
497
+ # Serialize a object
498
+ #
499
+ # @param obj [Object] the item to serialize
500
+ # @param format [:json, :yaml] the serializer to use
501
+ # @return [String]
502
+ def serialize(obj, format=:json)
503
+ if format == :yaml
504
+ YAML.dump(obj)
505
+ else
506
+ JSON.dump(obj)
507
+ end
508
+ end
509
+
510
+ # Deserialize a string
511
+ #
512
+ # @param string [String] the serialized text
513
+ # @param format [:json, :yaml] the serializer to use
514
+ # @return [Class]
515
+ def deserialize(string, format=:json)
516
+ if format == :yaml
517
+ YAML.load(string)
518
+ else
519
+ JSON.parse(string, :object_class => Util::IndifferentHash)
520
+ end
521
+ end
522
+
523
+ # Determines the default serializer
524
+ #
525
+ # As of MCollective 2.11.0 it will translate "package" into :package to
526
+ # faciliate JSON requests and other programming languages. This is a super
527
+ # experimental feature but will allow us to ditch YAML for now.
528
+ #
529
+ # By setting `choria.security.serializer` to JSON this new behaviour can be
530
+ # tested
531
+ #
532
+ # @return [Symbol]
533
+ def default_serializer
534
+ @config.pluginconf.fetch("choria.security.serializer", "json").downcase.intern
535
+ end
536
+
537
+ # The path where a server caches client certificates
538
+ #
539
+ # @note when the path does not exist it will attempt to make it
540
+ # @return [String]
541
+ # @raise [StandardError] when creating the directory fails
542
+ def server_public_cert_dir
543
+ dir = File.join(ssl_dir, "choria_security", "public_certs")
544
+
545
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
546
+
547
+ dir
548
+ end
549
+
550
+ # (see Util::Choria#ssl_dir)
551
+ def ssl_dir
552
+ choria.ssl_dir
553
+ end
554
+
555
+ # (see Util::Choria#certname)
556
+ def certname
557
+ choria.certname
558
+ end
559
+
560
+ def env_fetch(key, default=nil)
561
+ choria.env_fetch(key, default)
562
+ end
563
+
564
+ # (see Util::Choria#client_public_cert)
565
+ def client_public_cert
566
+ choria.client_public_cert
567
+ end
568
+
569
+ # (see Util::Choria#has_client_public_cert?)
570
+ def has_client_public_cert?
571
+ choria.has_client_public_cert?
572
+ end
573
+
574
+ # (see Util::Choria#client_private_key)
575
+ def client_private_key
576
+ choria.client_private_key
577
+ end
578
+
579
+ # (see Util::Choria#has_client_private_key?)
580
+ def has_client_private_key?
581
+ choria.has_client_private_key?
582
+ end
583
+
584
+ # The callerid based on the certificate name
585
+ #
586
+ # Caller ids are in the form `choria=certname`
587
+ #
588
+ # @return [String] callerid
589
+ # @raise [Exception] for invalid callerid or JWT token
590
+ def callerid
591
+ return request_signer.callerid if choria.anon_tls?
592
+
593
+ "choria=%s" % certname
594
+ end
595
+
596
+ # Signs a string using the private key
597
+ #
598
+ # @param string [String] the string to sign
599
+ # @param id [String] a callerid to sign as
600
+ # @return [String] Base64 encoded signature
601
+ # @raise [Exception] in case OpenSSL fails for some reason or keys cannot be found
602
+ def sign(string, id=nil)
603
+ key = client_private_key
604
+
605
+ @_keys ||= {}
606
+ if @_keys[key].nil?
607
+ if has_client_private_key?
608
+ Log.debug("Signing request using client private key %s" % key)
609
+ else
610
+ raise("Cannot find private key %s, cannot sign message" % key)
611
+ end
612
+
613
+ @_keys[key] ||= OpenSSL::PKey::RSA.new(File.read(key))
614
+ end
615
+
616
+ signed = @_keys[key].sign(OpenSSL::Digest.new("SHA256"), string)
617
+
618
+ Base64.encode64(signed).chomp
619
+ end
620
+
621
+ # Verifies a signature of a string using a certificate
622
+ #
623
+ # Optionally should the signature validation fail - or the specified cert does not exist -
624
+ # the list of privileged user certs will be tried to validate the message and any of those can
625
+ # validate it
626
+ #
627
+ # @param string [String] the signed string
628
+ # @param signature [String] Base64 encoded signature to verify
629
+ # @param callerid [String] Callerid to verify the signature for
630
+ # @param allow_privileged [Boolean] when true will check the privileged user certs should the main cert fails
631
+ def verify_signature(string, signature, callerid, allow_privileged=false)
632
+ candidate_keys = [public_certfile(callerid)]
633
+
634
+ candidate_keys.concat(privilegeduser_certs) if allow_privileged
635
+
636
+ candidate_keys.each do |certname|
637
+ next unless File.exist?(certname)
638
+
639
+ key = OpenSSL::X509::Certificate.new(File.read(certname)).public_key
640
+ result = key.verify(OpenSSL::Digest.new("SHA256"), Base64.decode64(signature), string)
641
+
642
+ if result
643
+ Log.debug("Message validated using certificate in %s (allow_privileged=%s)" % [certname, allow_privileged])
644
+ return true
645
+ end
646
+ end
647
+
648
+ false
649
+ end
650
+
651
+ # Produce a Base64 encoded SHA256 digest of a string
652
+ #
653
+ # @param string [String] the string to hash
654
+ # @return [String]
655
+ def hash(string)
656
+ OpenSSL::Digest.new("sha256", string).base64digest
657
+ end
658
+
659
+ # Retrieves the current time in UTC
660
+ #
661
+ # @return [Fixnum] seconds since epoch
662
+ def current_timestamp
663
+ Integer(Time.now.utc)
664
+ end
665
+
666
+ # Creates a empty choria:request:1
667
+ #
668
+ # Some envelope fields like time are set to sane defautls
669
+ #
670
+ # @return [Hash]
671
+ def empty_request
672
+ {
673
+ "protocol" => "choria:request:1",
674
+ "message" => nil,
675
+ "envelope" => {
676
+ "requestid" => nil,
677
+ "senderid" => @config.identity,
678
+ "callerid" => nil,
679
+ "filter" => {},
680
+ "collective" => @config.main_collective,
681
+ "agent" => nil,
682
+ "ttl" => @config.ttl,
683
+ "time" => current_timestamp
684
+ }
685
+ }
686
+ end
687
+
688
+ # Creates a empty choria:reply:1
689
+ #
690
+ # Some envelope fields like time are set to sane defautls
691
+ #
692
+ # @return [Hash]
693
+ def empty_reply
694
+ {
695
+ "protocol" => "choria:reply:1",
696
+ "message" => nil,
697
+ "envelope" => {
698
+ "senderid" => @config.identity,
699
+ "requestid" => nil,
700
+ "agent" => nil,
701
+ "time" => current_timestamp
702
+ }
703
+ }
704
+ end
705
+
706
+ # Converts a choria filter into a legacy format
707
+ #
708
+ # Choria filters have strings for fact filter keys, mcollective expect symbols
709
+ #
710
+ # @param filter [Hash] the input filter
711
+ # @return [Hash] a new filter converted to legacy format
712
+ def to_legacy_filter(filter)
713
+ return filter unless filter.include?("fact")
714
+
715
+ new = {}
716
+
717
+ filter.each do |key, value|
718
+ new[key] = value
719
+
720
+ next unless key == "fact"
721
+
722
+ new["fact"] = value.map do |ff|
723
+ {
724
+ :fact => ff.fetch(:fact, ff["fact"]),
725
+ :operator => ff.fetch(:operator, ff["operator"]),
726
+ :value => ff.fetch(:value, ff["value"])
727
+ }
728
+ end
729
+ end
730
+
731
+ new
732
+ end
733
+
734
+ # Converts a choria:request:1 to a legacy format
735
+ #
736
+ # @return [Hash]
737
+ def to_legacy_request(body)
738
+ {
739
+ :body => body["message"],
740
+ :senderid => body["envelope"]["senderid"],
741
+ :requestid => body["envelope"]["requestid"],
742
+ :filter => to_legacy_filter(body["envelope"]["filter"]),
743
+ :collective => body["envelope"]["collective"],
744
+ :agent => body["envelope"]["agent"],
745
+ :callerid => body["envelope"]["callerid"],
746
+ :ttl => body["envelope"]["ttl"],
747
+ :msgtime => body["envelope"]["time"]
748
+ }
749
+ end
750
+
751
+ # Converts a choria:reply:1 to a legacy format
752
+ #
753
+ # @return [Hash]
754
+ def to_legacy_reply(body)
755
+ {
756
+ :senderid => body["envelope"]["senderid"],
757
+ :requestid => body["envelope"]["requestid"],
758
+ :senderagent => body["envelope"]["agent"],
759
+ :msgtime => body["envelope"]["time"],
760
+ :body => body["message"]
761
+ }
762
+ end
763
+ end
764
+ end
765
+ end