em_apn_manager 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +108 -0
- data/Procfile +1 -0
- data/README.md +26 -0
- data/Rakefile +60 -0
- data/VERSION +1 -0
- data/bin/em_apn_manager +4 -0
- data/em_apn_manager.gemspec +197 -0
- data/lib/em_apn_manager.rb +22 -0
- data/lib/em_apn_manager/apn_server.rb +59 -0
- data/lib/em_apn_manager/cli.rb +69 -0
- data/lib/em_apn_manager/client.rb +75 -0
- data/lib/em_apn_manager/connection.rb +51 -0
- data/lib/em_apn_manager/engine.rb +9 -0
- data/lib/em_apn_manager/error_response.rb +36 -0
- data/lib/em_apn_manager/generators/install.rb +14 -0
- data/lib/em_apn_manager/generators/templates/em_apn_manager.yml +13 -0
- data/lib/em_apn_manager/logger.rb +15 -0
- data/lib/em_apn_manager/manager.rb +71 -0
- data/lib/em_apn_manager/notification.rb +58 -0
- metadata +725 -0
@@ -0,0 +1,59 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
# Mock Apple push server for testing.
|
4
|
+
module EventMachine
|
5
|
+
module ApnManager
|
6
|
+
module ApnServer
|
7
|
+
def post_init
|
8
|
+
EM::ApnManager.logger.info("Received a new connection")
|
9
|
+
@data = ""
|
10
|
+
|
11
|
+
start_tls(
|
12
|
+
:cert_chain_file => ENV["APN_CERT"],
|
13
|
+
:private_key_file => ENV["APN_CERT"],
|
14
|
+
:verify_peer => false
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
def ssl_handshake_completed
|
19
|
+
EM::ApnManager.logger.info("SSL handshake completed")
|
20
|
+
end
|
21
|
+
|
22
|
+
def receive_data(data)
|
23
|
+
@data << data
|
24
|
+
|
25
|
+
# Try to extract the payload header
|
26
|
+
headers = @data.unpack("cNNnH64n")
|
27
|
+
return if headers.last.nil?
|
28
|
+
|
29
|
+
# Try to grab the payload
|
30
|
+
payload_size = headers.last
|
31
|
+
payload = @data[45, payload_size]
|
32
|
+
return if payload.length != payload_size
|
33
|
+
|
34
|
+
@data = @data[45 + payload_size, -1] || ""
|
35
|
+
|
36
|
+
process(headers, payload)
|
37
|
+
end
|
38
|
+
|
39
|
+
def process(headers, payload)
|
40
|
+
message = "APN RECV #{headers[4]} #{payload}"
|
41
|
+
EM::ApnManager.logger.info(message)
|
42
|
+
|
43
|
+
args = Yajl::Parser.parse(payload)
|
44
|
+
|
45
|
+
# If the alert is 'DISCONNECT', then we fake a bad payload by replying
|
46
|
+
# with an error and disconnecting.
|
47
|
+
if args["aps"]["alert"] == "DISCONNECT"
|
48
|
+
EM::ApnManager.logger.info("Disconnecting")
|
49
|
+
send_data([8, 1, 0].pack("ccN"))
|
50
|
+
close_connection_after_writing
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def unbind
|
55
|
+
EM::ApnManager.logger.info("Connection closed")
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'thor'
|
4
|
+
require 'redis'
|
5
|
+
require 'yaml'
|
6
|
+
require 'yajl'
|
7
|
+
require 'yajl/json_gem'
|
8
|
+
require 'em_apn_manager'
|
9
|
+
require "em_apn_manager/manager"
|
10
|
+
require "em_apn_manager/apn_server"
|
11
|
+
|
12
|
+
ENV["APN_CERT"] ||= File.join(File.dirname(__FILE__), "..", "..", "certs", "cert.pem")
|
13
|
+
|
14
|
+
$apn_manager_redis = nil
|
15
|
+
|
16
|
+
module EventMachine
|
17
|
+
module ApnManager
|
18
|
+
class CLI < Thor
|
19
|
+
class_option :config, :aliases => ["-c"], :type => :string
|
20
|
+
class_option :environment, :aliases => ["-e"], :type => :string
|
21
|
+
|
22
|
+
def initialize(args = [], opts = [], config = {})
|
23
|
+
super(args, opts, config)
|
24
|
+
|
25
|
+
# Read config option, or use default config yml
|
26
|
+
config_path = options[:config] || File.join(".", "config", "em_apn_manager.yml")
|
27
|
+
if config_path && File.exists?(config_path)
|
28
|
+
EM::ApnManager.config = Thor::CoreExt::HashWithIndifferentAccess.new(YAML.load_file(config_path))
|
29
|
+
else
|
30
|
+
raise "No config file is specified or specified config file doesn't exist."
|
31
|
+
end
|
32
|
+
|
33
|
+
# read the environment var.
|
34
|
+
@environment = "test"
|
35
|
+
@environment = options[:environment] if %w{test development production}.include? options[:environment]
|
36
|
+
|
37
|
+
# create redis connection
|
38
|
+
$apn_manager_redis = Redis.new EM::ApnManager.config[@environment] || { host:"127.0.0.1", port:6379 }
|
39
|
+
end
|
40
|
+
|
41
|
+
desc "server", "Start manager server."
|
42
|
+
# option :daemon, :aliases => ["-d"], :type => :boolean
|
43
|
+
# option :pid_file, :aliases => ["-p"], :type => :string
|
44
|
+
def server
|
45
|
+
EM::ApnManager.logger.info("Starting APN Manager")
|
46
|
+
EM.run { EM::ApnManager::Manager.run env: @environment }
|
47
|
+
end
|
48
|
+
|
49
|
+
### For Testing ##################################################
|
50
|
+
desc "push_test_message", "Push test messages to server."
|
51
|
+
# if message is DISCONNECT, mock apns will disconnect.
|
52
|
+
def push_test_message
|
53
|
+
10.times do |i|
|
54
|
+
EM::ApnManager.push_notification({
|
55
|
+
cert: File.read(ENV["APN_CERT"]),
|
56
|
+
token: ["0F93C49EAAF3544B5218D2BAE893608C515F69B445279AB2B17511C37046C52B","D42A6795D0C6C0E5F3CC762F905C3654D2A07E72D64CDEC1E2F74AC43C4CC440"].sample,
|
57
|
+
message: "Hahahaha I am going to spam you. #{i}-#{rand * 100}"
|
58
|
+
})
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
desc "mock_apn_server", "Start a mock apple APNS Server."
|
63
|
+
def mock_apn_server
|
64
|
+
EM::ApnManager.logger.info("Starting Mock APN Server")
|
65
|
+
EM.run { EM.start_server("127.0.0.1", 2195, EM::ApnManager::ApnServer) }
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require "em_apn_manager/connection"
|
4
|
+
|
5
|
+
module EventMachine
|
6
|
+
module ApnManager
|
7
|
+
class Client
|
8
|
+
SANDBOX_GATEWAY = "gateway.sandbox.push.apple.com"
|
9
|
+
PRODUCTION_GATEWAY = "gateway.push.apple.com"
|
10
|
+
TEST_GATEWAY = "127.0.0.1"
|
11
|
+
PORT = 2195
|
12
|
+
|
13
|
+
attr_reader :gateway, :port, :cert, :connection, :error_callback, :close_callback, :open_callback
|
14
|
+
|
15
|
+
# A convenience method for creating and connecting.
|
16
|
+
def self.connect(options = {})
|
17
|
+
new(options).tap do |client|
|
18
|
+
client.connect
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(options = {})
|
23
|
+
@cert = options[:cert] || ENV["APN_CERT"]
|
24
|
+
@port = options[:port] || PORT
|
25
|
+
@environment = options[:env]
|
26
|
+
@gateway = options[:gateway] || ENV["APN_GATEWAY"]
|
27
|
+
@gateway ||= case @environment
|
28
|
+
when "test"
|
29
|
+
TEST_GATEWAY
|
30
|
+
when "development"
|
31
|
+
SANDBOX_GATEWAY
|
32
|
+
when "production"
|
33
|
+
PRODUCTION_GATEWAY
|
34
|
+
else
|
35
|
+
TEST_GATEWAY
|
36
|
+
end
|
37
|
+
@connection = nil
|
38
|
+
end
|
39
|
+
|
40
|
+
def connect
|
41
|
+
@connection = EM.connect(gateway, port, Connection, self)
|
42
|
+
end
|
43
|
+
|
44
|
+
def connected?
|
45
|
+
!connection.nil? && !connection.disconnected?
|
46
|
+
end
|
47
|
+
|
48
|
+
def deliver(notification)
|
49
|
+
if !connected?
|
50
|
+
puts "No connection."
|
51
|
+
return
|
52
|
+
end
|
53
|
+
notification.validate!
|
54
|
+
log(notification)
|
55
|
+
connection.send_data(notification.data)
|
56
|
+
end
|
57
|
+
|
58
|
+
def on_error(&block)
|
59
|
+
@error_callback = block
|
60
|
+
end
|
61
|
+
|
62
|
+
def on_close(&block)
|
63
|
+
@close_callback = block
|
64
|
+
end
|
65
|
+
|
66
|
+
def on_open(&block)
|
67
|
+
@open_callback = block
|
68
|
+
end
|
69
|
+
|
70
|
+
def log(notification)
|
71
|
+
EM::ApnManager.logger.info("TOKEN=#{notification.token} PAYLOAD=#{notification.payload.inspect}")
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require "em_apn_manager/error_response"
|
4
|
+
|
5
|
+
module EventMachine
|
6
|
+
module ApnManager
|
7
|
+
class Connection < EM::Connection
|
8
|
+
attr_reader :client
|
9
|
+
|
10
|
+
def initialize(*args)
|
11
|
+
super
|
12
|
+
@client = args.last
|
13
|
+
@disconnected = false
|
14
|
+
end
|
15
|
+
|
16
|
+
def disconnected?
|
17
|
+
@disconnected
|
18
|
+
end
|
19
|
+
|
20
|
+
def post_init
|
21
|
+
EM::ApnManager.logger.info("Connecting...")
|
22
|
+
|
23
|
+
start_tls(
|
24
|
+
:private_key_file => client.cert,
|
25
|
+
:cert_chain_file => client.cert,
|
26
|
+
:verify_peer => false
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
def connection_completed
|
31
|
+
EM::ApnManager.logger.info("Connection completed")
|
32
|
+
|
33
|
+
client.open_callback.call if client.open_callback
|
34
|
+
end
|
35
|
+
|
36
|
+
def receive_data(data)
|
37
|
+
data_array = data.unpack("ccN")
|
38
|
+
error_response = ErrorResponse.new(*data_array)
|
39
|
+
EM::ApnManager.logger.warn(error_response.to_s)
|
40
|
+
client.error_callback.call(error_response) if client.error_callback
|
41
|
+
end
|
42
|
+
|
43
|
+
def unbind
|
44
|
+
EM::ApnManager.logger.info("Connection closed")
|
45
|
+
|
46
|
+
@disconnected = true
|
47
|
+
client.close_callback.call if client.close_callback
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module EventMachine
|
4
|
+
module ApnManager
|
5
|
+
class ErrorResponse
|
6
|
+
DESCRIPTION = {
|
7
|
+
0 => "No errors encountered",
|
8
|
+
1 => "Processing error",
|
9
|
+
2 => "Missing device token",
|
10
|
+
3 => "Missing topic",
|
11
|
+
4 => "Missing payload",
|
12
|
+
5 => "Invalid token size",
|
13
|
+
6 => "Invalid topic size",
|
14
|
+
7 => "Invalid payload size",
|
15
|
+
8 => "Invalid token",
|
16
|
+
255 => "None (unknown)"
|
17
|
+
}
|
18
|
+
|
19
|
+
attr_reader :command, :status_code, :identifier
|
20
|
+
|
21
|
+
def initialize(command, status_code, identifier)
|
22
|
+
@command = command
|
23
|
+
@status_code = status_code
|
24
|
+
@identifier = identifier
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_s
|
28
|
+
"CODE=#{@status_code} ID=#{@identifier} DESC=#{description}"
|
29
|
+
end
|
30
|
+
|
31
|
+
def description
|
32
|
+
DESCRIPTION[@status_code] || "Missing description"
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module EventMachine
|
4
|
+
module ApnManager
|
5
|
+
class Install < Rails::Generators::Base
|
6
|
+
desc "Install EventMachine APN Manager."
|
7
|
+
source_root File.expand_path('../templates', __FILE__)
|
8
|
+
def create_config
|
9
|
+
say "Create Configure file to project...", :yellow
|
10
|
+
copy_file "em_apn_manager.yml", "config/em_apn_manager.yml"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'yajl'
|
5
|
+
require 'yajl/json_gem'
|
6
|
+
require 'em-hiredis'
|
7
|
+
require "em_apn_manager/client"
|
8
|
+
require "em_apn_manager/notification"
|
9
|
+
|
10
|
+
module EventMachine
|
11
|
+
module ApnManager
|
12
|
+
class Manager
|
13
|
+
$connection_pool = {}
|
14
|
+
|
15
|
+
def self.run options = {}
|
16
|
+
self.new.tap do |manager|
|
17
|
+
manager.run options
|
18
|
+
end
|
19
|
+
end # end of run
|
20
|
+
|
21
|
+
# options = {gateway, port}
|
22
|
+
def run options = {}
|
23
|
+
@redis = EM::Hiredis.connect
|
24
|
+
|
25
|
+
### launch a new connect to apple when detected any pushs.
|
26
|
+
@redis.pubsub.subscribe('push-notification') do |msg|
|
27
|
+
msg_hash = Yajl::Parser.parse(msg) # might be some wrong json
|
28
|
+
# save the cert to local first, since the start_tls read from file.
|
29
|
+
cert_filename = save_cert_to_file msg_hash["cert"]
|
30
|
+
# cert filename is a key for connection pool
|
31
|
+
client = $connection_pool[cert_filename]
|
32
|
+
|
33
|
+
### Create client connection if doesn't exist in pool.
|
34
|
+
if client.nil?
|
35
|
+
client = EM::ApnManager::Client.new(options.merge!({cert: cert_filename}))
|
36
|
+
# Store the connection to pool
|
37
|
+
$connection_pool[cert_filename] = client
|
38
|
+
end
|
39
|
+
|
40
|
+
### send message directly if connection was connected.
|
41
|
+
### TODO you can bind on_close, on_open, on_error events.
|
42
|
+
notification = EM::ApnManager::Notification.new(msg_hash["token"], :alert => msg_hash["message"])
|
43
|
+
if client.connected?
|
44
|
+
client.deliver(notification)
|
45
|
+
else
|
46
|
+
# if connection not connected, set callback block to deliver notification, and connect to apple server.
|
47
|
+
client.on_open do
|
48
|
+
client.deliver(notification)
|
49
|
+
end
|
50
|
+
client.connect
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def save_cert_to_file cert_content
|
58
|
+
# TODO, should store Rails.root/tmp/certs and this folder should be protected.
|
59
|
+
FileUtils.mkdir_p "certs"
|
60
|
+
filename = Base64.encode64(cert_content)[0..50]
|
61
|
+
filename = File.join "certs", filename
|
62
|
+
return filename if File.exist?(filename)
|
63
|
+
|
64
|
+
File.open filename, "w+" do |f|
|
65
|
+
f.write cert_content
|
66
|
+
end
|
67
|
+
filename
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
module EventMachine
|
4
|
+
module ApnManager
|
5
|
+
class Notification
|
6
|
+
DATA_MAX_BYTES = 256
|
7
|
+
|
8
|
+
class PayloadTooLarge < StandardError;end
|
9
|
+
|
10
|
+
attr_reader :token
|
11
|
+
attr_accessor :identifier, :expiry
|
12
|
+
|
13
|
+
def initialize(token, aps = {}, custom = {}, options = {})
|
14
|
+
raise "Bad push token: #{token}" if token.nil? || (token.length != 64)
|
15
|
+
|
16
|
+
@token = token
|
17
|
+
@aps = aps
|
18
|
+
@custom = custom
|
19
|
+
|
20
|
+
self.identifier = options[:identifier] if options[:identifier]
|
21
|
+
self.expiry = options[:expiry] if options[:expiry]
|
22
|
+
end
|
23
|
+
|
24
|
+
def payload
|
25
|
+
Yajl::Encoder.encode(@custom.merge(:aps => @aps))
|
26
|
+
end
|
27
|
+
|
28
|
+
# Documentation about this format is here:
|
29
|
+
# http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html
|
30
|
+
def data
|
31
|
+
identifier = @identifier || 0
|
32
|
+
expiry = @expiry || 0
|
33
|
+
size = [payload].pack("a*").size
|
34
|
+
data_array = [1, identifier, expiry, 32, token, size, payload]
|
35
|
+
data_array.pack("cNNnH*na*")
|
36
|
+
end
|
37
|
+
|
38
|
+
def validate!
|
39
|
+
if data.size > DATA_MAX_BYTES
|
40
|
+
error = "max is #{DATA_MAX_BYTES} bytes, but got #{data.size}: #{payload.inspect}"
|
41
|
+
raise PayloadTooLarge.new(error)
|
42
|
+
else
|
43
|
+
true
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def identifier=(new_identifier)
|
48
|
+
@identifier = new_identifier.to_i
|
49
|
+
end
|
50
|
+
|
51
|
+
def truncate_alert!
|
52
|
+
while data.size > DATA_MAX_BYTES && !@aps["alert"].nil? && @aps["alert"].size > 0
|
53
|
+
@aps["alert"] = @aps["alert"][0..-2]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|