1#!/usr/bin/env ruby
2#
3# Copyright (C) 2017 Denver Gingerich <denver@ossguy.com>
4# Copyright (C) 2017 Stephen Paul Weber <singpolyma@singpolyma.net>
5#
6# This file is part of sgx-catapult.
7#
8# sgx-catapult is free software: you can redistribute it and/or modify it under
9# the terms of the GNU Affero General Public License as published by the Free
10# Software Foundation, either version 3 of the License, or (at your option) any
11# later version.
12#
13# sgx-catapult is distributed in the hope that it will be useful, but WITHOUT
14# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
15# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
16# details.
17#
18# You should have received a copy of the GNU Affero General Public License along
19# with sgx-catapult. If not, see <http://www.gnu.org/licenses/>.
20
21require 'blather/client/dsl'
22require 'json'
23require 'net/http'
24require 'redis/connection/hiredis'
25require 'uri'
26
27require 'goliath/api'
28require 'goliath/server'
29require 'log4r'
30
31if ARGV.size != 8 then
32 puts "Usage: sgx-catapult.rb <component_jid> <component_password> " +
33 "<server_hostname> <server_port> " +
34 "<redis_hostname> <redis_port> <delivery_receipt_url> " +
35 "<http_listen_port>"
36 exit 0
37end
38
39module SGXcatapult
40 extend Blather::DSL
41
42 def self.run
43 client.run
44 end
45
46 # so classes outside this module can write messages, too
47 def self.write(stanza)
48 client.write(stanza)
49 end
50
51 def self.error_msg(orig, query_node, type, name, text = nil)
52 if not query_node.nil?
53 orig.add_child(query_node)
54 orig.type = :error
55 end
56
57 error = Nokogiri::XML::Node.new 'error', orig.document
58 error['type'] = type
59 orig.add_child(error)
60
61 suberr = Nokogiri::XML::Node.new name, orig.document
62 suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
63 error.add_child(suberr)
64
65 # TODO: add some explanatory xml:lang='en' text (see text param)
66 puts "RESPONSE3: #{orig.inspect}"
67 return orig
68 end
69
70 setup ARGV[0], ARGV[1], ARGV[2], ARGV[3]
71
72 message :chat?, :body do |m|
73 num_dest = m.to.to_s.split('@', 2)[0]
74
75 if num_dest[0] != '+'
76 # TODO: add text re number not (yet) supported/implmnted
77 write_to_stream error_msg(m.reply, m.body, :modify,
78 'policy-violation')
79 next
80 end
81
82 bare_jid = m.from.to_s.split('/', 2)[0]
83 cred_key = "catapult_cred-" + bare_jid
84
85 conn = Hiredis::Connection.new
86 conn.connect(ARGV[4], ARGV[5].to_i)
87
88 conn.write ["EXISTS", cred_key]
89 if conn.read == 0
90 conn.disconnect
91
92 # TODO: add text re credentials not being registered
93 write_to_stream error_msg(m.reply, m.body, :auth,
94 'registration-required')
95 next
96 end
97
98 conn.write ["LRANGE", cred_key, 0, 3]
99 creds = conn.read
100 conn.disconnect
101
102 uri = URI.parse('https://api.catapult.inetwork.com')
103 http = Net::HTTP.new(uri.host, uri.port)
104 http.use_ssl = true
105 request = Net::HTTP::Post.new('/v1/users/' + creds[0] +
106 '/messages')
107 request.basic_auth creds[1], creds[2]
108 request.add_field('Content-Type', 'application/json')
109 request.body = JSON.dump({
110 'from' => creds[3],
111 'to' => num_dest,
112 'text' => m.body,
113 'tag' => m.id, # TODO: message has it?
114 'receiptRequested' => 'all',
115 'callbackUrl' => ARGV[6]
116 })
117 response = http.request(request)
118
119 puts 'API response to send: ' + response.to_s + ' with code ' +
120 response.code + ', body "' + response.body + '"'
121
122 if response.code != '201'
123 # TODO: add text re unexpected code; mention code number
124 write_to_stream error_msg(m.reply, m.body, :cancel,
125 'internal-server-error')
126 next
127 end
128 end
129
130 def self.user_cap_identities()
131 [{:category => 'client', :type => 'sms'}]
132 end
133
134 def self.user_cap_features()
135 # TODO: add more features
136 ["urn:xmpp:receipts"]
137 end
138
139 presence :subscribe? do |p|
140 puts "PRESENCE1: #{p.inspect}"
141
142 msg = Blather::Stanza::Presence.new
143 msg.to = p.from
144 msg.from = p.to
145 msg.type = :subscribed
146
147 puts "RESPONSE5: #{msg.inspect}"
148 write_to_stream msg
149 end
150
151 presence :probe? do |p|
152 puts 'PRESENCE2: ' + p.inspect
153
154 caps = Blather::Stanza::Capabilities.new
155 # TODO: user a better node URI (?)
156 caps.node = 'http://catapult.sgx.soprani.ca/'
157 caps.identities = user_cap_identities()
158 caps.features = user_cap_features()
159
160 msg = caps.c
161 msg.to = p.from
162 msg.from = p.to.to_s + '/sgx'
163
164 puts 'RESPONSE6: ' + msg.inspect
165 write_to_stream msg
166 end
167
168 iq '/iq/ns:query', :ns =>
169 'http://jabber.org/protocol/disco#items' do |i, xpath_result|
170
171 write_to_stream i.reply
172 end
173
174 iq '/iq/ns:query', :ns =>
175 'http://jabber.org/protocol/disco#info' do |i, xpath_result|
176
177 if i.to.to_s.include? '@'
178 # TODO: confirm the node URL is expected using below
179 #puts "XR[node]: #{xpath_result[0]['node']}"
180
181 msg = i.reply
182 msg.identities = user_cap_identities()
183 msg.features = user_cap_features()
184
185 puts 'RESPONSE7: ' + msg.inspect
186 write_to_stream msg
187 next
188 end
189
190 msg = i.reply
191 msg.identities = [{:name =>
192 'Soprani.ca Gateway to XMPP - Catapult',
193 :type => 'sms-ctplt', :category => 'gateway'}]
194 msg.features = ["jabber:iq:register",
195 "jabber:iq:gateway", "jabber:iq:private",
196 "http://jabber.org/protocol/disco#info",
197 "http://jabber.org/protocol/commands",
198 "http://jabber.org/protocol/muc"]
199 write_to_stream msg
200 end
201
202 iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
203 puts "IQ: #{i.inspect}"
204
205 if i.type == :set
206 xn = qn.children.find { |v| v.element_name == "x" }
207
208 user_id = ''
209 api_token = ''
210 api_secret = ''
211 phone_num = ''
212
213 if xn.nil?
214 user_id = qn.children.find {
215 |v| v.element_name == "nick" }
216 api_token = qn.children.find {
217 |v| v.element_name == "username" }
218 api_secret = qn.children.find {
219 |v| v.element_name == "password" }
220 phone_num = qn.children.find {
221 |v| v.element_name == "phone" }
222 else
223 for field in xn.children
224 if field.element_name == "field"
225 val = field.children.find { |v|
226 v.element_name == "value" }
227
228 case field['var']
229 when 'nick'
230 user_id = val.text
231 when 'username'
232 api_token = val.text
233 when 'password'
234 api_secret = val.text
235 when 'phone'
236 phone_num = val.text
237 else
238 # TODO: error
239 puts "?: " +field['var']
240 end
241 end
242 end
243 end
244
245 if phone_num[0] != '+'
246 # TODO: add text re number not (yet) supported
247 write_to_stream error_msg(i.reply, qn, :modify,
248 'policy-violation')
249 next
250 end
251
252 uri = URI.parse('https://api.catapult.inetwork.com')
253 http = Net::HTTP.new(uri.host, uri.port)
254 http.use_ssl = true
255 request = Net::HTTP::Get.new('/v1/users/' + user_id +
256 '/phoneNumbers/' + phone_num)
257 request.basic_auth api_token, api_secret
258 response = http.request(request)
259
260 puts 'API response: ' + response.to_s + ' with code ' +
261 response.code + ', body "' + response.body + '"'
262
263 if response.code == '200'
264 params = JSON.parse response.body
265 if params['numberState'] == 'enabled'
266 num_key = "catapult_num-" + phone_num
267
268 bare_jid = i.from.to_s.split('/', 2)[0]
269 cred_key = "catapult_cred-" + bare_jid
270
271 # TODO: pre-validate ARGV[5] is integer
272 conn = Hiredis::Connection.new
273 conn.connect(ARGV[4], ARGV[5].to_i)
274
275 conn.write ["EXISTS", num_key]
276 if conn.read == 1
277 conn.disconnect
278
279 # TODO: add txt re num exists
280 write_to_stream error_msg(
281 i.reply, qn, :cancel,
282 'conflict')
283 next
284 end
285
286 conn.write ["EXISTS", cred_key]
287 if conn.read == 1
288 conn.disconnect
289
290 # TODO: add txt re already exist
291 write_to_stream error_msg(
292 i.reply, qn, :cancel,
293 'conflict')
294 next
295 end
296
297 conn.write ["RPUSH",num_key,bare_jid]
298 if conn.read != 1
299 conn.disconnect
300
301 # TODO: catch/relay RuntimeError
302 # TODO: add txt re push failure
303 write_to_stream error_msg(
304 i.reply, qn, :cancel,
305 'internal-server-error')
306 next
307 end
308
309 conn.write ["RPUSH",cred_key,user_id]
310 conn.write ["RPUSH",cred_key,api_token]
311 conn.write ["RPUSH",cred_key,api_secret]
312 conn.write ["RPUSH",cred_key,phone_num]
313
314 for n in 1..4 do
315 # TODO: catch/relay RuntimeError
316 result = conn.read
317 if result != n
318 conn.disconnect
319
320 write_to_stream(
321 error_msg(
322 i.reply, qn, :cancel,
323 'internal-server-error')
324 )
325 next
326 end
327 end
328 conn.disconnect
329
330 write_to_stream i.reply
331 else
332 # TODO: add text re number disabled
333 write_to_stream error_msg(i.reply, qn,
334 :modify, 'not-acceptable')
335 end
336 elsif response.code == '401'
337 # TODO: add text re bad credentials
338 write_to_stream error_msg(i.reply, qn, :auth,
339 'not-authorized')
340 elsif response.code == '404'
341 # TODO: add text re number not found or disabled
342 write_to_stream error_msg(i.reply, qn, :cancel,
343 'item-not-found')
344 else
345 # TODO: add text re misc error, and mention code
346 write_to_stream error_msg(i.reply, qn, :modify,
347 'not-acceptable')
348 end
349
350 elsif i.type == :get
351 orig = i.reply
352
353 msg = Nokogiri::XML::Node.new 'query',orig.document
354 msg['xmlns'] = 'jabber:iq:register'
355 n1 = Nokogiri::XML::Node.new 'instructions',msg.document
356 n1.content= "Enter the information from your Account " +
357 "page as well as the Phone Number\nin your " +
358 "account you want to use (ie. '+12345678901')" +
359 ".\nUser Id is nick, API Token is username, " +
360 "API Secret is password, Phone Number is phone"+
361 ".\n\nThe source code for this gateway is at " +
362 "https://github.com/ossguy/sgx-catapult ." +
363 "\nCopyright (C) 2017 Denver Gingerich, " +
364 "licensed under AGPLv3+."
365 n2 = Nokogiri::XML::Node.new 'nick',msg.document
366 n3 = Nokogiri::XML::Node.new 'username',msg.document
367 n4 = Nokogiri::XML::Node.new 'password',msg.document
368 n5 = Nokogiri::XML::Node.new 'phone',msg.document
369 msg.add_child(n1)
370 msg.add_child(n2)
371 msg.add_child(n3)
372 msg.add_child(n4)
373 msg.add_child(n5)
374
375 x = Blather::Stanza::X.new :form, [
376 {:required => true, :type => :"text-single",
377 :label => 'User Id', :var => 'nick'},
378 {:required => true, :type => :"text-single",
379 :label => 'API Token', :var => 'username'},
380 {:required => true, :type => :"text-private",
381 :label => 'API Secret', :var => 'password'},
382 {:required => true, :type => :"text-single",
383 :label => 'Phone Number', :var => 'phone'}
384 ]
385 x.title= 'Register for ' +
386 'Soprani.ca Gateway to XMPP - Catapult'
387 x.instructions= "Enter the details from your Account " +
388 "page as well as the Phone Number\nin your " +
389 "account you want to use (ie. '+12345678901')" +
390 ".\n\nThe source code for this gateway is at " +
391 "https://github.com/ossguy/sgx-catapult ." +
392 "\nCopyright (C) 2017 Denver Gingerich, " +
393 "licensed under AGPLv3+."
394 msg.add_child(x)
395
396 orig.add_child(msg)
397 puts "RESPONSE2: #{orig.inspect}"
398 write_to_stream orig
399 puts "SENT"
400 end
401 end
402
403 subscription(:request?) do |s|
404 # TODO: are these the best to return? really need '!' here?
405 #write_to_stream s.approve!
406 #write_to_stream s.request!
407 end
408end
409
410[:INT, :TERM].each do |sig|
411 trap(sig) {
412 puts 'Shutting down gateway...'
413 SGXcatapult.shutdown
414 puts 'Gateway has terminated.'
415
416 EM.stop
417 }
418end
419
420class ReceiptMessage < Blather::Stanza
421 def self.new(to = nil)
422 node = super :message
423 node.to = to
424 node
425 end
426end
427
428class WebhookHandler < Goliath::API
429 def response(env)
430 puts 'ENV: ' + env.to_s
431 body = Rack::Request.new(env).body.read
432 puts 'BODY: ' + body
433 params = JSON.parse body
434
435 users_num = ''
436 others_num = ''
437 if params['direction'] == 'in'
438 users_num = params['to']
439 others_num = params['from']
440 elsif params['direction'] == 'out'
441 users_num = params['from']
442 others_num = params['to']
443 else
444 # TODO: exception or similar
445 puts "big problem: '" + params['direction'] + "'"
446 return [200, {}, "OK"]
447 end
448
449 num_key = "catapult_num-" + users_num
450
451 # TODO: validate that others_num starts with '+' or is shortcode
452
453 conn = Hiredis::Connection.new
454 conn.connect(ARGV[4], ARGV[5].to_i)
455
456 conn.write ["EXISTS", num_key]
457 if conn.read == 0
458 conn.disconnect
459
460 puts "num_key (#{num_key}) DNE; Catapult misconfigured?"
461
462 # TODO: likely not appropriate; give error to Catapult?
463 # TODO: add text re credentials not being registered
464 #write_to_stream error_msg(m.reply, m.body, :auth,
465 # 'registration-required')
466 return [200, {}, "OK"]
467 end
468
469 conn.write ["LRANGE", num_key, 0, 0]
470 bare_jid = conn.read[0]
471 conn.disconnect
472
473 msg = ''
474 case params['direction']
475 when 'in'
476 text = ''
477 case params['eventType']
478 when 'sms'
479 text = params['text']
480 when 'mms'
481 text = "MMS (pic not implemented) with text: " +
482 params['text']
483 else
484 text = "unknown type (#{params['eventType']})" +
485 " with text: " + params['text']
486
487 # TODO log/notify of this properly
488 puts text
489 end
490
491 msg = Blather::Stanza::Message.new(bare_jid, text)
492 else # per prior switch, this is: params['direction'] == 'out'
493 msg = ReceiptMessage.new(bare_jid)
494 msg['id'] = params['tag']
495
496 case params['deliveryState']
497 when 'not-delivered'
498 # TODO: add text re deliveryDescription reason
499 msg = SGXcatapult.error_msg(msg, nil, :cancel,
500 'service-unavailable')
501 return [200, {}, "OK"]
502 when 'delivered'
503 # TODO: send only when requested per XEP-0184
504 rcvd = Nokogiri::XML::Node.new 'received',
505 msg.document
506 rcvd['xmlns'] = 'urn:xmpp:receipts'
507 rcvd['id'] = params['tag']
508 msg.add_child(rcvd)
509 when 'waiting'
510 # can't really do anything with it; nice to know
511 puts "message with id #{params['id']} waiting"
512 return [200, {}, "OK"]
513 else
514 # TODO: notify somehow of unknown state receivd?
515 puts "message with id #{params['id']} has " +
516 "other state #{params['deliveryState']}"
517 return [200, {}, "OK"]
518 end
519
520 puts "RESPONSE4: #{msg.inspect}"
521 end
522
523 msg.from = others_num + '@' + ARGV[0]
524 SGXcatapult.write(msg)
525
526 [200, {}, "OK"]
527 end
528end
529
530EM.run do
531 SGXcatapult.run
532
533 server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
534 server.api = WebhookHandler.new
535 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
536 server.logger = Log4r::Logger.new('goliath')
537 server.logger.add(Log4r::StdoutOutputter.new('console'))
538 server.logger.level = Log4r::INFO
539 server.start
540end