choria-mcorpc-support 2.22.1 → 2.23.0.pre

Sign up to get free protection for your applications and to get access to all the features.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/lib/mcollective.rb +1 -1
  3. data/lib/mcollective/agent/bolt_tasks.ddl +235 -0
  4. data/lib/mcollective/agent/bolt_tasks.json +347 -0
  5. data/lib/mcollective/agent/bolt_tasks.rb +176 -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 +7 -3
  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 +249 -0
  23. data/lib/mcollective/application/completion.rb +6 -6
  24. data/lib/mcollective/application/describe_filter.rb +20 -20
  25. data/lib/mcollective/application/facts.rb +11 -11
  26. data/lib/mcollective/application/federation.rb +239 -0
  27. data/lib/mcollective/application/find.rb +4 -4
  28. data/lib/mcollective/application/help.rb +3 -3
  29. data/lib/mcollective/application/inventory.rb +3 -341
  30. data/lib/mcollective/application/ping.rb +3 -77
  31. data/lib/mcollective/application/playbook.rb +207 -0
  32. data/lib/mcollective/application/plugin.rb +106 -106
  33. data/lib/mcollective/application/rpc.rb +3 -108
  34. data/lib/mcollective/application/tasks.rb +416 -0
  35. data/lib/mcollective/applications.rb +11 -10
  36. data/lib/mcollective/audit/choria.rb +33 -0
  37. data/lib/mcollective/cache.rb +2 -4
  38. data/lib/mcollective/client.rb +11 -10
  39. data/lib/mcollective/config.rb +21 -26
  40. data/lib/mcollective/connector/base.rb +2 -1
  41. data/lib/mcollective/connector/nats.ddl +9 -0
  42. data/lib/mcollective/connector/nats.rb +450 -0
  43. data/lib/mcollective/data.rb +8 -3
  44. data/lib/mcollective/data/agent_data.rb +1 -1
  45. data/lib/mcollective/data/base.rb +6 -5
  46. data/lib/mcollective/data/bolt_task_data.ddl +90 -0
  47. data/lib/mcollective/data/bolt_task_data.rb +32 -0
  48. data/lib/mcollective/data/collective_data.rb +1 -1
  49. data/lib/mcollective/data/fact_data.rb +6 -6
  50. data/lib/mcollective/data/fstat_data.rb +2 -4
  51. data/lib/mcollective/data/result.rb +7 -2
  52. data/lib/mcollective/ddl/agentddl.rb +5 -17
  53. data/lib/mcollective/ddl/base.rb +10 -13
  54. data/lib/mcollective/discovery.rb +12 -26
  55. data/lib/mcollective/discovery/choria.ddl +11 -0
  56. data/lib/mcollective/discovery/choria.rb +223 -0
  57. data/lib/mcollective/discovery/flatfile.rb +7 -8
  58. data/lib/mcollective/discovery/mc.rb +2 -2
  59. data/lib/mcollective/discovery/stdin.rb +17 -18
  60. data/lib/mcollective/exceptions.rb +13 -0
  61. data/lib/mcollective/facts/base.rb +9 -9
  62. data/lib/mcollective/facts/yaml_facts.rb +12 -12
  63. data/lib/mcollective/generators.rb +3 -3
  64. data/lib/mcollective/generators/agent_generator.rb +3 -4
  65. data/lib/mcollective/generators/base.rb +14 -15
  66. data/lib/mcollective/generators/data_generator.rb +5 -6
  67. data/lib/mcollective/log.rb +2 -2
  68. data/lib/mcollective/logger/base.rb +3 -2
  69. data/lib/mcollective/logger/console_logger.rb +10 -10
  70. data/lib/mcollective/logger/file_logger.rb +7 -7
  71. data/lib/mcollective/logger/syslog_logger.rb +11 -15
  72. data/lib/mcollective/matcher.rb +14 -14
  73. data/lib/mcollective/matcher/parser.rb +31 -41
  74. data/lib/mcollective/matcher/scanner.rb +69 -74
  75. data/lib/mcollective/message.rb +10 -17
  76. data/lib/mcollective/monkey_patches.rb +2 -4
  77. data/lib/mcollective/optionparser.rb +1 -0
  78. data/lib/mcollective/pluginmanager.rb +3 -5
  79. data/lib/mcollective/pluginpackager.rb +1 -3
  80. data/lib/mcollective/pluginpackager/agent_definition.rb +3 -8
  81. data/lib/mcollective/pluginpackager/forge_packager.rb +7 -9
  82. data/lib/mcollective/pluginpackager/standard_definition.rb +1 -2
  83. data/lib/mcollective/registration/base.rb +18 -16
  84. data/lib/mcollective/rpc.rb +2 -4
  85. data/lib/mcollective/rpc/actionrunner.rb +16 -18
  86. data/lib/mcollective/rpc/agent.rb +26 -43
  87. data/lib/mcollective/rpc/audit.rb +1 -0
  88. data/lib/mcollective/rpc/client.rb +67 -85
  89. data/lib/mcollective/rpc/helpers.rb +55 -62
  90. data/lib/mcollective/rpc/progress.rb +2 -2
  91. data/lib/mcollective/rpc/reply.rb +17 -19
  92. data/lib/mcollective/rpc/request.rb +7 -5
  93. data/lib/mcollective/rpc/result.rb +6 -8
  94. data/lib/mcollective/rpc/stats.rb +49 -58
  95. data/lib/mcollective/security/base.rb +29 -36
  96. data/lib/mcollective/security/choria.rb +765 -0
  97. data/lib/mcollective/shell.rb +9 -4
  98. data/lib/mcollective/signer/base.rb +28 -0
  99. data/lib/mcollective/signer/choria.rb +185 -0
  100. data/lib/mcollective/ssl.rb +8 -6
  101. data/lib/mcollective/util.rb +52 -53
  102. data/lib/mcollective/util/bolt_support.rb +176 -0
  103. data/lib/mcollective/util/bolt_support/plan_runner.rb +167 -0
  104. data/lib/mcollective/util/bolt_support/task_result.rb +94 -0
  105. data/lib/mcollective/util/bolt_support/task_results.rb +128 -0
  106. data/lib/mcollective/util/choria.rb +1103 -0
  107. data/lib/mcollective/util/indifferent_hash.rb +12 -0
  108. data/lib/mcollective/util/natswrapper.rb +242 -0
  109. data/lib/mcollective/util/playbook.rb +435 -0
  110. data/lib/mcollective/util/playbook/data_stores.rb +201 -0
  111. data/lib/mcollective/util/playbook/data_stores/base.rb +99 -0
  112. data/lib/mcollective/util/playbook/data_stores/consul_data_store.rb +88 -0
  113. data/lib/mcollective/util/playbook/data_stores/environment_data_store.rb +33 -0
  114. data/lib/mcollective/util/playbook/data_stores/etcd_data_store.rb +42 -0
  115. data/lib/mcollective/util/playbook/data_stores/file_data_store.rb +106 -0
  116. data/lib/mcollective/util/playbook/data_stores/shell_data_store.rb +103 -0
  117. data/lib/mcollective/util/playbook/inputs.rb +265 -0
  118. data/lib/mcollective/util/playbook/nodes.rb +207 -0
  119. data/lib/mcollective/util/playbook/nodes/mcollective_nodes.rb +86 -0
  120. data/lib/mcollective/util/playbook/nodes/pql_nodes.rb +40 -0
  121. data/lib/mcollective/util/playbook/nodes/shell_nodes.rb +55 -0
  122. data/lib/mcollective/util/playbook/nodes/terraform_nodes.rb +65 -0
  123. data/lib/mcollective/util/playbook/nodes/yaml_nodes.rb +47 -0
  124. data/lib/mcollective/util/playbook/playbook_logger.rb +47 -0
  125. data/lib/mcollective/util/playbook/puppet_logger.rb +51 -0
  126. data/lib/mcollective/util/playbook/report.rb +152 -0
  127. data/lib/mcollective/util/playbook/task_result.rb +55 -0
  128. data/lib/mcollective/util/playbook/tasks.rb +196 -0
  129. data/lib/mcollective/util/playbook/tasks/base.rb +45 -0
  130. data/lib/mcollective/util/playbook/tasks/graphite_event_task.rb +64 -0
  131. data/lib/mcollective/util/playbook/tasks/mcollective_task.rb +356 -0
  132. data/lib/mcollective/util/playbook/tasks/shell_task.rb +93 -0
  133. data/lib/mcollective/util/playbook/tasks/slack_task.rb +105 -0
  134. data/lib/mcollective/util/playbook/tasks/webhook_task.rb +136 -0
  135. data/lib/mcollective/util/playbook/template_util.rb +98 -0
  136. data/lib/mcollective/util/playbook/uses.rb +169 -0
  137. data/lib/mcollective/util/tasks_support.rb +733 -0
  138. data/lib/mcollective/util/tasks_support/cli.rb +260 -0
  139. data/lib/mcollective/util/tasks_support/default_formatter.rb +138 -0
  140. data/lib/mcollective/util/tasks_support/json_formatter.rb +108 -0
  141. data/lib/mcollective/validator.rb +6 -1
  142. data/lib/mcollective/validator/bolt_task_name_validator.ddl +7 -0
  143. data/lib/mcollective/validator/bolt_task_name_validator.rb +11 -0
  144. data/lib/mcollective/validator/length_validator.rb +1 -3
  145. metadata +67 -4
@@ -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