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