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'
26require 'uuid'
27
28require 'goliath/api'
29require 'goliath/server'
30require 'log4r'
31
32puts "Soprani.ca/SMS Gateway for XMPP - Catapult v0.011"
33
34if ARGV.size != 8 then
35 puts "Usage: sgx-catapult.rb <component_jid> <component_password> " +
36 "<server_hostname> <server_port> " +
37 "<redis_hostname> <redis_port> <delivery_receipt_url> " +
38 "<http_listen_port>"
39 exit 0
40end
41
42module SGXcatapult
43 extend Blather::DSL
44
45 @jingle_sids = Hash.new
46 @uuid_gen = UUID.new
47
48 def self.run
49 client.run
50 end
51
52 # so classes outside this module can write messages, too
53 def self.write(stanza)
54 client.write(stanza)
55 end
56
57 def self.error_msg(orig, query_node, type, name, text = nil)
58 if not query_node.nil?
59 orig.add_child(query_node)
60 orig.type = :error
61 end
62
63 error = Nokogiri::XML::Node.new 'error', orig.document
64 error['type'] = type
65 orig.add_child(error)
66
67 suberr = Nokogiri::XML::Node.new name, orig.document
68 suberr['xmlns'] = 'urn:ietf:params:xml:ns:xmpp-stanzas'
69 error.add_child(suberr)
70
71 # TODO: add some explanatory xml:lang='en' text (see text param)
72 puts "RESPONSE3: #{orig.inspect}"
73 return orig
74 end
75
76 setup ARGV[0], ARGV[1], ARGV[2], ARGV[3]
77
78 message :chat?, :body do |m|
79 num_dest = m.to.to_s.split('@', 2)[0]
80
81 if num_dest[0] != '+'
82 # TODO: add text re number not (yet) supported/implmnted
83 write_to_stream error_msg(m.reply, m.body, :cancel,
84 'item-not-found')
85 next
86 end
87
88 bare_jid = m.from.to_s.split('/', 2)[0]
89 cred_key = "catapult_cred-" + bare_jid
90
91 conn = Hiredis::Connection.new
92 conn.connect(ARGV[4], ARGV[5].to_i)
93
94 conn.write ["EXISTS", cred_key]
95 if conn.read == 0
96 conn.disconnect
97
98 # TODO: add text re credentials not being registered
99 write_to_stream error_msg(m.reply, m.body, :auth,
100 'registration-required')
101 next
102 end
103
104 conn.write ["LRANGE", cred_key, 0, 3]
105 user_id, api_token, api_secret, users_num = conn.read
106 conn.disconnect
107
108 uri = URI.parse('https://api.catapult.inetwork.com')
109 http = Net::HTTP.new(uri.host, uri.port)
110 http.use_ssl = true
111 request = Net::HTTP::Post.new('/v1/users/' + user_id +
112 '/messages')
113 request.basic_auth api_token, api_secret
114 request.add_field('Content-Type', 'application/json')
115 request.body = JSON.dump({
116 'from' => users_num,
117 'to' => num_dest,
118 'text' => m.body,
119 'tag' => m.id, # TODO: message has it?
120 'receiptRequested' => 'all',
121 'callbackUrl' => ARGV[6]
122 })
123 response = http.request(request)
124
125 puts 'API response to send: ' + response.to_s + ' with code ' +
126 response.code + ', body "' + response.body + '"'
127
128 if response.code != '201'
129 # TODO: add text re unexpected code; mention code number
130 write_to_stream error_msg(m.reply, m.body, :cancel,
131 'internal-server-error')
132 next
133 end
134 end
135
136 def self.user_cap_identities()
137 [{:category => 'client', :type => 'sms'}]
138 end
139
140 def self.user_cap_features()
141 [
142 "urn:xmpp:receipts",
143 "urn:xmpp:jingle:1", "urn:xmpp:jingle:transports:ibb:1",
144
145 # TODO: eventually add more efficient file transfer mechanisms
146 #"urn:xmpp:jingle:transports:s5b:1",
147
148 # TODO: MUST add all relevant/reasonable vers of file-transfer
149 #"urn:xmpp:jingle:apps:file-transfer:4"
150 "urn:xmpp:jingle:apps:file-transfer:3"
151 ]
152 end
153
154 presence :subscribe? do |p|
155 puts "PRESENCE1: #{p.inspect}"
156
157 msg = Blather::Stanza::Presence.new
158 msg.to = p.from
159 msg.from = p.to
160 msg.type = :subscribed
161
162 puts "RESPONSE5: #{msg.inspect}"
163 write_to_stream msg
164 end
165
166 presence :probe? do |p|
167 puts 'PRESENCE2: ' + p.inspect
168
169 caps = Blather::Stanza::Capabilities.new
170 # TODO: user a better node URI (?)
171 caps.node = 'http://catapult.sgx.soprani.ca/'
172 caps.identities = user_cap_identities()
173 caps.features = user_cap_features()
174
175 msg = caps.c
176 msg.to = p.from
177 msg.from = p.to.to_s + '/sgx'
178
179 puts 'RESPONSE6: ' + msg.inspect
180 write_to_stream msg
181 end
182
183 iq '/iq/ns:jingle', :ns => 'urn:xmpp:jingle:1' do |i, jn|
184 puts "IQj: #{i.inspect}"
185
186 if jn[0]['action'] == 'transport-accept'
187 puts "REPLY0: #{i.reply.inspect}"
188 write_to_stream i.reply
189 next
190 elsif jn[0]['action'] == 'session-terminate'
191 # TODO: unexpected (usually we do this; handle?)
192 puts "TERMINATED"
193 next
194 elsif jn[0]['action'] == 'transport-info'
195 # TODO: unexpected, but should handle in a nice way
196 puts "FAIL!!!"
197 next
198 elsif i.type == :error
199 # TODO: do something, maybe terminating the connection
200 puts 'ERROR!!!'
201 next
202 end
203
204 # TODO: should probably confirm we got session-initiate here
205
206 write_to_stream i.reply
207 puts "RESPONSE8: #{i.reply.inspect}"
208
209 msg = Blather::Stanza::Iq.new :set
210 msg.to = i.from
211 msg.from = i.to
212
213 cn = jn.children.find { |v| v.element_name == "content" }
214 puts 'CN-name: ' + cn['name']
215 puts 'JN-sid: ' + jn[0]['sid']
216
217 ibb_found = false
218 last_sid = ''
219 for child in cn.children
220 if child.element_name == 'transport'
221 puts 'TPORT: ' + child.namespace.href
222 last_sid = child['sid']
223 if 'urn:xmpp:jingle:transports:ibb:1' ==
224 child.namespace.href
225
226 ibb_found = true
227 break
228 end
229 end
230 end
231
232 j = Nokogiri::XML::Node.new 'jingle',msg.document
233 j['xmlns'] = 'urn:xmpp:jingle:1'
234 j['sid'] = jn[0]['sid']
235 msg.add_child(j)
236
237 content = Nokogiri::XML::Node.new 'content',msg.document
238 content['name'] = cn['name']
239 content['creator'] = 'initiator'
240 j.add_child(content)
241
242 transport = Nokogiri::XML::Node.new 'transport',msg.document
243 # TODO: make block-size more variable and/or dependent on sender
244 transport['block-size'] = '4096'
245 transport['xmlns'] = 'urn:xmpp:jingle:transports:ibb:1'
246 if ibb_found
247 transport['sid'] = last_sid
248 j['action'] = 'session-accept'
249 j['responder'] = i.from
250
251 dsc = Nokogiri::XML::Node.new 'description',msg.document
252 dsc['xmlns'] = 'urn:xmpp:jingle:apps:file-transfer:3'
253 content.add_child(dsc)
254 else
255 # for Conversations - it tries s5b even if caps ibb-only
256 transport['sid'] = @uuid_gen.generate
257 j['action'] = 'transport-replace'
258 j['initiator'] = i.from
259 end
260 content.add_child(transport)
261
262 @jingle_sids[transport['sid']] = jn[0]['sid']
263
264 puts "RESPONSE9: #{msg.inspect}"
265 write_to_stream msg
266 end
267
268 iq '/iq/ns:open', :ns =>
269 'http://jabber.org/protocol/ibb' do |i, xpath_result|
270
271 puts "IQo: #{i.inspect}"
272 write_to_stream i.reply
273 end
274
275 iq '/iq/ns:data', :ns =>
276 'http://jabber.org/protocol/ibb' do |i, dn|
277
278 # TODO: decode and save partial data so can upload it when done
279 puts "IQd: #{i.inspect}"
280 write_to_stream i.reply
281 end
282
283 iq '/iq/ns:close', :ns =>
284 'http://jabber.org/protocol/ibb' do |i, cn|
285
286 puts "IQc: #{i.inspect}"
287 write_to_stream i.reply
288
289 # TODO: upload cached data to server (do before success reply)
290
291 # received the complete file so now close the stream
292 msg = Blather::Stanza::Iq.new :set
293 msg.to = i.from
294 msg.from = i.to
295
296 j = Nokogiri::XML::Node.new 'jingle',msg.document
297 j['xmlns'] = 'urn:xmpp:jingle:1'
298 j['action'] = 'session-terminate'
299 j['sid'] = @jingle_sids[cn[0]['sid']]
300 msg.add_child(j)
301
302 r = Nokogiri::XML::Node.new 'reason',msg.document
303 s = Nokogiri::XML::Node.new 'success',msg.document
304 r.add_child(s)
305 j.add_child(r)
306
307 puts 'RESPONSE1: ' + msg.inspect
308 write_to_stream msg
309 end
310
311 iq '/iq/ns:query', :ns =>
312 'http://jabber.org/protocol/disco#items' do |i, xpath_result|
313
314 write_to_stream i.reply
315 end
316
317 iq '/iq/ns:query', :ns =>
318 'http://jabber.org/protocol/disco#info' do |i, xpath_result|
319
320 if i.to.to_s.include? '@'
321 # TODO: confirm the node URL is expected using below
322 #puts "XR[node]: #{xpath_result[0]['node']}"
323
324 msg = i.reply
325 msg.identities = user_cap_identities()
326 msg.features = user_cap_features()
327
328 puts 'RESPONSE7: ' + msg.inspect
329 write_to_stream msg
330 next
331 end
332
333 msg = i.reply
334 msg.identities = [{:name =>
335 'Soprani.ca Gateway to XMPP - Catapult',
336 :type => 'sms-ctplt', :category => 'gateway'}]
337 msg.features = ["jabber:iq:register",
338 "jabber:iq:gateway", "jabber:iq:private",
339 "http://jabber.org/protocol/disco#info",
340 "http://jabber.org/protocol/commands",
341 "http://jabber.org/protocol/muc"]
342 write_to_stream msg
343 end
344
345 iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
346 puts "IQ: #{i.inspect}"
347
348 if i.type == :set
349 xn = qn.children.find { |v| v.element_name == "x" }
350
351 user_id = ''
352 api_token = ''
353 api_secret = ''
354 phone_num = ''
355
356 if xn.nil?
357 user_id = qn.children.find {
358 |v| v.element_name == "nick" }
359 api_token = qn.children.find {
360 |v| v.element_name == "username" }
361 api_secret = qn.children.find {
362 |v| v.element_name == "password" }
363 phone_num = qn.children.find {
364 |v| v.element_name == "phone" }
365 else
366 for field in xn.children
367 if field.element_name == "field"
368 val = field.children.find { |v|
369 v.element_name == "value" }
370
371 case field['var']
372 when 'nick'
373 user_id = val.text
374 when 'username'
375 api_token = val.text
376 when 'password'
377 api_secret = val.text
378 when 'phone'
379 phone_num = val.text
380 else
381 # TODO: error
382 puts "?: " +field['var']
383 end
384 end
385 end
386 end
387
388 if phone_num[0] != '+'
389 # TODO: add text re number not (yet) supported
390 write_to_stream error_msg(i.reply, qn, :cancel,
391 'item-not-found')
392 next
393 end
394
395 uri = URI.parse('https://api.catapult.inetwork.com')
396 http = Net::HTTP.new(uri.host, uri.port)
397 http.use_ssl = true
398 request = Net::HTTP::Get.new('/v1/users/' + user_id +
399 '/phoneNumbers/' + phone_num)
400 request.basic_auth api_token, api_secret
401 response = http.request(request)
402
403 puts 'API response: ' + response.to_s + ' with code ' +
404 response.code + ', body "' + response.body + '"'
405
406 if response.code == '200'
407 params = JSON.parse response.body
408 if params['numberState'] == 'enabled'
409 num_key = "catapult_num-" + phone_num
410
411 bare_jid = i.from.to_s.split('/', 2)[0]
412 cred_key = "catapult_cred-" + bare_jid
413
414 # TODO: pre-validate ARGV[5] is integer
415 conn = Hiredis::Connection.new
416 conn.connect(ARGV[4], ARGV[5].to_i)
417
418 conn.write ["EXISTS", num_key]
419 if conn.read == 1
420 conn.disconnect
421
422 # TODO: add txt re num exists
423 write_to_stream error_msg(
424 i.reply, qn, :cancel,
425 'conflict')
426 next
427 end
428
429 conn.write ["EXISTS", cred_key]
430 if conn.read == 1
431 conn.disconnect
432
433 # TODO: add txt re already exist
434 write_to_stream error_msg(
435 i.reply, qn, :cancel,
436 'conflict')
437 next
438 end
439
440 conn.write ["RPUSH",num_key,bare_jid]
441 if conn.read != 1
442 conn.disconnect
443
444 # TODO: catch/relay RuntimeError
445 # TODO: add txt re push failure
446 write_to_stream error_msg(
447 i.reply, qn, :cancel,
448 'internal-server-error')
449 next
450 end
451
452 conn.write ["RPUSH",cred_key,user_id]
453 conn.write ["RPUSH",cred_key,api_token]
454 conn.write ["RPUSH",cred_key,api_secret]
455 conn.write ["RPUSH",cred_key,phone_num]
456
457 for n in 1..4 do
458 # TODO: catch/relay RuntimeError
459 result = conn.read
460 if result != n
461 conn.disconnect
462
463 write_to_stream(
464 error_msg(
465 i.reply, qn, :cancel,
466 'internal-server-error')
467 )
468 next
469 end
470 end
471 conn.disconnect
472
473 write_to_stream i.reply
474 else
475 # TODO: add text re number disabled
476 write_to_stream error_msg(i.reply, qn,
477 :modify, 'not-acceptable')
478 end
479 elsif response.code == '401'
480 # TODO: add text re bad credentials
481 write_to_stream error_msg(i.reply, qn, :auth,
482 'not-authorized')
483 elsif response.code == '404'
484 # TODO: add text re number not found or disabled
485 write_to_stream error_msg(i.reply, qn, :cancel,
486 'item-not-found')
487 else
488 # TODO: add text re misc error, and mention code
489 write_to_stream error_msg(i.reply, qn, :modify,
490 'not-acceptable')
491 end
492
493 elsif i.type == :get
494 orig = i.reply
495
496 msg = Nokogiri::XML::Node.new 'query',orig.document
497 msg['xmlns'] = 'jabber:iq:register'
498 n1 = Nokogiri::XML::Node.new 'instructions',msg.document
499 n1.content= "Enter the information from your Account " +
500 "page as well as the Phone Number\nin your " +
501 "account you want to use (ie. '+12345678901')" +
502 ".\nUser Id is nick, API Token is username, " +
503 "API Secret is password, Phone Number is phone"+
504 ".\n\nThe source code for this gateway is at " +
505 "https://github.com/ossguy/sgx-catapult ." +
506 "\nCopyright (C) 2017 Denver Gingerich and " +
507 "others, licensed under AGPLv3+."
508 n2 = Nokogiri::XML::Node.new 'nick',msg.document
509 n3 = Nokogiri::XML::Node.new 'username',msg.document
510 n4 = Nokogiri::XML::Node.new 'password',msg.document
511 n5 = Nokogiri::XML::Node.new 'phone',msg.document
512 msg.add_child(n1)
513 msg.add_child(n2)
514 msg.add_child(n3)
515 msg.add_child(n4)
516 msg.add_child(n5)
517
518 x = Blather::Stanza::X.new :form, [
519 {:required => true, :type => :"text-single",
520 :label => 'User Id', :var => 'nick'},
521 {:required => true, :type => :"text-single",
522 :label => 'API Token', :var => 'username'},
523 {:required => true, :type => :"text-private",
524 :label => 'API Secret', :var => 'password'},
525 {:required => true, :type => :"text-single",
526 :label => 'Phone Number', :var => 'phone'}
527 ]
528 x.title= 'Register for ' +
529 'Soprani.ca Gateway to XMPP - Catapult'
530 x.instructions= "Enter the details from your Account " +
531 "page as well as the Phone Number\nin your " +
532 "account you want to use (ie. '+12345678901')" +
533 ".\n\nThe source code for this gateway is at " +
534 "https://github.com/ossguy/sgx-catapult ." +
535 "\nCopyright (C) 2017 Denver Gingerich and " +
536 "others, licensed under AGPLv3+."
537 msg.add_child(x)
538
539 orig.add_child(msg)
540 puts "RESPONSE2: #{orig.inspect}"
541 write_to_stream orig
542 puts "SENT"
543 end
544 end
545
546 subscription(:request?) do |s|
547 # TODO: are these the best to return? really need '!' here?
548 #write_to_stream s.approve!
549 #write_to_stream s.request!
550 end
551end
552
553[:INT, :TERM].each do |sig|
554 trap(sig) {
555 puts 'Shutting down gateway...'
556 SGXcatapult.shutdown
557 puts 'Gateway has terminated.'
558
559 EM.stop
560 }
561end
562
563class ReceiptMessage < Blather::Stanza
564 def self.new(to = nil)
565 node = super :message
566 node.to = to
567 node
568 end
569end
570
571class WebhookHandler < Goliath::API
572 def response(env)
573 puts 'ENV: ' + env.to_s
574 body = Rack::Request.new(env).body.read
575 puts 'BODY: ' + body
576 params = JSON.parse body
577
578 users_num = ''
579 others_num = ''
580 if params['direction'] == 'in'
581 users_num = params['to']
582 others_num = params['from']
583 elsif params['direction'] == 'out'
584 users_num = params['from']
585 others_num = params['to']
586 else
587 # TODO: exception or similar
588 puts "big problem: '" + params['direction'] + "'"
589 return [200, {}, "OK"]
590 end
591
592 num_key = "catapult_num-" + users_num
593
594 # TODO: validate that others_num starts with '+' or is shortcode
595
596 conn = Hiredis::Connection.new
597 conn.connect(ARGV[4], ARGV[5].to_i)
598
599 conn.write ["EXISTS", num_key]
600 if conn.read == 0
601 conn.disconnect
602
603 puts "num_key (#{num_key}) DNE; Catapult misconfigured?"
604
605 # TODO: likely not appropriate; give error to Catapult?
606 # TODO: add text re credentials not being registered
607 #write_to_stream error_msg(m.reply, m.body, :auth,
608 # 'registration-required')
609 return [200, {}, "OK"]
610 end
611
612 conn.write ["LRANGE", num_key, 0, 0]
613 bare_jid = conn.read[0]
614 conn.disconnect
615
616 msg = ''
617 case params['direction']
618 when 'in'
619 text = ''
620 case params['eventType']
621 when 'sms'
622 text = params['text']
623 when 'mms'
624 text = "MMS (pic not implemented) with text: " +
625 params['text']
626 else
627 text = "unknown type (#{params['eventType']})" +
628 " with text: " + params['text']
629
630 # TODO log/notify of this properly
631 puts text
632 end
633
634 msg = Blather::Stanza::Message.new(bare_jid, text)
635 else # per prior switch, this is: params['direction'] == 'out'
636 msg = ReceiptMessage.new(bare_jid)
637
638 # TODO: put in member/instance variable
639 uuid_gen = UUID.new
640 msg['id'] = uuid_gen.generate
641
642 case params['deliveryState']
643 when 'not-delivered'
644 # TODO: add text re deliveryDescription reason
645 msg = SGXcatapult.error_msg(msg, nil, :cancel,
646 'service-unavailable')
647 return [200, {}, "OK"]
648 when 'delivered'
649 # TODO: send only when requested per XEP-0184
650 rcvd = Nokogiri::XML::Node.new 'received',
651 msg.document
652 rcvd['xmlns'] = 'urn:xmpp:receipts'
653 rcvd['id'] = params['tag']
654 msg.add_child(rcvd)
655 when 'waiting'
656 # can't really do anything with it; nice to know
657 puts "message with id #{params['id']} waiting"
658 return [200, {}, "OK"]
659 else
660 # TODO: notify somehow of unknown state receivd?
661 puts "message with id #{params['id']} has " +
662 "other state #{params['deliveryState']}"
663 return [200, {}, "OK"]
664 end
665
666 puts "RESPONSE4: #{msg.inspect}"
667 end
668
669 msg.from = others_num + '@' + ARGV[0]
670 SGXcatapult.write(msg)
671
672 [200, {}, "OK"]
673 end
674end
675
676EM.run do
677 SGXcatapult.run
678
679 server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
680 server.api = WebhookHandler.new
681 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
682 server.logger = Log4r::Logger.new('goliath')
683 server.logger.add(Log4r::StdoutOutputter.new('console'))
684 server.logger.level = Log4r::INFO
685 server.start
686end