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
 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