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
129 # TODO: don't echo message; leave in until we rcv msgs properly
130 begin
131 puts "#{m.from.to_s} -> #{m.to.to_s} #{m.body}"
132 msg = Blather::Stanza::Message.new(m.from, 'thx for "' +
133 m.body + '"')
134 msg.from = m.to
135 write_to_stream msg
136 rescue => e
137 # TODO: do something better with this info
138 say m.from, e.inspect
139 end
140 end
141
142 def self.user_cap_identities()
143 [{:category => 'client', :type => 'sms'}]
144 end
145
146 def self.user_cap_features()
147 # TODO: add more features
148 ["urn:xmpp:receipts"]
149 end
150
151 presence :subscribe? do |p|
152 puts "PRESENCE1: #{p.inspect}"
153
154 msg = Blather::Stanza::Presence.new
155 msg.to = p.from
156 msg.from = p.to
157 msg.type = :subscribed
158
159 puts "RESPONSE5: #{msg.inspect}"
160 write_to_stream msg
161 end
162
163 presence :probe? do |p|
164 puts 'PRESENCE2: ' + p.inspect
165
166 caps = Blather::Stanza::Capabilities.new
167 # TODO: user a better node URI (?)
168 caps.node = 'http://catapult.sgx.soprani.ca/'
169 caps.identities = user_cap_identities()
170 caps.features = user_cap_features()
171
172 msg = caps.c
173 msg.to = p.from
174 msg.from = p.to.to_s + '/sgx'
175
176 puts 'RESPONSE6: ' + msg.inspect
177 write_to_stream msg
178 end
179
180 iq '/iq/ns:query', :ns =>
181 'http://jabber.org/protocol/disco#items' do |i, xpath_result|
182
183 write_to_stream i.reply
184 end
185
186 iq '/iq/ns:query', :ns =>
187 'http://jabber.org/protocol/disco#info' do |i, xpath_result|
188
189 if i.to.to_s.include? '@'
190 # TODO: confirm the node URL is expected using below
191 #puts "XR[node]: #{xpath_result[0]['node']}"
192
193 msg = i.reply
194 msg.identities = user_cap_identities()
195 msg.features = user_cap_features()
196
197 puts 'RESPONSE7: ' + msg.inspect
198 write_to_stream msg
199 next
200 end
201
202 msg = i.reply
203 msg.identities = [{:name =>
204 'Soprani.ca Gateway to XMPP - Catapult',
205 :type => 'sms-ctplt', :category => 'gateway'}]
206 msg.features = ["jabber:iq:register",
207 "jabber:iq:gateway", "jabber:iq:private",
208 "http://jabber.org/protocol/disco#info",
209 "http://jabber.org/protocol/commands",
210 "http://jabber.org/protocol/muc"]
211 write_to_stream msg
212 end
213
214 iq '/iq/ns:query', :ns => 'jabber:iq:register' do |i, qn|
215 puts "IQ: #{i.inspect}"
216
217 if i.type == :set
218 xn = qn.children.find { |v| v.element_name == "x" }
219
220 user_id = ''
221 api_token = ''
222 api_secret = ''
223 phone_num = ''
224
225 if xn.nil?
226 user_id = qn.children.find {
227 |v| v.element_name == "nick" }
228 api_token = qn.children.find {
229 |v| v.element_name == "username" }
230 api_secret = qn.children.find {
231 |v| v.element_name == "password" }
232 phone_num = qn.children.find {
233 |v| v.element_name == "phone" }
234 else
235 for field in xn.children
236 if field.element_name == "field"
237 val = field.children.find { |v|
238 v.element_name == "value" }
239
240 case field['var']
241 when 'nick'
242 user_id = val.text
243 when 'username'
244 api_token = val.text
245 when 'password'
246 api_secret = val.text
247 when 'phone'
248 phone_num = val.text
249 else
250 # TODO: error
251 puts "?: " +field['var']
252 end
253 end
254 end
255 end
256
257 if phone_num[0] != '+'
258 # TODO: add text re number not (yet) supported
259 write_to_stream error_msg(i.reply, qn, :modify,
260 'policy-violation')
261 next
262 end
263
264 uri = URI.parse('https://api.catapult.inetwork.com')
265 http = Net::HTTP.new(uri.host, uri.port)
266 http.use_ssl = true
267 request = Net::HTTP::Get.new('/v1/users/' + user_id +
268 '/phoneNumbers/' + phone_num)
269 request.basic_auth api_token, api_secret
270 response = http.request(request)
271
272 puts 'API response: ' + response.to_s + ' with code ' +
273 response.code + ', body "' + response.body + '"'
274
275 if response.code == '200'
276 params = JSON.parse response.body
277 if params['numberState'] == 'enabled'
278 num_key = "catapult_num-" + phone_num
279
280 bare_jid = i.from.to_s.split('/', 2)[0]
281 cred_key = "catapult_cred-" + bare_jid
282
283 # TODO: pre-validate ARGV[5] is integer
284 conn = Hiredis::Connection.new
285 conn.connect(ARGV[4], ARGV[5].to_i)
286
287 conn.write ["EXISTS", num_key]
288 if conn.read == 1
289 conn.disconnect
290
291 # TODO: add txt re num exists
292 write_to_stream error_msg(
293 i.reply, qn, :cancel,
294 'conflict')
295 next
296 end
297
298 conn.write ["EXISTS", cred_key]
299 if conn.read == 1
300 conn.disconnect
301
302 # TODO: add txt re already exist
303 write_to_stream error_msg(
304 i.reply, qn, :cancel,
305 'conflict')
306 next
307 end
308
309 conn.write ["RPUSH",num_key,bare_jid]
310 if conn.read != 1
311 conn.disconnect
312
313 # TODO: catch/relay RuntimeError
314 # TODO: add txt re push failure
315 write_to_stream error_msg(
316 i.reply, qn, :cancel,
317 'internal-server-error')
318 next
319 end
320
321 conn.write ["RPUSH",cred_key,user_id]
322 conn.write ["RPUSH",cred_key,api_token]
323 conn.write ["RPUSH",cred_key,api_secret]
324 conn.write ["RPUSH",cred_key,phone_num]
325
326 for n in 1..4 do
327 # TODO: catch/relay RuntimeError
328 result = conn.read
329 if result != n
330 conn.disconnect
331
332 write_to_stream(
333 error_msg(
334 i.reply, qn, :cancel,
335 'internal-server-error')
336 )
337 next
338 end
339 end
340 conn.disconnect
341
342 write_to_stream i.reply
343 else
344 # TODO: add text re number disabled
345 write_to_stream error_msg(i.reply, qn,
346 :modify, 'not-acceptable')
347 end
348 elsif response.code == '401'
349 # TODO: add text re bad credentials
350 write_to_stream error_msg(i.reply, qn, :auth,
351 'not-authorized')
352 elsif response.code == '404'
353 # TODO: add text re number not found or disabled
354 write_to_stream error_msg(i.reply, qn, :cancel,
355 'item-not-found')
356 else
357 # TODO: add text re misc error, and mention code
358 write_to_stream error_msg(i.reply, qn, :modify,
359 'not-acceptable')
360 end
361
362 elsif i.type == :get
363 orig = i.reply
364
365 msg = Nokogiri::XML::Node.new 'query',orig.document
366 msg['xmlns'] = 'jabber:iq:register'
367 n1 = Nokogiri::XML::Node.new 'instructions',msg.document
368 n1.content= "Enter the information from your Account " +
369 "page as well as the Phone Number\nin your " +
370 "account you want to use (ie. '+12345678901')" +
371 ".\nUser Id is nick, API Token is username, " +
372 "API Secret is password, Phone Number is phone"+
373 ".\n\nThe source code for this gateway is at " +
374 "https://github.com/ossguy/sgx-catapult ." +
375 "\nCopyright (C) 2017 Denver Gingerich, " +
376 "licensed under AGPLv3+."
377 n2 = Nokogiri::XML::Node.new 'nick',msg.document
378 n3 = Nokogiri::XML::Node.new 'username',msg.document
379 n4 = Nokogiri::XML::Node.new 'password',msg.document
380 n5 = Nokogiri::XML::Node.new 'phone',msg.document
381 msg.add_child(n1)
382 msg.add_child(n2)
383 msg.add_child(n3)
384 msg.add_child(n4)
385 msg.add_child(n5)
386
387 x = Blather::Stanza::X.new :form, [
388 {:required => true, :type => :"text-single",
389 :label => 'User Id', :var => 'nick'},
390 {:required => true, :type => :"text-single",
391 :label => 'API Token', :var => 'username'},
392 {:required => true, :type => :"text-private",
393 :label => 'API Secret', :var => 'password'},
394 {:required => true, :type => :"text-single",
395 :label => 'Phone Number', :var => 'phone'}
396 ]
397 x.title= 'Register for ' +
398 'Soprani.ca Gateway to XMPP - Catapult'
399 x.instructions= "Enter the details from your Account " +
400 "page as well as the Phone Number\nin your " +
401 "account you want to use (ie. '+12345678901')" +
402 ".\n\nThe source code for this gateway is at " +
403 "https://github.com/ossguy/sgx-catapult ." +
404 "\nCopyright (C) 2017 Denver Gingerich, " +
405 "licensed under AGPLv3+."
406 msg.add_child(x)
407
408 orig.add_child(msg)
409 puts "RESPONSE2: #{orig.inspect}"
410 write_to_stream orig
411 puts "SENT"
412 end
413 end
414
415 subscription(:request?) do |s|
416 # TODO: are these the best to return? really need '!' here?
417 #write_to_stream s.approve!
418 #write_to_stream s.request!
419 end
420end
421
422[:INT, :TERM].each do |sig|
423 trap(sig) {
424 puts 'Shutting down gateway...'
425 SGXcatapult.shutdown
426 puts 'Gateway has terminated.'
427
428 EM.stop
429 }
430end
431
432class ReceiptMessage < Blather::Stanza
433 def self.new(to = nil)
434 node = super :message
435 node.to = to
436 node
437 end
438end
439
440class WebhookHandler < Goliath::API
441 def response(env)
442 puts 'ENV: ' + env.to_s
443 body = Rack::Request.new(env).body.read
444 puts 'BODY: ' + body
445 params = JSON.parse body
446
447 users_num = ''
448 others_num = ''
449 if params['direction'] == 'in'
450 users_num = params['to']
451 others_num = params['from']
452 elsif params['direction'] == 'out'
453 users_num = params['from']
454 others_num = params['to']
455 else
456 # TODO: exception or similar
457 puts "big problem: '" + params['direction'] + "'"
458 return [200, {}, "OK"]
459 end
460
461 num_key = "catapult_num-" + users_num
462
463 # TODO: validate that others_num starts with '+' or is shortcode
464
465 conn = Hiredis::Connection.new
466 conn.connect(ARGV[4], ARGV[5].to_i)
467
468 conn.write ["EXISTS", num_key]
469 if conn.read == 0
470 conn.disconnect
471
472 puts "num_key (#{num_key}) DNE; Catapult misconfigured?"
473
474 # TODO: likely not appropriate; give error to Catapult?
475 # TODO: add text re credentials not being registered
476 #write_to_stream error_msg(m.reply, m.body, :auth,
477 # 'registration-required')
478 return [200, {}, "OK"]
479 end
480
481 conn.write ["LRANGE", num_key, 0, 0]
482 bare_jid = conn.read[0]
483 conn.disconnect
484
485 msg = ''
486 case params['direction']
487 when 'in'
488 text = ''
489 case params['eventType']
490 when 'sms'
491 text = params['text']
492 when 'mms'
493 text = "MMS (pic not implemented) with text: " +
494 params['text']
495 else
496 text = "unknown type (#{params['eventType']})" +
497 " with text: " + params['text']
498
499 # TODO log/notify of this properly
500 puts text
501 end
502
503 msg = Blather::Stanza::Message.new(bare_jid, text)
504 else # per prior switch, this is: params['direction'] == 'out'
505 msg = ReceiptMessage.new(bare_jid)
506 msg['id'] = params['tag']
507
508 case params['deliveryState']
509 when 'not-delivered'
510 # TODO: add text re deliveryDescription reason
511 msg = SGXcatapult.error_msg(msg, nil, :cancel,
512 'service-unavailable')
513 return [200, {}, "OK"]
514 when 'delivered'
515 # TODO: send only when requested per XEP-0184
516 rcvd = Nokogiri::XML::Node.new 'received',
517 msg.document
518 rcvd['xmlns'] = 'urn:xmpp:receipts'
519 rcvd['id'] = params['tag']
520 msg.add_child(rcvd)
521 when 'waiting'
522 # can't really do anything with it; nice to know
523 puts "message with id #{params['id']} waiting"
524 return [200, {}, "OK"]
525 else
526 # TODO: notify somehow of unknown state receivd?
527 puts "message with id #{params['id']} has " +
528 "other state #{params['deliveryState']}"
529 return [200, {}, "OK"]
530 end
531
532 puts "RESPONSE4: #{msg.inspect}"
533 end
534
535 msg.from = others_num + '@' + ARGV[0]
536 SGXcatapult.write(msg)
537
538 [200, {}, "OK"]
539 end
540end
541
542EM.run do
543 SGXcatapult.run
544
545 server = Goliath::Server.new('0.0.0.0', ARGV[7].to_i)
546 server.api = WebhookHandler.new
547 server.app = Goliath::Rack::Builder.build(server.api.class, server.api)
548 server.logger = Log4r::Logger.new('goliath')
549 server.logger.add(Log4r::StdoutOutputter.new('console'))
550 server.logger.level = Log4r::INFO
551 server.start
552end