racoon 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,25 +6,28 @@ has since taken on a different path. How does it differ from apnserver? By a few
6
6
  1. It implements the APNS feedback service;
7
7
  2. Uses Yajl for JSON encoding/decoding rather than ActiveSupport;
8
8
  3. Expects certificates as strings instead of paths to files;
9
- 4. Does not assume there is only one certificate; and
10
- 5. Receives packets containing notifications from beanstalkd instead of a listening socket.
9
+ 4. Does not assume there is only one certificate (read: supports multiple projects); and
10
+ 5. Receives packets containing notifications from beanstalkd instead of a listening socket;
11
+ 6. Operates on a distributed architecture (many parallel workers, one firehose.
11
12
 
12
- The above changes were made because of the need for an APNS provider to replace the current
13
- provider used by [Diligent Street](http://www.diligentstreet.com/) with something more robust. As such, it needs to be
14
- suitable for a hosted environment, where multiple—unrelated—users of the service will be
15
- using it.
13
+ The above changes were made because of the need to replace an existing APNs provider with something
14
+ more robust, and better suited to scaling upwards. This APNs provider had a couple requirements:
15
+
16
+ 1. Support fully the APNs protocol (including feedback)
17
+ 2. Scale outwards horizontally
18
+ 3. Support multiple projects
16
19
 
17
20
  It should be noted that the development of this project is independent of the work bpoweski
18
21
  is doing on apnserver. If you're looking for that project, [go here](https://github.com/bpoweski/apnserver).
19
22
 
20
23
  ## Description
21
24
 
22
- racoon is a server and a set of command line programs to send push notifications to iOS devices.
23
- Apple recommends to maintain an open connection to the push notification service, and refrain
24
- from opening up and tearing down SSL connections repeatedly. As such, a separate daemon is
25
- introduced that has messages queued up (beanstalkd) for consumption by this daemon. This
26
- decouples the APNS server from your backend system. Those notifications are sent over a
27
- persistent connection to Apple.
25
+ Racoon consists of a firehose, which maintains the connections to Apple's APNs service. It also
26
+ consists of a worker, which works on a beanstalk tube to pop notifications off and process them,
27
+ before sending them off to the firehose. You can run many workers, they all run in parallel to
28
+ one another. Additionally, it includes a command line tool to send (test) the system.
29
+
30
+ At this time, Racoon only supports Apple's APNs service.
28
31
 
29
32
  ## Remaining Tasks & Issues
30
33
 
@@ -46,99 +49,109 @@ using frac.as, this is the file you would upload to the web service.
46
49
  If you're not using frac.as, then the contents of this file are what you need to use as
47
50
  your certificate, not the path to the file.
48
51
 
49
- ## APN Server Daemon
50
-
51
- <pre>
52
- Usage: racoond [options]
53
- --beanstalk <csv ip:port mappings>
54
- The comma-separated list of ip:port for beanstalk servers
55
-
56
- --pid <pid file path>
57
- Path used for the PID file. Defaults to /var/run/racoon.pid
52
+ ## Firehose
58
53
 
59
- --log <log file path>
60
- Path used for the log file. Defaults to /var/log/racoon.log
54
+ The firehose sits at the end of the pipeline. All the workers deliver messages to this
55
+ part of racoon. Think of it as the drain in your sink.
61
56
 
62
- --help
63
- usage message
64
-
65
- --daemon
66
- Runs process as daemon, not available on Windows
57
+ <pre>
58
+ Usage: racoon-firehose [switches]
59
+ --pid </var/run/racoon-firehose.pid> the path to store the pid
60
+ --log </var/log/racoon-firehosed.log> the path to store the log
61
+ --daemon to daemonize the server
62
+ --help this message
67
63
  </pre>
68
64
 
69
- ## APN Server Client
70
-
71
- TODO: Document this
65
+ ## Worker
72
66
 
73
- ## Sending Notifications from Ruby
67
+ The worker is the part of the system which interacts with a beanstalk cluster. Each worker
68
+ can talk to more than one beanstalk server, thus forming the "cluster". Since beanstalk
69
+ clustering is all done client side, it's not a traditional cluster, but I'm going to call
70
+ it that way anyway.
74
71
 
75
- You need to set up a connection to the beanstalkd service. We can do this simply by defining
76
- the following method:
72
+ The worker pops items that are ready off of the beanstalk cluster. It grabs those packets,
73
+ constructs a message out of it, and then forms another packet, suitable for sending to
74
+ Apple, before sending that packet to the firehose.
77
75
 
78
- ```ruby
79
- def beanstalk
80
- return @beanstalk if @beanstalk
81
- @beanstalk = Beanstalk::Pool.new ["127.0.0.1:11300"]
82
- @beanstalk.use "racoon-apns"
83
- @beanstalk
84
- end
85
- ```
76
+ You may run as many workers as your little heart desires. Keep in mind however, there will
77
+ come a point when having only one firehose is not suitable to handle the amount of traffic
78
+ you will be passing through from the workers. At this point, a second firehose will need
79
+ to be started. I doubt anyone will ever be processing enough messages a second to warrant
80
+ a second firehose.
86
81
 
87
- In this way, whenever we need access to beanstalk, we'll make the connection and set up to use
88
- the appropriate tube whether the connection is open yet or not.
89
-
90
- We will also need two pieces of information: A project, and a notification.
82
+ <pre>
83
+ Usage: racoon-worker [switches]
84
+ --beanstalk <127.0.0.1:11300> csv list of ip:port for beanstalk servers
85
+ --pid </var/run/racoon-worker.pid> the path to store the pid
86
+ --log </var/log/racoon-worker.log> the path to store the log
87
+ --daemon to daemonize the server
88
+ --help this message
89
+ </pre>
91
90
 
92
- **Note that the tube should always be "racoon-apns". The server won't look on any other tube for
93
- push notifications.**
91
+ It should be noted that the worker's `--beanstalk` parameter requires comma separated values
92
+ in `IP:port` pairings. Where `IP` is either an IP address, or a hostname to a host running a
93
+ beanstalkd server on the associated port `port`. In the future, I will create a config file
94
+ instead of having to specify this information on the command line each time. My apologies for
95
+ the inconvenience.
94
96
 
95
- ### Project
97
+ ## Sender
96
98
 
97
- A project os comprised of a few pieces of information at a minimum (you may supply more if you
98
- wish, but racoond will ignore them):
99
+ The sender is the program used to form a packet, place it on beanstalk for racoon to consume.
100
+ It is useful during testing.
99
101
 
100
- ```ruby
101
- project = { :name => "Awesome project", :certificate => "contents of the generated .pem file" }
102
- ```
102
+ <pre>
103
+ Usage: racoon-send [switches] (--b64-token | --hex-token) <token>
104
+ --beanstalk <127.0.0.1:11300> csv of ip:port for beanstalk servers
105
+ --pem <path> the path to the pem file, if a pem is supplied the server defaults to gateway.push.apple.com:2195
106
+ --alert <message> the message to send
107
+ --sound <default> the sound to play, defaults to 'default'
108
+ --badge <number> the badge number
109
+ --custom <json string> a custom json string to be added to the main object
110
+ --b64-token <token> a base 64 encoded device token
111
+ --hex-token <token> a hex encoded device token
112
+ --help this message
113
+ </pre>
103
114
 
104
- ### Notification
115
+ ## Preparing packets to place on beanstalk
105
116
 
106
- A notification is a ruby hash containing the things to be sent along, including the device token.
107
- An example notification may look like this:
117
+ Until an API can be built that you can tie into with your own applications, please take care
118
+ to construct your notifications as YAML payload with the following format. I will use ruby
119
+ syntax for this example:
108
120
 
109
121
  ```ruby
110
- notification = { :aps => { :alert => "Some text",
111
- :sound => "Audio_file",
112
- :badge => 42,
113
- :custom => { :field => "lala",
114
- :stuff => 42 }
115
- }
116
- }
122
+ {
123
+ :project => { :name => "My awesome app", :certificate => "...", :sandbox => true },
124
+ :identifier => 12345,
125
+ :notification => { :aps => { :alert => "text",
126
+ :sound => "default",
127
+ :badge => 1,
128
+ :custom => { ... }
129
+ }
130
+ },
131
+ :device_token => "..."
132
+ }
117
133
  ```
118
134
 
119
- Finally within we can send a push notification using the following code:
135
+ A few key points need to be raised here. For starters, the `sandbox` key should only be true if
136
+ you desire to work in the sandbox, and your `certificate` contains the text of your development
137
+ certificate.
120
138
 
121
- ```ruby
122
- beanstalk.yput({ :project => project,
123
- :notification => notification,
124
- :send_at => Time.mktime(2012, 12, 21, 4, 15)
125
- :device_token => "binary encoded token",
126
- :sandbox => true
127
- })
128
- ```
139
+ Secondly, the `identifier` must be a unique 32-bit number identifying your message should you
140
+ choose to want useful error messages. (This feature is not presently written, as such, you can
141
+ supply anything you want, just make sure it falls within the range of `0` to `4294967295`.
129
142
 
130
- This will construct a notification which is to be scheduled to be delivered on December 21st,
131
- 2012 at 4:15 AM localtime to the server. That is, if the server's timezone is UTC, you must
132
- account for that in your client application.
143
+ The `notification` key represents the payload we'll send to Apple. The `custom` key must be
144
+ present if you intend to send custom data. Note however, that `custom` will be removed, and the
145
+ items you place in its hash will be substituted in with the payload when the message is passed
146
+ to Apple. As such, if you want a custom key -> value pair of: `"foo" => "bar"`, you would ensure
147
+ you have: `:custom => { "foo" => "bar" }` in the notification. Your application should just look
148
+ for "foo" in the payload delivered to the app.
133
149
 
134
- Note that the `sandbox` parameter is used to indicate whether or not we're using a development
135
- certificate, and as such, should contact Apple's sandbox APN service instead of the production
136
- certificate. If left out, we assume production.
150
+ Finally, the ```device_token``` is a binary encoded representation of your devices token. Your
151
+ app gets it in hex, please ensure you convert it to binary before sending it to beanstalk.
137
152
 
138
- This will schedule the push on beanstalkd. Racoon is constantly polling beanstalkd looking for
139
- ready jobs it can pop off and process (send to Apple). Using beanstalkd however allows us to
140
- queue up items, and during peak times, add another **N** more racoon servers to make up any
141
- backlog, to ensure our messages are sent fast, and that we can scale.
153
+ TODO: Document new way of handling scheduling notifications in the future. Abstract beanstalk
154
+ away from users of the library, give them a proper client interface to use instead.
142
155
 
143
156
  ## Installation
144
157
 
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+
4
+ require 'getoptlong'
5
+ require 'rubygems'
6
+ require 'daemons'
7
+ require 'racoon'
8
+ require 'csv'
9
+
10
+ def usage
11
+ puts "Usage: racoon-firehose [switches]"
12
+ puts " --pid </var/run/racoon-firehose.pid> the path to store the pid"
13
+ puts " --log </var/log/racoon-firehosed.log> the path to store the log"
14
+ puts " --daemon to daemonize the server"
15
+ puts " --help this message"
16
+ end
17
+
18
+ def daemonize
19
+ Daemonize.daemonize(@log_file, 'racoon-firehose')
20
+ open(@pid_file,"w") { |f| f.write(Process.pid) }
21
+ open(@pid_file,"w") do |f|
22
+ f.write(Process.pid)
23
+ File.chmod(0644, @pid_file)
24
+ end
25
+ end
26
+
27
+ opts = GetoptLong.new(
28
+ ["--pid", "-i", GetoptLong::REQUIRED_ARGUMENT],
29
+ ["--log", "-l", GetoptLong::REQUIRED_ARGUMENT],
30
+ ["--help", "-h", GetoptLong::NO_ARGUMENT],
31
+ ["--daemon", "-d", GetoptLong::NO_ARGUMENT]
32
+ )
33
+
34
+ @pid_file = '/var/run/racoon-firehose.pid'
35
+ @log_file = '/var/log/racoon-firehose.log'
36
+ daemon = false
37
+
38
+ opts.each do |opt, arg|
39
+ case opt
40
+ when '--help'
41
+ usage
42
+ exit 1
43
+ when '--pid'
44
+ @pid_file = arg
45
+ when '--log'
46
+ @log_file = arg
47
+ when '--daemon'
48
+ daemon = true
49
+ end
50
+ end
51
+
52
+ Racoon::Config.logger = Logger.new(@log_file)
53
+
54
+ if daemon
55
+ daemonize
56
+ else
57
+ puts "Starting racoon worker."
58
+ end
59
+ Racoon::Firehose.new.start!
@@ -14,6 +14,7 @@ require 'beanstalk-client'
14
14
  def usage
15
15
  puts "Usage: racoon-send [switches] (--b64-token | --hex-token) <token>"
16
16
  puts " --beanstalk <127.0.0.1:11300> csv of ip:port for beanstalk servers"
17
+ puts " --tube <racoon> the beanstalk tube to use"
17
18
  puts " --pem <path> the path to the pem file, if a pem is supplied the server defaults to gateway.push.apple.com:2195"
18
19
  puts " --alert <message> the message to send"
19
20
  puts " --sound <default> the sound to play, defaults to 'default'"
@@ -26,6 +27,7 @@ end
26
27
 
27
28
  opts = GetoptLong.new(
28
29
  ["--beanstalk", "-b", GetoptLong::REQUIRED_ARGUMENT],
30
+ ["--tube", "-t", GetoptLong::REQUIRED_ARGUMENT],
29
31
  ["--pem", "-c", GetoptLong::REQUIRED_ARGUMENT],
30
32
  ["--alert", "-a", GetoptLong::REQUIRED_ARGUMENT],
31
33
  ["--sound", "-S", GetoptLong::REQUIRED_ARGUMENT],
@@ -37,6 +39,7 @@ opts = GetoptLong.new(
37
39
  )
38
40
 
39
41
  beanstalks = ["127.0.0.1:11300"]
42
+ tube = 'racoon'
40
43
  certificate = nil
41
44
  notification = Racoon::Notification.new
42
45
 
@@ -47,6 +50,8 @@ opts.each do |opt, arg|
47
50
  exit
48
51
  when '--beanstalk'
49
52
  beanstalks = CSV.parse(arg)[0]
53
+ when '--tube'
54
+ tube = arg
50
55
  when '--pem'
51
56
  certificate = File.read(arg)
52
57
  when '--alert'
@@ -69,12 +74,13 @@ if notification.device_token.nil?
69
74
  exit
70
75
  else
71
76
  bs = Beanstalk::Pool.new beanstalks
72
- %w{use watch}.each { |s| bs.send(s, "racoon-apns") }
77
+ %w{use watch}.each { |s| bs.send(s, tube) }
73
78
  bs.ignore("default")
74
- project = { :name => "test", :certificate => certificate }
75
- bs.yput({ :project => project,
79
+ project = { :name => "test", :certificate => certificate, :sandbox => true }
80
+ notif = { :project => project,
81
+ :identifier => 1,
76
82
  :notification => notification.payload,
77
83
  :device_token => notification.device_token,
78
- :sandbox => true
79
- })
84
+ }
85
+ bs.yput(notif)
80
86
  end
@@ -4,22 +4,21 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
4
4
  require 'getoptlong'
5
5
  require 'rubygems'
6
6
  require 'daemons'
7
- require 'eventmachine'
8
7
  require 'racoon'
9
- require 'racoon/server'
10
8
  require 'csv'
11
9
 
12
10
  def usage
13
- puts "Usage: racoond [switches] --beanstalk a.b.c.d:11300,...,w.x.y.z:11300"
11
+ puts "Usage: racoon-worker [switches]"
14
12
  puts " --beanstalk <127.0.0.1:11300> csv list of ip:port for beanstalk servers"
15
- puts " --pid </var/run/racoond.pid> the path to store the pid"
16
- puts " --log </var/log/racoond.log> the path to store the log"
13
+ puts " --tube <racoon> the beanstalk tube to use"
14
+ puts " --pid </var/run/racoon-worker.pid> the path to store the pid"
15
+ puts " --log </var/log/racoon-worker.log> the path to store the log"
17
16
  puts " --daemon to daemonize the server"
18
17
  puts " --help this message"
19
18
  end
20
19
 
21
20
  def daemonize
22
- Daemonize.daemonize(@log_file, 'racoond')
21
+ Daemonize.daemonize(@log_file, 'racoon-worker')
23
22
  open(@pid_file,"w") { |f| f.write(Process.pid) }
24
23
  open(@pid_file,"w") do |f|
25
24
  f.write(Process.pid)
@@ -29,6 +28,7 @@ end
29
28
 
30
29
  opts = GetoptLong.new(
31
30
  ["--beanstalk", "-b", GetoptLong::REQUIRED_ARGUMENT],
31
+ ["--tube", "-t", GetoptLong::REQUIRED_ARGUMENT],
32
32
  ["--pid", "-i", GetoptLong::REQUIRED_ARGUMENT],
33
33
  ["--log", "-l", GetoptLong::REQUIRED_ARGUMENT],
34
34
  ["--help", "-h", GetoptLong::NO_ARGUMENT],
@@ -36,8 +36,9 @@ opts = GetoptLong.new(
36
36
  )
37
37
 
38
38
  beanstalks = ["127.0.0.1:11300"]
39
- @pid_file = '/var/run/racoond.pid'
40
- @log_file = '/var/log/racoond.log'
39
+ tube = 'racoon'
40
+ @pid_file = '/var/run/racoon-worker.pid'
41
+ @log_file = '/var/log/racoon-worker.log'
41
42
  daemon = false
42
43
 
43
44
  opts.each do |opt, arg|
@@ -47,6 +48,8 @@ opts.each do |opt, arg|
47
48
  exit 1
48
49
  when '--beanstalk'
49
50
  beanstalks = CSV.parse(arg)[0]
51
+ when '--tube'
52
+ tube = arg
50
53
  when '--pid'
51
54
  @pid_file = arg
52
55
  when '--log'
@@ -58,8 +61,9 @@ end
58
61
 
59
62
  Racoon::Config.logger = Logger.new(@log_file)
60
63
 
61
- daemonize if daemon
62
- server = Racoon::Server.new(beanstalks) do |feedback_record|
63
- Racoon::Config.logger.info "Received feedback at #{feedback_record[:feedback_at]} (length: #{feedback_record[:length]}): #{feedback_record[:device_token]}"
64
+ if daemon
65
+ daemonize
66
+ else
67
+ puts "Starting racoon worker."
64
68
  end
65
- server.start!
69
+ Racoon::Worker.new(beanstalks, tube).start!
@@ -2,9 +2,11 @@ require 'logger'
2
2
  require 'racoon/config'
3
3
  require 'racoon/payload'
4
4
  require 'racoon/notification'
5
- require 'racoon/client'
6
- require 'racoon/feedback_client'
5
+ require 'racoon/apns/connection'
6
+ require 'racoon/apns/feedback_connection'
7
+ require 'racoon/worker'
8
+ require 'racoon/firehose'
7
9
 
8
10
  module Racoon
9
- VERSION = "0.4.0"
11
+ VERSION = "0.5.0"
10
12
  end
@@ -0,0 +1,52 @@
1
+ # Racoon - A distributed APNs provider
2
+ # Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
3
+ #
4
+ # This module contains the connection to the APNs service.
5
+
6
+ require 'openssl'
7
+ require 'socket'
8
+
9
+ module Racoon
10
+ module APNS
11
+ class Connection
12
+ attr_accessor :pem, :host, :port, :password
13
+
14
+ def initialize(pem, host = 'gateway.push.apple.com', port = 2195, pass = nil)
15
+ @pem, @host, @port, @password = pem, host, port, pass
16
+ end
17
+
18
+ def connect!
19
+ raise "Your certificate is not set." unless self.pem
20
+
21
+ @context = OpenSSL::SSL::SSLContext.new
22
+ @context.cert = OpenSSL::X509::Certificate.new(self.pem)
23
+ @context.key = OpenSSL::PKey::RSA.new(self.pem, self.password)
24
+
25
+ @sock = TCPSocket.new(self.host, self.port.to_i)
26
+ @ssl = OpenSSL::SSL::SSLSocket.new(@sock, @context)
27
+ @ssl.connect
28
+
29
+ return @sock, @ssl
30
+ end
31
+
32
+ def disconnect!
33
+ @ssl.close
34
+ @sock.close
35
+ @ssl = nil
36
+ @sock = nil
37
+ end
38
+
39
+ def write(bytes)
40
+ if host.include? "sandbox"
41
+ notification = Notification.parse(bytes)
42
+ Config.logger.debug "#{Time.now} [#{host}:#{port}] Device: #{notification.device_token.unpack('H*')} sending #{notification.json_payload}"
43
+ end
44
+ @ssl.write(notification.to_bytes)
45
+ end
46
+
47
+ def connected?
48
+ @ssl
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,29 @@
1
+ # Racoon - A distributed APNs provider
2
+ # Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
3
+ #
4
+ # This module contains the code that connects to the feedback service.
5
+
6
+ module Racoon
7
+ module APNS
8
+ class FeedbackConnection < Connection
9
+ def initialize(pem, host = 'feedback.push.apple.com', port = 2196, pass = nil)
10
+ @pem, @host, @port, @pass = pem, host, port, pass
11
+ end
12
+
13
+ def read
14
+ records ||= []
15
+ while record = @ssl.read(38)
16
+ records << parse_tuple(record)
17
+ end
18
+ records
19
+ end
20
+
21
+ private
22
+
23
+ def parse_tuple(data)
24
+ feedback = data.unpack("N1n1H*")
25
+ { :feedback_at => Time.at(feedback[0]), :length => feedback[1], :device_token => feedback[2] }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,3 +1,8 @@
1
+ # Racoon - A distributed APNs provider
2
+ # Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
3
+ #
4
+ # Configuration settings.
5
+
1
6
  module Racoon
2
7
  class Config
3
8
  class << self
@@ -0,0 +1,70 @@
1
+ # Racoon - A distributed APNs provider
2
+ # Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
3
+ #
4
+ # This module contains the firehose which is responsible for maintaining all the open
5
+ # connections to Apple, and sending data over the right ones.
6
+
7
+ require 'digest/sha1'
8
+ require 'eventmachine'
9
+ require 'ffi-rzmq'
10
+
11
+ module Racoon
12
+ class Firehose
13
+ def initialize(address = "tcp://*:11555", context = ZMQ::Context.new(1), &feedback_callback)
14
+ @connections = {}
15
+ @context = context
16
+ @firehose = context.socket(ZMQ::PULL)
17
+ @address = address
18
+ @feedback_callback = feedback_callback
19
+ end
20
+
21
+ def start!
22
+ EventMachine::run do
23
+ @firehose.bind(@address)
24
+
25
+ EventMachine::PeriodicTimer.new(28800) do
26
+ @connections.each_pair do |key, data|
27
+ begin
28
+ uri = "gateway.#{project[:sandbox] ? 'sandbox.' : ''}push.apple.com"
29
+ feedback = Racoon::APNS::FeedbackConnection.new(data[:certificate], uri)
30
+ feedback.connect!
31
+ feedback.read.each do |record|
32
+ @feedback_callback.call(record) if @feedback_callback
33
+ end
34
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
35
+ feedback.disconnect!
36
+ end
37
+ end
38
+ end
39
+
40
+ EventMachine::PeriodicTimer.new(0.1) do
41
+ received_message = ZMQ::Message.new
42
+ @firehose.recv(received_message, ZMQ::NOBLOCK)
43
+ yaml_string = received_message.copy_out_string
44
+
45
+ if yaml_string and yaml_string != ""
46
+ packet = YAML::load(yaml_string)
47
+
48
+ apns(packet[:project], packet[:bytes])
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def apns(project, bytes, retries=2)
55
+ uri = "gateway.#{project[:sandbox] ? 'sandbox.' : ''}push.apple.com"
56
+ hash = Digest::SHA1.hexdigest("#{project[:name]}-#{project[:certificate]}")
57
+
58
+ begin
59
+ connection = Racoon::APNS::Connection.new(project[:certificate], uri)
60
+ @connections[hash] ||= { :connection => connection, :certificate => project[:certificate], :sandbox => project[:sandbox] }
61
+
62
+ connection.connect! unless connection.connected?
63
+ connection.write(bytes)
64
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
65
+ connection.disconnect!
66
+ retry if (retries -= 1) > 0
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,3 +1,8 @@
1
+ # Racoon - A distributed APNs provider
2
+ # Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
3
+ #
4
+ # This module contains the class that represents notifications and all their details.
5
+
1
6
  require 'racoon/payload'
2
7
  require 'base64'
3
8
  require 'yajl'
@@ -6,11 +11,10 @@ module Racoon
6
11
  class Notification
7
12
  include Racoon::Payload
8
13
 
9
- attr_accessor :identifier, :expiry, :device_token, :alert, :badge, :sound, :custom, :send_at, :expiry
14
+ attr_accessor :identifier, :expiry, :device_token, :alert, :badge, :sound, :custom, :expiry
10
15
 
11
16
  def initialize
12
17
  @expiry = 0
13
- @send_at = Time.now
14
18
  end
15
19
 
16
20
  def payload
@@ -75,5 +79,20 @@ module Racoon
75
79
 
76
80
  notification
77
81
  end
82
+
83
+ def self.create_from_packet(packet)
84
+ aps = packet[:notification][:aps]
85
+
86
+ notification = Notification.new
87
+ notification.identifier = packet[:identifier]
88
+ notification.expiry = packet[:expiry] || 0
89
+ notification.device_token = packet[:device_token]
90
+ notification.badge = aps[:badge] if aps.has_key? :badge
91
+ notification.alert = aps[:alert] if aps.has_key? :alert
92
+ notification.sound = aps[:sound] if aps.has_key? :sound
93
+ notification.custom = aps[:custom] if aps.has_key? :custom
94
+
95
+ notification
96
+ end
78
97
  end
79
98
  end
@@ -1,3 +1,8 @@
1
+ # Racoon - A distributed APNs provider
2
+ # Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
3
+ #
4
+ # APNs payload data
5
+
1
6
  module Racoon
2
7
  module Payload
3
8
  PayloadInvalid = Class.new(RuntimeError)
@@ -0,0 +1,76 @@
1
+ # Racoon - A distributed APNs provider
2
+ # Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
3
+ #
4
+ # This module contains the worker which processes notifications before sending them off
5
+ # down to the firehose.
6
+
7
+ require 'beanstalk-client'
8
+ require 'eventmachine'
9
+ require 'ffi-rzmq'
10
+ require 'yaml'
11
+
12
+ module Racoon
13
+ class Worker
14
+ def initialize(beanstalk_uris, tube = "racoon", address = "tcp://*:11555", context = ZMQ::Context.new(1))
15
+ @beanstalk_uris = beanstalk_uris
16
+ @context = context
17
+ @firehose = context.socket(ZMQ::PUSH)
18
+ @tube = tube
19
+ @address = address
20
+ # First packet, send something silly, the firehose ignores it
21
+ @send_batch = true
22
+ end
23
+
24
+ def start!
25
+ EventMachine::run do
26
+ @firehose.connect(@address)
27
+
28
+ if @send_batch
29
+ @send_batch = false
30
+ @firehose.send_string("")
31
+ end
32
+
33
+ EventMachine::PeriodicTimer.new(0.5) do
34
+ begin
35
+ if beanstalk.peek_ready
36
+ job = beanstalk.reserve(1)
37
+ process job
38
+ job.delete
39
+ end
40
+ rescue Beanstalk::TimedOut
41
+ Config.logger.info "[Beanstalk] Unable to secure job, operation timed out."
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def beanstalk
50
+ return @beanstalk if @beanstalk
51
+ @beanstalk ||= Beanstalk::Pool.new(@beanstalk_uris)
52
+ %w{use watch}.each { |s| @beanstalk.send(s, @tube) }
53
+ @beanstalk.ignore('default')
54
+ @beanstalk
55
+ end
56
+
57
+ # Expects json ala:
58
+ # json = {
59
+ # "project":{
60
+ # "name":"foobar",
61
+ # "certificate":"...",
62
+ # "sandbox":false
63
+ # },
64
+ # "bytes":"..."
65
+ # }
66
+ def process(job)
67
+ packet = job.ybody
68
+ project = packet[:project]
69
+
70
+ notification = Notification.create_from_packet(packet)
71
+
72
+ data = { :project => project, :bytes => notification.to_bytes }
73
+ @firehose.send_string(YAML::dump(data))
74
+ end
75
+ end
76
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: racoon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -14,7 +14,7 @@ default_executable:
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: yajl-ruby
17
- requirement: &70324876524860 !ruby/object:Gem::Requirement
17
+ requirement: &70355758737600 !ruby/object:Gem::Requirement
18
18
  none: false
19
19
  requirements:
20
20
  - - ! '>='
@@ -22,10 +22,10 @@ dependencies:
22
22
  version: 0.7.0
23
23
  type: :runtime
24
24
  prerelease: false
25
- version_requirements: *70324876524860
25
+ version_requirements: *70355758737600
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: beanstalk-client
28
- requirement: &70324876524360 !ruby/object:Gem::Requirement
28
+ requirement: &70355758737040 !ruby/object:Gem::Requirement
29
29
  none: false
30
30
  requirements:
31
31
  - - ! '>='
@@ -33,10 +33,21 @@ dependencies:
33
33
  version: 1.0.0
34
34
  type: :runtime
35
35
  prerelease: false
36
- version_requirements: *70324876524360
36
+ version_requirements: *70355758737040
37
+ - !ruby/object:Gem::Dependency
38
+ name: ffi-rzmq
39
+ requirement: &70355758736520 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ~>
43
+ - !ruby/object:Gem::Version
44
+ version: 0.8.0
45
+ type: :runtime
46
+ prerelease: false
47
+ version_requirements: *70355758736520
37
48
  - !ruby/object:Gem::Dependency
38
49
  name: bundler
39
- requirement: &70324876523860 !ruby/object:Gem::Requirement
50
+ requirement: &70355758735740 !ruby/object:Gem::Requirement
40
51
  none: false
41
52
  requirements:
42
53
  - - ~>
@@ -44,10 +55,10 @@ dependencies:
44
55
  version: 1.0.0
45
56
  type: :development
46
57
  prerelease: false
47
- version_requirements: *70324876523860
58
+ version_requirements: *70355758735740
48
59
  - !ruby/object:Gem::Dependency
49
60
  name: eventmachine
50
- requirement: &70324876523360 !ruby/object:Gem::Requirement
61
+ requirement: &70355758734300 !ruby/object:Gem::Requirement
51
62
  none: false
52
63
  requirements:
53
64
  - - ! '>='
@@ -55,27 +66,30 @@ dependencies:
55
66
  version: 0.12.8
56
67
  type: :development
57
68
  prerelease: false
58
- version_requirements: *70324876523360
59
- description: A toolkit for proxying and sending Apple Push Notifications prepared
60
- for a hosted environment
69
+ version_requirements: *70355758734300
70
+ description: A distributed Apple Push Notification Service (APNs) provider developed
71
+ for hosting multiple projects.
61
72
  email: jeremy.tregunna@me.com
62
73
  executables:
63
74
  - racoon-send
64
- - racoond
75
+ - racoon-worker
76
+ - racoon-firehose
65
77
  extensions: []
66
78
  extra_rdoc_files:
67
79
  - README.mdown
68
80
  files:
69
81
  - bin/apnserverd.fedora.init
70
82
  - bin/apnserverd.ubuntu.init
83
+ - bin/racoon-firehose
71
84
  - bin/racoon-send
72
- - bin/racoond
73
- - lib/racoon/client.rb
85
+ - bin/racoon-worker
86
+ - lib/racoon/apns/connection.rb
87
+ - lib/racoon/apns/feedback_connection.rb
74
88
  - lib/racoon/config.rb
75
- - lib/racoon/feedback_client.rb
89
+ - lib/racoon/firehose.rb
76
90
  - lib/racoon/notification.rb
77
91
  - lib/racoon/payload.rb
78
- - lib/racoon/server.rb
92
+ - lib/racoon/worker.rb
79
93
  - lib/racoon.rb
80
94
  - README.mdown
81
95
  - spec/models/client_spec.rb
@@ -108,7 +122,7 @@ rubyforge_project: racoon
108
122
  rubygems_version: 1.6.2
109
123
  signing_key:
110
124
  specification_version: 3
111
- summary: Apple Push Notification Toolkit for hosted environments
125
+ summary: Distributed Apple Push Notification provider suitable for multi-project hosting.
112
126
  test_files:
113
127
  - spec/models/client_spec.rb
114
128
  - spec/models/feedback_client_spec.rb
@@ -1,44 +0,0 @@
1
- require 'openssl'
2
- require 'socket'
3
-
4
- module Racoon
5
- class Client
6
- attr_accessor :pem, :host, :port, :password
7
-
8
- def initialize(pem, host = 'gateway.push.apple.com', port = 2195, pass = nil)
9
- @pem, @host, @port, @password = pem, host, port, pass
10
- end
11
-
12
- def connect!
13
- raise "Your certificate is not set." unless self.pem
14
-
15
- @context = OpenSSL::SSL::SSLContext.new
16
- @context.cert = OpenSSL::X509::Certificate.new(self.pem)
17
- @context.key = OpenSSL::PKey::RSA.new(self.pem, self.password)
18
-
19
- @sock = TCPSocket.new(self.host, self.port.to_i)
20
- @ssl = OpenSSL::SSL::SSLSocket.new(@sock, @context)
21
- @ssl.connect
22
-
23
- return @sock, @ssl
24
- end
25
-
26
- def disconnect!
27
- @ssl.close
28
- @sock.close
29
- @ssl = nil
30
- @sock = nil
31
- end
32
-
33
- def write(notification)
34
- if host.include? "sandbox"
35
- Config.logger.debug "#{Time.now} [#{host}:#{port}] Device: #{notification.device_token.unpack('H*')} sending #{notification.json_payload}"
36
- end
37
- @ssl.write(notification.to_bytes)
38
- end
39
-
40
- def connected?
41
- @ssl
42
- end
43
- end
44
- end
@@ -1,24 +0,0 @@
1
- # Feedback service
2
-
3
- module Racoon
4
- class FeedbackClient < Client
5
- def initialize(pem, host = 'feedback.push.apple.com', port = 2196, pass = nil)
6
- @pem, @host, @port, @pass = pem, host, port, pass
7
- end
8
-
9
- def read
10
- records ||= []
11
- while record = @ssl.read(38)
12
- records << parse_tuple(record)
13
- end
14
- records
15
- end
16
-
17
- private
18
-
19
- def parse_tuple(data)
20
- feedback = data.unpack("N1n1H*")
21
- { :feedback_at => Time.at(feedback[0]), :length => feedback[1], :device_token => feedback[2] }
22
- end
23
- end
24
- end
@@ -1,193 +0,0 @@
1
- require 'beanstalk-client'
2
-
3
- module Racoon
4
- class Server
5
- attr_accessor :beanstalkd_uris, :feedback_callback
6
-
7
- def initialize(beanstalkd_uris = ["127.0.0.1:11300"], &feedback_blk)
8
- @beanstalks = {}
9
- @clients = {}
10
- @feedback_callback = feedback_blk
11
- @beanstalkd_uris = beanstalkd_uris
12
- end
13
-
14
- def beanstalk(arg)
15
- tube = "racoon-#{arg}"
16
- return @beanstalks[tube] if @beanstalks[tube]
17
- @beanstalks[tube] = Beanstalk::Pool.new @beanstalkd_uris
18
- @beanstalks[tube]
19
- end
20
-
21
- def start!
22
- EventMachine::run do
23
- EventMachine::PeriodicTimer.new(3600) do
24
- begin
25
- b = beanstalk('feedback')
26
- %w{watch use}.each { |s| b.send(s, "racoon-feedback") }
27
- b.ignore('default')
28
- if b.peek_ready
29
- item = b.reserve(1)
30
- handle_feedback(item)
31
- end
32
- rescue Beanstalk::TimedOut
33
- Config.logger.info "(Beanstalkd) Unable to secure a job, operation timed out."
34
- end
35
- end
36
-
37
- # Every minute,poll all the clients, ensuring they've been inactive for 20+ minutes.
38
- EventMachine::PeriodicTimer.new(60) do
39
- remove_clients = []
40
-
41
- @clients.each_pair do |project_name, packet|
42
- if Time.now - packet[:timestamp] >= 1200 # 20 minutes
43
- packet[:connection].disconnect!
44
- remove_clients << project_name
45
- end
46
- end
47
-
48
- remove_clients.each do |project_name|
49
- @clients[project_name] = nil
50
- end
51
- end
52
-
53
- EventMachine::PeriodicTimer.new(2) do
54
- begin
55
- b = beanstalk('apns')
56
- %w{watch use}.each { |s| b.send(s, "racoon-apns") }
57
- b.ignore('default')
58
- jobs = []
59
- until b.peek_ready.nil?
60
- item = b.reserve(1)
61
- jobs << item
62
- end
63
- handle_jobs jobs if jobs.count > 0
64
- rescue Beanstalk::TimedOut
65
- Config.logger.info "(Beanstalkd) Unable to secure a job, operation timed out."
66
- end
67
- end
68
- end
69
- end
70
-
71
- private
72
-
73
- # Received a notification. job is YAML encoded hash in the following format:
74
- # job = {
75
- # :project => {
76
- # :name => "Foo",
77
- # :certificate => "contents of a certificate.pem"
78
- # },
79
- # :device_token => "0f21ab...def",
80
- # :notification => notification.payload,
81
- # :sandbox => true # Development environment?
82
- # }
83
- def handle_jobs(jobs)
84
- connections = {}
85
- jobs.each do |job|
86
- packet = job.ybody
87
- project = packet[:project]
88
-
89
- client = get_client(project[:name], project[:certificate], packet[:sandbox])
90
- conn = client[:connection]
91
- connections[conn] ||= []
92
-
93
- notification = create_notification_from_packet(packet)
94
-
95
- connections[conn] << { :job => job, :notification => notification }
96
- end
97
-
98
- connections.each_pair do |conn, tasks|
99
- conn.connect! unless conn.connected?
100
- tasks.each do |data|
101
- job = data[:job]
102
- notif = data[:notification]
103
-
104
- begin
105
- conn.write(notif)
106
- @clients[project[:name]][:timestamp] = Time.now
107
-
108
- # TODO: Listen for error responses from Apple
109
- job.delete
110
- rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
111
- Config.logger.error "Caught error, closing connection and adding notification back to queue"
112
-
113
- connection.disconnect!
114
-
115
- job.release
116
- rescue RuntimeError => e
117
- Config.logger.error "Unable to handle: #{e}"
118
-
119
- job.delete
120
- end
121
- end
122
- end
123
- end
124
-
125
- # Will be a hash with two keys:
126
- # :certificate and :sandbox.
127
- def handle_feedback(job)
128
- begin
129
- packet = job.ybody
130
- uri = "feedback.#{packet[:sandbox] ? 'sandbox.' : ''}push.apple.com"
131
- feedback_client = Racoon::FeedbackClient.new(packet[:certificate], uri)
132
- feedback_client.connect!
133
- feedback_client.read.each do |record|
134
- feedback_callback.call record
135
- end
136
- feedback_client.disconnect!
137
- job.delete
138
- rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
139
- Config.logger.error "(Feedback) Caught Error, closing connection"
140
- feedback_client.disconnect!
141
- job.release
142
- rescue RuntimeError => e
143
- Config.logger.error "(Feedback) Unable to handle: #{e}"
144
- job.delete
145
- end
146
- end
147
-
148
- # Returns a hash containing a timestamp referring to when the connection was opened.
149
- # This timestamp will be updated to reflect when there was last activity over the socket.
150
- def get_client(project_name, certificate, sandbox = false)
151
- uri = "gateway.#{sandbox ? 'sandbox.' : ''}push.apple.com"
152
- unless @clients[project_name]
153
- @clients[project_name] = { :timestamp => Time.now, :connection => Racoon::Client.new(certificate, uri) }
154
- end
155
- @clients[project_name] ||= { :timestamp => Time.now, :connection => Racoon::Client.new(certificate, uri) }
156
- client = @clients[project_name]
157
- connection = client[:connection]
158
-
159
- # If the certificate has changed, but we still are connected using the old certificate,
160
- # disconnect and reconnect.
161
- unless connection.pem.eql?(certificate)
162
- connection.disconnect! if connection.connected?
163
- @clients[project_name] = { :timestamp => Time.now, :connection => Racoon::Client.new(certificate, uri) }
164
- client = @clients[project_name]
165
- end
166
-
167
- client
168
- end
169
-
170
- def purge_client(job)
171
- project_name = job.ybody
172
- client = @clients[project_name]
173
- client.disconnect! if client
174
- @clients[project_name] = nil
175
- job.delete
176
- end
177
-
178
- def create_notification_from_packet(packet)
179
- aps = packet[:notification][:aps]
180
-
181
- notification = Notification.new
182
- notification.identifier = packet[:identifier]
183
- notification.expiry = packet[:expiry]
184
- notification.device_token = packet[:device_token]
185
- notification.badge = aps[:badge] if aps.has_key? :badge
186
- notification.alert = aps[:alert] if aps.has_key? :alert
187
- notification.sound = aps[:sound] if aps.has_key? :sound
188
- notification.custom = aps[:custom] if aps.has_key? :custom
189
-
190
- notification
191
- end
192
- end
193
- end