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