sgx-catapult.rb

  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