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.
- checksums.yaml +4 -4
- data/lib/mcollective.rb +1 -2
- data/lib/mcollective/agent/bolt_tasks.ddl +253 -0
- data/lib/mcollective/agent/bolt_tasks.json +365 -0
- data/lib/mcollective/agent/bolt_tasks.rb +178 -0
- data/lib/mcollective/agent/choria_util.ddl +152 -0
- data/lib/mcollective/agent/choria_util.json +244 -0
- data/lib/mcollective/agent/rpcutil.ddl +8 -4
- data/lib/mcollective/agent/rpcutil.json +333 -0
- data/lib/mcollective/agent/scout.ddl +169 -0
- data/lib/mcollective/agent/scout.json +224 -0
- data/lib/mcollective/agents.rb +7 -6
- data/lib/mcollective/aggregate.rb +4 -4
- data/lib/mcollective/aggregate/average.rb +2 -2
- data/lib/mcollective/aggregate/base.rb +2 -2
- data/lib/mcollective/aggregate/result.rb +3 -3
- data/lib/mcollective/aggregate/result/collection_result.rb +2 -2
- data/lib/mcollective/aggregate/result/numeric_result.rb +2 -2
- data/lib/mcollective/aggregate/sum.rb +2 -2
- data/lib/mcollective/aggregate/summary.rb +3 -4
- data/lib/mcollective/application.rb +57 -21
- data/lib/mcollective/application/choria.rb +189 -0
- data/lib/mcollective/application/completion.rb +6 -6
- data/lib/mcollective/application/facts.rb +11 -11
- data/lib/mcollective/application/federation.rb +237 -0
- data/lib/mcollective/application/find.rb +4 -4
- data/lib/mcollective/application/help.rb +3 -3
- data/lib/mcollective/application/inventory.rb +3 -341
- data/lib/mcollective/application/ping.rb +5 -51
- data/lib/mcollective/application/playbook.rb +207 -0
- data/lib/mcollective/application/plugin.rb +106 -106
- data/lib/mcollective/application/rpc.rb +3 -108
- data/lib/mcollective/application/tasks.rb +425 -0
- data/lib/mcollective/applications.rb +11 -10
- data/lib/mcollective/audit/choria.rb +33 -0
- data/lib/mcollective/cache.rb +2 -4
- data/lib/mcollective/client.rb +11 -10
- data/lib/mcollective/config.rb +21 -34
- data/lib/mcollective/connector/base.rb +2 -1
- data/lib/mcollective/connector/nats.ddl +9 -0
- data/lib/mcollective/connector/nats.rb +450 -0
- data/lib/mcollective/data.rb +8 -3
- data/lib/mcollective/data/agent_data.rb +1 -1
- data/lib/mcollective/data/base.rb +6 -5
- data/lib/mcollective/data/bolt_task_data.ddl +90 -0
- data/lib/mcollective/data/bolt_task_data.rb +32 -0
- data/lib/mcollective/data/collective_data.rb +1 -1
- data/lib/mcollective/data/fact_data.rb +6 -6
- data/lib/mcollective/data/fstat_data.rb +2 -4
- data/lib/mcollective/data/result.rb +7 -2
- data/lib/mcollective/ddl/agentddl.rb +5 -17
- data/lib/mcollective/ddl/base.rb +10 -13
- data/lib/mcollective/discovery.rb +24 -39
- data/lib/mcollective/discovery/choria.ddl +11 -0
- data/lib/mcollective/discovery/choria.rb +223 -0
- data/lib/mcollective/discovery/flatfile.rb +7 -8
- data/lib/mcollective/discovery/mc.rb +2 -2
- data/lib/mcollective/discovery/stdin.rb +17 -18
- data/lib/mcollective/exceptions.rb +13 -0
- data/lib/mcollective/facts/base.rb +9 -9
- data/lib/mcollective/facts/yaml_facts.rb +12 -12
- data/lib/mcollective/generators.rb +3 -3
- data/lib/mcollective/generators/agent_generator.rb +3 -4
- data/lib/mcollective/generators/base.rb +14 -15
- data/lib/mcollective/generators/data_generator.rb +5 -6
- data/lib/mcollective/log.rb +2 -2
- data/lib/mcollective/logger/base.rb +3 -2
- data/lib/mcollective/logger/console_logger.rb +10 -10
- data/lib/mcollective/logger/file_logger.rb +7 -7
- data/lib/mcollective/logger/syslog_logger.rb +11 -15
- data/lib/mcollective/message.rb +8 -39
- data/lib/mcollective/monkey_patches.rb +2 -4
- data/lib/mcollective/optionparser.rb +2 -1
- data/lib/mcollective/pluginmanager.rb +3 -5
- data/lib/mcollective/pluginpackager.rb +1 -3
- data/lib/mcollective/pluginpackager/agent_definition.rb +3 -8
- data/lib/mcollective/pluginpackager/forge_packager.rb +7 -9
- data/lib/mcollective/pluginpackager/standard_definition.rb +1 -2
- data/lib/mcollective/registration/base.rb +18 -16
- data/lib/mcollective/rpc.rb +2 -4
- data/lib/mcollective/rpc/actionrunner.rb +16 -18
- data/lib/mcollective/rpc/agent.rb +26 -43
- data/lib/mcollective/rpc/audit.rb +1 -0
- data/lib/mcollective/rpc/client.rb +67 -85
- data/lib/mcollective/rpc/helpers.rb +55 -62
- data/lib/mcollective/rpc/progress.rb +2 -2
- data/lib/mcollective/rpc/reply.rb +17 -19
- data/lib/mcollective/rpc/request.rb +7 -5
- data/lib/mcollective/rpc/result.rb +6 -8
- data/lib/mcollective/rpc/stats.rb +49 -58
- data/lib/mcollective/security/base.rb +13 -56
- data/lib/mcollective/security/choria.rb +765 -0
- data/lib/mcollective/shell.rb +9 -4
- data/lib/mcollective/signer/base.rb +28 -0
- data/lib/mcollective/signer/choria.rb +185 -0
- data/lib/mcollective/ssl.rb +8 -6
- data/lib/mcollective/util.rb +58 -55
- data/lib/mcollective/util/bolt_support.rb +176 -0
- data/lib/mcollective/util/bolt_support/plan_runner.rb +167 -0
- data/lib/mcollective/util/bolt_support/task_result.rb +94 -0
- data/lib/mcollective/util/bolt_support/task_results.rb +128 -0
- data/lib/mcollective/util/choria.rb +946 -0
- data/lib/mcollective/util/indifferent_hash.rb +12 -0
- data/lib/mcollective/util/natswrapper.rb +242 -0
- data/lib/mcollective/util/playbook.rb +435 -0
- data/lib/mcollective/util/playbook/data_stores.rb +201 -0
- data/lib/mcollective/util/playbook/data_stores/base.rb +99 -0
- data/lib/mcollective/util/playbook/data_stores/consul_data_store.rb +88 -0
- data/lib/mcollective/util/playbook/data_stores/environment_data_store.rb +33 -0
- data/lib/mcollective/util/playbook/data_stores/etcd_data_store.rb +42 -0
- data/lib/mcollective/util/playbook/data_stores/file_data_store.rb +106 -0
- data/lib/mcollective/util/playbook/data_stores/shell_data_store.rb +103 -0
- data/lib/mcollective/util/playbook/inputs.rb +265 -0
- data/lib/mcollective/util/playbook/nodes.rb +207 -0
- data/lib/mcollective/util/playbook/nodes/mcollective_nodes.rb +86 -0
- data/lib/mcollective/util/playbook/nodes/pql_nodes.rb +40 -0
- data/lib/mcollective/util/playbook/nodes/shell_nodes.rb +55 -0
- data/lib/mcollective/util/playbook/nodes/terraform_nodes.rb +65 -0
- data/lib/mcollective/util/playbook/nodes/yaml_nodes.rb +47 -0
- data/lib/mcollective/util/playbook/playbook_logger.rb +47 -0
- data/lib/mcollective/util/playbook/puppet_logger.rb +51 -0
- data/lib/mcollective/util/playbook/report.rb +152 -0
- data/lib/mcollective/util/playbook/task_result.rb +55 -0
- data/lib/mcollective/util/playbook/tasks.rb +196 -0
- data/lib/mcollective/util/playbook/tasks/base.rb +45 -0
- data/lib/mcollective/util/playbook/tasks/graphite_event_task.rb +64 -0
- data/lib/mcollective/util/playbook/tasks/mcollective_task.rb +356 -0
- data/lib/mcollective/util/playbook/tasks/shell_task.rb +93 -0
- data/lib/mcollective/util/playbook/tasks/slack_task.rb +105 -0
- data/lib/mcollective/util/playbook/tasks/webhook_task.rb +136 -0
- data/lib/mcollective/util/playbook/template_util.rb +98 -0
- data/lib/mcollective/util/playbook/uses.rb +169 -0
- data/lib/mcollective/util/tasks_support.rb +751 -0
- data/lib/mcollective/util/tasks_support/cli.rb +260 -0
- data/lib/mcollective/util/tasks_support/default_formatter.rb +138 -0
- data/lib/mcollective/util/tasks_support/json_formatter.rb +108 -0
- data/lib/mcollective/validator.rb +6 -1
- data/lib/mcollective/validator/bolt_task_name_validator.ddl +7 -0
- data/lib/mcollective/validator/bolt_task_name_validator.rb +11 -0
- data/lib/mcollective/validator/length_validator.rb +1 -3
- metadata +65 -6
- data/lib/mcollective/application/describe_filter.rb +0 -87
- data/lib/mcollective/matcher.rb +0 -220
- data/lib/mcollective/matcher/parser.rb +0 -128
- 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
|