diff --git a/Gemfile b/Gemfile
index bf16f02..9f06b0e 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,10 +1,12 @@
source "http://rubygems.org"
-gem "eventmachine"
gem "daemons"
-gem "activesupport", ">= 3.0.0"
-gem "i18n" # active support whines without this
+gem "yajl-ruby"
+gem "beanstalk-client"
+gem "ffi"
+gem "ffi-rzmq"
+gem "zmqmachine", :git => "git://github.com/jeremytregunna/zmqmachine.git"
group :spec do
gem "rspec"
-end
\ No newline at end of file
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index 408394e..3af895c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,11 +1,20 @@
+GIT
+ remote: git://github.com/jeremytregunna/zmqmachine.git
+ revision: fb39523eafa41127d3042f2bbe65c92fc451d412
+ specs:
+ zmqmachine (0.4.0)
+ ffi-rzmq (>= 0.7.0)
+
GEM
remote: http://rubygems.org/
specs:
- activesupport (3.0.3)
+ beanstalk-client (1.1.0)
daemons (1.0.10)
diff-lcs (1.1.2)
- eventmachine (0.12.10)
- i18n (0.5.0)
+ ffi (1.0.7)
+ rake (>= 0.8.7)
+ ffi-rzmq (0.8.0)
+ rake (0.8.7)
rspec (2.3.0)
rspec-core (~> 2.3.0)
rspec-expectations (~> 2.3.0)
@@ -14,13 +23,16 @@ GEM
rspec-expectations (2.3.0)
diff-lcs (~> 1.1.2)
rspec-mocks (2.3.0)
+ yajl-ruby (0.8.1)
PLATFORMS
ruby
DEPENDENCIES
- activesupport (>= 3.0.0)
+ beanstalk-client
daemons
- eventmachine
- i18n
+ ffi
+ ffi-rzmq
rspec
+ yajl-ruby
+ zmqmachine!
diff --git a/README.mdown b/README.mdown
new file mode 100644
index 0000000..9952c3c
--- /dev/null
+++ b/README.mdown
@@ -0,0 +1,201 @@
+# Racoon push notification server
+
+This project started off as a fork of [apnserver](https://github.com/bpoweski/apnserver). It
+has since taken on a different path. How does it differ from apnserver? By a few key points:
+
+1. It implements the APNS feedback service;
+2. Uses Yajl for JSON encoding/decoding rather than ActiveSupport;
+3. Expects certificates as strings instead of paths to files;
+4. Does not assume there is only one certificate (read: supports multiple projects); and
+5. Receives packets containing notifications from beanstalkd instead of a listening socket;
+6. Operates on a distributed architecture (many parallel workers, one firehose).
+
+The above changes were made because of the need to replace an existing APNs provider with something
+more robust, and better suited to scaling upwards. This APNs provider had a couple requirements:
+
+1. Support fully the APNs protocol (including feedback)
+2. Scale outwards horizontally
+3. Support multiple projects
+
+It should be noted that the development of this project is independent of the work bpoweski
+is doing on apnserver. If you're looking for that project, [go here](https://github.com/bpoweski/apnserver).
+
+## BIT FAT DEPENDENCY WARNING
+
+Since version 0.6.0, Racoon uses ZMQMachine. However, the mainline ZMQMachine does not support PUSH/PULL
+sockets as of the time I wrote this (probably still the same way, else I'd have removed this by now).
+As such, I strongly urge you to [fork this repository](https://github.com/jeremytregunna/zmqmachine) and install it before installing racoon.
+
+## Description
+
+Racoon consists of a firehose, which maintains the connections to Apple's APNs service. It also
+consists of a worker, which works on a beanstalk tube to pop notifications off and process them,
+before sending them off to the firehose. You can run many workers, they all run in parallel to
+one another. Additionally, it includes a command line tool to send (test) the system.
+
+At this time, Racoon only supports Apple's APNs service.
+
+## Remaining Tasks & Issues
+
+You can see progress by looking at the [issue tracker](https://www.pivotaltracker.com/projects/279053).
+
+## Preparing Certificates
+
+Certificates must be prepared before they can be used with racoon. Unfortunately, Apple
+gives us *.p12* files instead of *.pem* files. As such, we need to convert them. This
+can be accomplished by dropping to the command line and running this command:
+
+
+$ openssl pkcs12 -in cert.p12 -out cert.pem -nodes -clcerts
+
+
+This will generate a file suitable for use with this daemon, called *cert.pem*. If you're
+using frac.as, this is the file you would upload to the web service.
+
+If you're not using frac.as, then the contents of this file are what you need to use as
+your certificate, not the path to the file.
+
+## Firehose
+
+The firehose sits at the end of the pipeline. All the workers deliver messages to this
+part of racoon. Think of it as the drain in your sink.
+
+
+Usage: racoon-firehose [switches]
+ --pid the path to store the pid
+ --log the path to store the log
+ --daemon to daemonize the server
+ --help this message
+
+
+## Worker
+
+The worker is the part of the system which interacts with a beanstalk cluster. Each worker
+can talk to more than one beanstalk server, thus forming the "cluster". Since beanstalk
+clustering is all done client side, it's not a traditional cluster, but I'm going to call
+it that way anyway.
+
+The worker pops items that are ready off of the beanstalk cluster. It grabs those packets,
+constructs a message out of it, and then forms another packet, suitable for sending to
+Apple, before sending that packet to the firehose.
+
+You may run as many workers as your little heart desires. Keep in mind however, there will
+come a point when having only one firehose is not suitable to handle the amount of traffic
+you will be passing through from the workers. At this point, a second firehose will need
+to be started. I doubt anyone will ever be processing enough messages a second to warrant
+a second firehose.
+
+
+Usage: racoon-worker [switches]
+ --beanstalk <127.0.0.1:11300> csv list of ip:port for beanstalk servers
+ --pid the path to store the pid
+ --log the path to store the log
+ --daemon to daemonize the server
+ --help this message
+
+
+It should be noted that the worker's `--beanstalk` parameter requires comma separated values
+in `IP:port` pairings. Where `IP` is either an IP address, or a hostname to a host running a
+beanstalkd server on the associated port `port`. In the future, I will create a config file
+instead of having to specify this information on the command line each time. My apologies for
+the inconvenience.
+
+## Sender
+
+The sender is the program used to form a packet, place it on beanstalk for racoon to consume.
+It is useful during testing.
+
+
+Usage: racoon-send [switches] (--b64-token | --hex-token)
+ --beanstalk <127.0.0.1:11300> csv of ip:port for beanstalk servers
+ --pem the path to the pem file, if a pem is supplied the server defaults to gateway.push.apple.com:2195
+ --alert the message to send
+ --sound the sound to play, defaults to 'default'
+ --badge the badge number
+ --custom a custom json string to be added to the main object
+ --b64-token a base 64 encoded device token
+ --hex-token a hex encoded device token
+ --help this message
+
+
+## Preparing packets to place on beanstalk
+
+Until an API can be built that you can tie into with your own applications, please take care
+to construct your notifications as YAML payload with the following format. I will use ruby
+syntax for this example:
+
+```ruby
+{
+ :project => { :name => "My awesome app", :certificate => "...", :sandbox => true },
+ :identifier => 12345,
+ :notification => { :aps => { :alert => "text",
+ :sound => "default",
+ :badge => 1,
+ :custom => { ... }
+ }
+ },
+ :device_token => "..."
+}
+```
+
+A few key points need to be raised here. For starters, the `sandbox` key should only be true if
+you desire to work in the sandbox, and your `certificate` contains the text of your development
+certificate.
+
+Secondly, the `identifier` must be a unique 32-bit number identifying your message should you
+choose to want useful error messages. (This feature is not presently written, as such, you can
+supply anything you want, just make sure it falls within the range of `0` to `4294967295`.
+
+The `notification` key represents the payload we'll send to Apple. The `custom` key must be
+present if you intend to send custom data. Note however, that `custom` will be removed, and the
+items you place in its hash will be substituted in with the payload when the message is passed
+to Apple. As such, if you want a custom key -> value pair of: `"foo" => "bar"`, you would ensure
+you have: `:custom => { "foo" => "bar" }` in the notification. Your application should just look
+for "foo" in the payload delivered to the app.
+
+Finally, the ```device_token``` is a binary encoded representation of your devices token. Your
+app gets it in hex, please ensure you convert it to binary before sending it to beanstalk.
+
+TODO: Document new way of handling scheduling notifications in the future. Abstract beanstalk
+away from users of the library, give them a proper client interface to use instead.
+
+## Installation
+
+Racoon is hosted on [rubygems](https://rubygems.org/gems/racoon)
+
+
+$ gem install racoon
+
+
+Adding racoon to your Rails application
+
+```ruby
+gem 'racoon'
+```
+
+## License
+
+(The MIT License)
+
+Copyright (c) 2011 Jeremy Tregunna
+Copyright (c) 2011 Ben Poweski
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
diff --git a/README.textile b/README.textile
deleted file mode 100644
index 8546420..0000000
--- a/README.textile
+++ /dev/null
@@ -1,175 +0,0 @@
-h1. Apple Push Notification Server Toolkit
-
-* http://github.com/bpoweski/apnserver
-
-h2. Description
-
-apnserver is a server and set of command line programs to send push notifications to the iPhone.
-Apple recommends to maintain an open connection to the push notification service and refrain from
-opening up and tearing down SSL connections repeated. To solve this problem an intermediate
-network server is introduced that queues are requests to the APN service and sends them via a
-persistent connection.
-
-h2. Remaining Tasks
-
- * Implement feedback service mechanism
- * Implement robust notification sending in reactor periodic scheduler
-
-h2. Issues Fixed
-
- * second attempt at retry logic, SSL Errors close down sockets now
- * apnsend --badge option correctly sends integer number rather than string of number for aps json payload
- * connections are properly closed in Notification#push method now
- * removed json gem in favor of ActiveSupport
- * Rails 3.x support
- * drop the erroneous puts statements in favor a configurable logger
- * moved to Rspec
-
-h2. APN Server Daemon
-
-
-
-Usage: apnserverd [options] --pem /path/to/pem
- --bind-address bind address (defaults to 0.0.0.0)
- bind address of the server daemon
-
- --proxy-port port
- the port that the daemon will listen on (defaults to 22195)
-
- --server server
- APN Server (defaults to gateway.push.apple.com)
-
- --port port of the APN Server
- APN server port (defaults to 2195)
-
- --pem pem file path
- The PEM encoded private key and certificate.
- To export a PEM ecoded file execute
- # openssl pkcs12 -in cert.p12 -out cert.pem -nodes -clcerts
-
- --pem-passphrase passphrase
- The PEM passphrase to decode key.
-
- --help
- usage message
-
- --daemon
- Runs process as daemon, not available on Windows
-
-
-
-h2. APN Server Client
-
-With the APN server client script you can send push notifications directly to
-Apple's APN server over an SSL connection or to the above daemon using a plain socket.
-To send a notification to Apple's APN server using SSL the *--pem* option must be used.
-
-
-
-Usage: apnsend [switches] (--b64-token | --hex-token)
- --server the apn server defaults to a locally running apnserverd
- --port <2195> the port of the apn server
- --pem the path to the pem file, if a pem is supplied the server
- defaults to gateway.push.apple.com:2195
- --pem-passphrase the pem passphrase
- --alert the message to send"
- --sound the sound to play, defaults to 'default'
- --badge the badge number
- --custom a custom json string to be added to the main object
- --b64-token a base 64 encoded device token
- --hex-token a hex encoded device token
- --help this message
-
-
-
-To send a base64 encoded push notification via the command line execute the following:
-
-
-
- $ apnsend --server gateway.push.apple.com --port 2195 --pem key.pem \
- --b64-token j92f12jh8lqcAwcOVeSIrsBxibaJ0xyCi8/AkmzNlk8= --sound default \
- --alert Hello
-
-
-
-h2. Sending Notifications from Ruby
-
-To configure the client to send to the local apnserverd process configure the ApnServer client with the following.
-
-
-
- # configured for a using the apnserverd proxy
- ApnServer::Config.host = 'localhost'
- ApnServer::Config.port = 22195
- ApnServer::Config.logger = Rails.logger
-
-
-
-To configure the client to send directly to Apple's push notification server, bypassing the apnserverd process configure the following.
-
-
-
- ApnServer::Config.pem = '/path/to/pem'
- ApnServer::Config.host = 'gateway.push.apple.com'
- ApnServer::Config.port = 2195
-
-
-
-Finally within we can send a push notification using the following code
-
-
-
- notification = ApnServer::Notification.new
- notification.device_token = Base64.decode64(apns_token) # if base64 encoded
- notification.alert = message
- notification.badge = 1
- notification.sound = 'default'
- notification.push
-
-
-
-
-h2. Installation
-
-Apnserver is hosted on "rubygems":https://rubygems.org/gems/apnserver
-
-
-
- $ gem install apnserver
-
-
-
-Adding apnserver to your Rails application
-
-
-
- gem 'apnserver', '>= 0.2.0
-
-
-
-
-h2. License
-
-(The MIT License)
-
-Copyright (c) 2011 Ben Poweski
-
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-'Software'), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
-
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-
diff --git a/Rakefile b/Rakefile
index 279fcde..4bd6dac 100644
--- a/Rakefile
+++ b/Rakefile
@@ -2,9 +2,20 @@ Dir['tasks/**/*.rake'].each { |t| load t }
require 'rake/rdoctask'
Rake::RDocTask.new do |rd|
- rd.main = "README.textile"
+ rd.main = "README.mdown"
rd.rdoc_dir = 'rdoc'
- rd.rdoc_files.include("README.textile", "lib/**/*.rb")
+ rd.rdoc_files.include("README.mdown", "lib/**/*.rb")
+end
+
+gemspec = eval(File.read("racoon.gemspec"))
+task :build => "#{gemspec.full_name}.gem"
+file "#{gemspec.full_name}.gem" => gemspec.files + ["racoon.gemspec"] do
+ system "gem build racoon.gemspec"
+ system "gem install racoon-#{gemspec.version}.gem"
+end
+
+task :submit_gem => "racoon-#{gemspec.version}.gem" do
+ system "gem push racoon-#{gemspec.version}.gem"
end
task :default => [:spec]
diff --git a/apnserver.gemspec b/apnserver.gemspec
deleted file mode 100644
index e872c70..0000000
--- a/apnserver.gemspec
+++ /dev/null
@@ -1,25 +0,0 @@
-Gem::Specification.new do |s|
- s.name = %q{apnserver}
- s.version = "0.2.1"
- s.platform = Gem::Platform::RUBY
-
- s.authors = ["Ben Poweski"]
- s.date = %q{2011-01-01}
- s.description = %q{A toolkit for proxying and sending Apple Push Notifications}
- s.email = %q{bpoweski@3factors.com}
- s.executables = ["apnsend", "apnserverd"]
- s.extra_rdoc_files = ["README.textile"]
- s.files = Dir.glob("{bin,lib}/**/*") + %w(README.textile)
- s.homepage = %q{http://github.com/bpoweski/apnserver}
- s.rdoc_options = ["--charset=UTF-8"]
- s.require_paths = ["lib"]
- s.rubyforge_project = %q{apnserver}
- s.rubygems_version = %q{1.3.5}
- s.summary = %q{Apple Push Notification Toolkit}
- s.test_files = Dir.glob("spec/**/*")
-
- s.required_rubygems_version = ">= 1.3.6"
- s.add_dependency 'activesupport', '~> 3.0.0'
- s.add_development_dependency 'bundler', '~> 1.0.0'
- s.add_development_dependency 'eventmachine', '>= 0.12.8'
-end
diff --git a/bin/apnserverd b/bin/apnserverd
deleted file mode 100755
index 82119ba..0000000
--- a/bin/apnserverd
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env ruby
-$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
-
-require 'getoptlong'
-require 'rubygems'
-require 'daemons'
-require 'apnserver'
-require 'apnserver/server'
-
-def usage
- puts "Usage: apnserverd [switches] --pem "
- puts " --pem-passphrase pem passphrase"
- puts " --bind-address [0.0.0.0] bind address of proxy"
- puts " --proxy-port [22195] port proxy listens on"
- puts " --server the apn server to send messages to"
- puts " --port <2195> the port of the apn server"
- puts " --pid
-
-PATH=/sbin:/usr/sbin:/bin:/usr/bin
-DESC="Apple Push Notification Server Daemon"
-NAME=apnserverd
-DAEMON=/usr/bin/$NAME
-PEMPATH=""
-DAEMON_ARGS="--daemon --pem $PEMPATH"
-PIDFILE=/var/run/$NAME.pid
-SCRIPTNAME=/etc/init.d/$NAME
-
-# Exit if the package is not installed
-[ -x "$DAEMON" ] || exit 0
-
-# Load the VERBOSE setting and other rcS variables
-. /lib/init/vars.sh
-
-# Define LSB log_* functions.
-# Depend on lsb-base (>= 3.0-6) to ensure that this file is present.
-. /lib/lsb/init-functions
-
-#
-# Function that starts the daemon/service
-#
-do_start()
-{
- # Return
- # 0 if daemon has been started
- # 1 if daemon was already running
- # 2 if daemon could not be started
- start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \
- || return 1
- start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- \
- $DAEMON_ARGS \
- || return 2
-}
-
-#
-# Function that stops the daemon/service
-#
-do_stop()
-{
- # Return
- # 0 if daemon has been stopped
- # 1 if daemon was already stopped
- # 2 if daemon could not be stopped
- # other if a failure occurred
- start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME
- RETVAL="$?"
- [ "$RETVAL" = 2 ] && return 2
- # Wait for children to finish too if this is a daemon that forks
- # and if the daemon is only ever run from this initscript.
- # If the above conditions are not satisfied then add some other code
- # that waits for the process to drop all resources that could be
- # needed by services started subsequently. A last resort is to
- # sleep for some time.
- start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON
- [ "$?" = 2 ] && return 2
- # Many daemons don't delete their pidfiles when they exit.
- rm -f $PIDFILE
- return "$RETVAL"
-}
-
-
-case "$1" in
- start)
- [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
- do_start
- case "$?" in
- 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
- 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
- esac
- ;;
- stop)
- [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
- do_stop
- case "$?" in
- 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
- 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
- esac
- ;;
- restart|force-reload)
- log_daemon_msg "Restarting $DESC" "$NAME"
- do_stop
- case "$?" in
- 0|1)
- do_start
- case "$?" in
- 0) log_end_msg 0 ;;
- 1) log_end_msg 1 ;; # Old process is still running
- *) log_end_msg 1 ;; # Failed to start
- esac
- ;;
- *)
- # Failed to stop
- log_end_msg 1
- ;;
- esac
- ;;
- *)
- echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload}" >&2
- exit 3
- ;;
-esac
-
-:
diff --git a/bin/racoon-firehose b/bin/racoon-firehose
new file mode 100755
index 0000000..9ab67bf
--- /dev/null
+++ b/bin/racoon-firehose
@@ -0,0 +1,83 @@
+#!/usr/bin/env ruby
+$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
+
+require 'getoptlong'
+require 'rubygems'
+require 'daemons'
+require 'racoon'
+require 'csv'
+require 'yaml'
+require 'zmqmachine'
+
+def usage
+ puts "Usage: racoon-firehose [switches]"
+ puts " --pid the path to store the pid"
+ puts " --log the path to store the log"
+ puts " --daemon to daemonize the server"
+ puts " --help this message"
+end
+
+def daemonize
+ Daemonize.daemonize(@log_file, 'racoon-firehose')
+ open(@pid_file,"w") { |f| f.write(Process.pid) }
+ open(@pid_file,"w") do |f|
+ f.write(Process.pid)
+ File.chmod(0644, @pid_file)
+ end
+end
+
+opts = GetoptLong.new(
+ ["--pid", "-i", GetoptLong::REQUIRED_ARGUMENT],
+ ["--log", "-l", GetoptLong::REQUIRED_ARGUMENT],
+ ["--help", "-h", GetoptLong::NO_ARGUMENT],
+ ["--daemon", "-d", GetoptLong::NO_ARGUMENT]
+)
+
+@pid_file = '/var/run/racoon-firehose.pid'
+@log_file = '/var/log/racoon-firehose.log'
+daemon = false
+
+opts.each do |opt, arg|
+ case opt
+ when '--help'
+ usage
+ exit 1
+ when '--pid'
+ @pid_file = arg
+ when '--log'
+ @log_file = arg
+ when '--daemon'
+ daemon = true
+ end
+end
+
+Racoon::Config.logger = Logger.new(@log_file)
+
+if daemon
+ daemonize
+else
+ puts "Starting racoon firehose."
+end
+
+ZM::Reactor.new(:firehose).run do |context|
+ firehose = Racoon::Firehose.new(context) do |feedback_record|
+ Racoon::Config.logger.info "[Feedback] Time: #{feedback_record[:feedback_at]} Device: #{feedback_record[:device_token]} Length: #{feedback_record[:length]}"
+ end
+ context.pull_socket(firehose)
+
+ context.periodical_timer(28800) do
+ firehose.connections.each_pair do |key, data|
+ begin
+ project = data[:project]
+ uri = "gateway.#{project[:sandbox] ? 'sandbox.' : ''}push.apple.com"
+ feedback = Racoon::APNS::FeedbackConnection.new(data[:certificate], uri)
+ feedback.connect!
+ feedback.read.each do |record|
+ @feedback_callback.call(record) if @feedback_callback
+ end
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
+ feedback.disconnect!
+ end
+ end
+ end
+end.join
diff --git a/bin/apnsend b/bin/racoon-send
similarity index 58%
rename from bin/apnsend
rename to bin/racoon-send
index 5e55e6c..8fbd10e 100755
--- a/bin/apnsend
+++ b/bin/racoon-send
@@ -4,16 +4,18 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'logger'
require 'getoptlong'
require 'rubygems'
-require 'apnserver'
+require 'racoon'
require 'base64'
require 'socket'
+require 'yajl'
+require 'csv'
+require 'beanstalk-client'
def usage
- puts "Usage: apnsend [switches] (--b64-token | --hex-token) "
- puts " --server the apn server defaults to a locally running apnserverd"
- puts " --port <2195> the port of the apn server"
+ puts "Usage: racoon-send [switches] (--b64-token | --hex-token) "
+ puts " --beanstalk <127.0.0.1:11300> csv of ip:port for beanstalk servers"
+ puts " --tube the beanstalk tube to use"
puts " --pem the path to the pem file, if a pem is supplied the server defaults to gateway.push.apple.com:2195"
- puts " --pem-passphrase the pem passphrase"
puts " --alert the message to send"
puts " --sound the sound to play, defaults to 'default'"
puts " --badge the badge number"
@@ -24,34 +26,34 @@ def usage
end
opts = GetoptLong.new(
- ["--server", "-s", GetoptLong::REQUIRED_ARGUMENT],
- ["--port", "-p", GetoptLong::REQUIRED_ARGUMENT],
+ ["--beanstalk", "-b", GetoptLong::REQUIRED_ARGUMENT],
+ ["--tube", "-t", GetoptLong::REQUIRED_ARGUMENT],
["--pem", "-c", GetoptLong::REQUIRED_ARGUMENT],
- ["--pem-passphrase", "-C", GetoptLong::REQUIRED_ARGUMENT],
["--alert", "-a", GetoptLong::REQUIRED_ARGUMENT],
["--sound", "-S", GetoptLong::REQUIRED_ARGUMENT],
- ["--badge", "-b", GetoptLong::REQUIRED_ARGUMENT],
+ ["--badge", "-n", GetoptLong::REQUIRED_ARGUMENT],
["--custom", "-j", GetoptLong::REQUIRED_ARGUMENT],
["--b64-token", "-B", GetoptLong::REQUIRED_ARGUMENT],
["--hex-token", "-H", GetoptLong::REQUIRED_ARGUMENT],
["--help", "-h", GetoptLong::NO_ARGUMENT]
)
-notification = ApnServer::Notification.new
+beanstalks = ["127.0.0.1:11300"]
+tube = 'racoon'
+certificate = nil
+notification = Racoon::Notification.new
opts.each do |opt, arg|
case opt
when '--help'
usage
exit
- when '--server'
- ApnServer::Config.host = arg
- when '--port'
- ApnServer::Config.port = arg.to_i
+ when '--beanstalk'
+ beanstalks = CSV.parse(arg)[0]
+ when '--tube'
+ tube = arg
when '--pem'
- ApnServer::Config.pem = arg
- when '--pem-passphrase'
- ApnServer::Config.password = arg
+ certificate = File.read(arg)
when '--alert'
notification.alert = arg
when '--sound'
@@ -59,11 +61,11 @@ opts.each do |opt, arg|
when '--badge'
notification.badge = arg.to_i
when '--custom'
- notification.custom = ActiveSupport::JSON.decode(arg)
+ notification.custom = Yajl::Parser.parse(arg)
when '--b64-token'
notification.device_token = Base64::decode64(arg)
when '--hex-token'
- notification.device_token = arg.scan(/[0-9a-f][0-9a-f]/).map {|s| s.hex.chr}.join
+ notification.device_token = arg.scan(/[0-9a-f][0-9a-f]/).map {|s| s.hex.chr}.join
end
end
@@ -71,5 +73,14 @@ if notification.device_token.nil?
usage
exit
else
- notification.push
+ bs = Beanstalk::Pool.new beanstalks
+ %w{use watch}.each { |s| bs.send(s, tube) }
+ bs.ignore("default")
+ project = { :name => "test", :certificate => certificate, :sandbox => true }
+ notif = { :project => project,
+ :identifier => 1,
+ :notification => notification.payload,
+ :device_token => notification.device_token,
+ }
+ bs.yput(notif)
end
diff --git a/bin/racoon-worker b/bin/racoon-worker
new file mode 100755
index 0000000..e4c8e1c
--- /dev/null
+++ b/bin/racoon-worker
@@ -0,0 +1,120 @@
+#!/usr/bin/env ruby
+$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
+
+require 'getoptlong'
+require 'rubygems'
+require 'daemons'
+require 'racoon'
+require 'csv'
+require 'yaml'
+require 'zmqmachine'
+require 'beanstalk-client'
+
+def usage
+ puts "Usage: racoon-worker [switches]"
+ puts " --beanstalk <127.0.0.1:11300> csv list of ip:port for beanstalk servers"
+ puts " --tube the beanstalk tube to use"
+ puts " --firehose <127.0.0.1:11555> the address of the firehose"
+ puts " --pid the path to store the pid"
+ puts " --log the path to store the log"
+ puts " --daemon to daemonize the server"
+ puts " --help this message"
+end
+
+def daemonize
+ Daemonize.daemonize(@log_file, 'racoon-worker')
+ open(@pid_file,"w") { |f| f.write(Process.pid) }
+ open(@pid_file,"w") do |f|
+ f.write(Process.pid)
+ File.chmod(0644, @pid_file)
+ end
+end
+
+opts = GetoptLong.new(
+ ["--beanstalk", "-b", GetoptLong::REQUIRED_ARGUMENT],
+ ["--tube", "-t", GetoptLong::REQUIRED_ARGUMENT],
+ ["--firehose", "-f", GetoptLong::REQUIRED_ARGUMENT],
+ ["--pid", "-i", GetoptLong::REQUIRED_ARGUMENT],
+ ["--log", "-l", GetoptLong::REQUIRED_ARGUMENT],
+ ["--help", "-h", GetoptLong::NO_ARGUMENT],
+ ["--daemon", "-d", GetoptLong::NO_ARGUMENT]
+)
+
+@beanstalks = ["127.0.0.1:11300"]
+firehose_address = "127.0.0.1:11555".split(":")
+@tube = 'racoon'
+@pid_file = '/var/run/racoon-worker.pid'
+@log_file = '/var/log/racoon-worker.log'
+daemon = false
+
+opts.each do |opt, arg|
+ case opt
+ when '--help'
+ usage
+ exit 1
+ when '--beanstalk'
+ @beanstalks = CSV.parse(arg)[0]
+ when '--tube'
+ @tube = arg
+ when '--firehose'
+ firehose_address = arg.split(":")
+ when '--pid'
+ @pid_file = arg
+ when '--log'
+ @log_file = arg
+ when '--daemon'
+ daemon = true
+ end
+end
+
+Racoon::Config.logger = Logger.new(@log_file)
+
+def beanstalk
+ return @beanstalk if @beanstalk
+ @beanstalk = Beanstalk::Pool.new(@beanstalks)
+ %w{use watch}.each { |s| @beanstalk.send(s, @tube) }
+ @beanstalk.ignore('default')
+ @beanstalk
+end
+
+# Expects json ala:
+# json = {
+# "project":{
+# "name":"foobar",
+# "certificate":"...",
+# "sandbox":false
+# },
+# "bytes":"..."
+# }
+def process(job, worker)
+ packet = job.ybody
+ project = packet[:project]
+
+ notification = Racoon::Notification.create_from_packet(packet)
+
+ data = { :project => project, :bytes => notification.to_bytes }
+ worker.send_message(YAML::dump(data))
+end
+
+if daemon
+ daemonize
+else
+ puts "Starting racoon worker."
+end
+
+ZM::Reactor.new(:worker).run do |context|
+ worker = Racoon::Worker.new(context, ZM::Address.new(firehose_address[0], firehose_address[1], :tcp))
+ context.push_socket(worker)
+
+ context.periodical_timer(0.1) do
+ begin
+ if beanstalk.peek_ready
+ job = beanstalk.reserve(1)
+ process(job, worker)
+ job.delete
+ end
+ rescue Beanstalk::TimedOut
+ Racoon::Config.logger.info "[Beanstalk] Unable to secure job, timed out."
+ end
+ end
+end.join
diff --git a/doc/firehose.mdown b/doc/firehose.mdown
new file mode 100644
index 0000000..e13e256
--- /dev/null
+++ b/doc/firehose.mdown
@@ -0,0 +1,4 @@
+# Racoon Firehose
+
+This component sits at the end of the pipeline. Its job is to pull notifications (as they are to be
+sent to apple) in from all the workers, and fire the message off to Apple.
diff --git a/doc/worker.mdown b/doc/worker.mdown
new file mode 100644
index 0000000..542157d
--- /dev/null
+++ b/doc/worker.mdown
@@ -0,0 +1,13 @@
+# Racoon Worker
+
+Workers are very simple devices. They sit at the front-end and pop items off the beanstalk cluster.
+They create the notifications, and feed them out to the connector.
+
+## Parts
+
+* Popper
+ This piece pops items off of the beanstalk cluster. It creates notifications, and passes them
+ over the firehose.
+* Firehose
+ This is a push socket where we send the results from the popper. These are the results preprepared
+ to be written over the socket.
\ No newline at end of file
diff --git a/lib/apnserver.rb b/lib/apnserver.rb
deleted file mode 100644
index 8731c53..0000000
--- a/lib/apnserver.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-require 'logger'
-require 'apnserver/payload'
-require 'apnserver/notification'
-require 'apnserver/client'
diff --git a/lib/apnserver/client.rb b/lib/apnserver/client.rb
deleted file mode 100644
index 642acf0..0000000
--- a/lib/apnserver/client.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-require 'openssl'
-require 'socket'
-
-module ApnServer
- class Client
- attr_accessor :pem, :host, :port, :password
-
- def initialize(pem, host = 'gateway.push.apple.com', port = 2195, pass = nil)
- @pem, @host, @port, @password = pem, host, port, pass
- end
-
- def connect!
- raise "The path to your pem file is not set." unless self.pem
- raise "The path to your pem file does not exist!" unless File.exist?(self.pem)
-
- @context = OpenSSL::SSL::SSLContext.new
- @context.cert = OpenSSL::X509::Certificate.new(File.read(self.pem))
- @context.key = OpenSSL::PKey::RSA.new(File.read(self.pem), self.password)
-
- @sock = TCPSocket.new(self.host, self.port.to_i)
- @ssl = OpenSSL::SSL::SSLSocket.new(@sock, @context)
- @ssl.connect
-
- return @sock, @ssl
- end
-
- def disconnect!
- @ssl.close
- @sock.close
- @ssl = nil
- @sock = nil
- end
-
- def write(notification)
- Config.logger.debug "#{Time.now} [#{host}:#{port}] Device: #{notification.device_token.unpack('H*')} sending #{notification.json_payload}"
- @ssl.write(notification.to_bytes)
- end
-
- def connected?
- @ssl
- end
- end
-end
\ No newline at end of file
diff --git a/lib/apnserver/notification.rb b/lib/apnserver/notification.rb
deleted file mode 100644
index 1a66189..0000000
--- a/lib/apnserver/notification.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-require 'apnserver/payload'
-require 'base64'
-require 'active_support/ordered_hash'
-require 'active_support/json'
-
-module ApnServer
- class Config
- class << self
- attr_accessor :host, :port, :pem, :password, :logger
- end
- end
-
- Config.logger = Logger.new("/dev/null")
-
- class Notification
- include ApnServer::Payload
-
- attr_accessor :device_token, :alert, :badge, :sound, :custom
-
- def payload
- p = Hash.new
- [:badge, :alert, :sound, :custom].each do |k|
- p[k] = send(k) if send(k)
- end
- create_payload(p)
- end
-
- def json_payload
- j = ActiveSupport::JSON.encode(payload)
- raise PayloadInvalid.new("The payload is larger than allowed: #{j.length}") if j.size > 256
- j
- end
-
- def push
- if Config.pem.nil?
- socket = TCPSocket.new(Config.host || 'localhost', Config.port.to_i || 22195)
- socket.write(to_bytes)
- socket.close
- else
- client = ApnServer::Client.new(Config.pem, Config.host || 'gateway.push.apple.com', Config.port.to_i || 2195)
- client.connect!
- client.write(self)
- client.disconnect!
- end
- end
-
- def to_bytes
- j = json_payload
- [0, 0, device_token.size, device_token, 0, j.size, j].pack("ccca*cca*")
- end
-
- def self.valid?(p)
- begin
- Notification.parse(p)
- rescue PayloadInvalid => p
- Config.logger.error "PayloadInvalid: #{p}"
- false
- rescue RuntimeError
- false
- rescue Exception => e
- false
- end
- end
-
- def self.parse(p)
- buffer = p.dup
- notification = Notification.new
-
- header = buffer.slice!(0, 3).unpack('ccc')
- if header[0] != 0
- raise RuntimeError.new("Header of notification is invalid: #{header.inspect}")
- end
-
- # parse token
- notification.device_token = buffer.slice!(0, 32).unpack('a*').first
-
- # parse json payload
- payload_len = buffer.slice!(0, 2).unpack('CC')
- j = buffer.slice!(0, payload_len.last)
- result = ActiveSupport::JSON.decode(j)
-
- ['alert', 'badge', 'sound'].each do |k|
- notification.send("#{k}=", result['aps'][k]) if result['aps'] && result['aps'][k]
- end
- result.delete('aps')
- notification.custom = result
-
- notification
- end
- end
-end
diff --git a/lib/apnserver/protocol.rb b/lib/apnserver/protocol.rb
deleted file mode 100644
index 3b10346..0000000
--- a/lib/apnserver/protocol.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module ApnServer
- module Protocol
- def post_init
- @address = Socket.unpack_sockaddr_in(self.get_peername)
- Config.logger.debug "#{Time.now} [#{address.last}:#{address.first}] CONNECT"
- end
-
- def unbind
- Config.logger.debug "#{Time.now} [#{address.last}:#{address.first}] DISCONNECT"
- end
-
- def receive_data(data)
- (@buf ||= "") << data
- if notification = ApnServer::Notification.valid?(@buf)
- Config.logger.debug "#{Time.now} [#{address.last}:#{address.first}] found valid Notification: #{notification}"
- queue.push(notification)
- else
- Config.logger.debug "#{Time.now} [#{address.last}:#{address.first}] invalid notification: #{@buf}"
- end
- end
- end
-end
\ No newline at end of file
diff --git a/lib/apnserver/server.rb b/lib/apnserver/server.rb
deleted file mode 100644
index 9a1d19f..0000000
--- a/lib/apnserver/server.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-module ApnServer
- class Server
- attr_accessor :client, :bind_address, :port
-
- def initialize(pem, bind_address = '0.0.0.0', port = 22195)
- @queue = EM::Queue.new
- @client = ApnServer::Client.new(pem)
- @bind_address, @port = bind_address, port
- end
-
- def start!
- EventMachine::run do
- Config.logger.info "#{Time.now} Starting APN Server on #{bind_address}:#{port}"
-
- EM.start_server(bind_address, port, ApnServer::ServerConnection) do |s|
- s.queue = @queue
- end
-
- EventMachine::PeriodicTimer.new(1) do
- unless @queue.empty?
- size = @queue.size
- size.times do
- @queue.pop do |notification|
- begin
- @client.connect! unless @client.connected?
- @client.write(notification)
- rescue Errno::EPIPE, OpenSSL::SSL::SSLError
- Config.logger.error "Caught Error, closing connecting and adding notification back to queue"
- @client.disconnect!
- @queue.push(notification)
- rescue RuntimeError => e
- Config.logger.error "Unable to handle: #{e}"
- end
- end
- end
- end
- end
- end
- end
- end
-end
diff --git a/lib/apnserver/server_connection.rb b/lib/apnserver/server_connection.rb
deleted file mode 100644
index fa0d470..0000000
--- a/lib/apnserver/server_connection.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-require 'socket'
-require 'apnserver/protocol'
-
-module ApnServer
- class ServerConnection < EventMachine::Connection
- include Protocol
- attr_accessor :queue, :address
- end
-end
\ No newline at end of file
diff --git a/lib/racoon.rb b/lib/racoon.rb
new file mode 100644
index 0000000..9887807
--- /dev/null
+++ b/lib/racoon.rb
@@ -0,0 +1,12 @@
+require 'logger'
+require 'racoon/config'
+require 'racoon/payload'
+require 'racoon/notification'
+require 'racoon/apns/connection'
+require 'racoon/apns/feedback_connection'
+require 'racoon/worker'
+require 'racoon/firehose'
+
+module Racoon
+ VERSION = "1.0.1"
+end
diff --git a/lib/racoon/apns/connection.rb b/lib/racoon/apns/connection.rb
new file mode 100644
index 0000000..72580a6
--- /dev/null
+++ b/lib/racoon/apns/connection.rb
@@ -0,0 +1,67 @@
+# Racoon - A distributed APNs provider
+# Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
+#
+# This module contains the connection to the APNs service.
+
+require 'openssl'
+require 'socket'
+
+module Racoon
+ module APNS
+ class Connection
+ attr_accessor :pem, :host, :port, :password
+
+ def initialize(pem, host = 'gateway.push.apple.com', port = 2195, pass = nil)
+ @pem, @host, @port, @password = pem, host, port, pass
+ end
+
+ def connect!
+ raise "Your certificate is not set." unless self.pem
+
+ @context = OpenSSL::SSL::SSLContext.new
+ @context.cert = OpenSSL::X509::Certificate.new(self.pem)
+ @context.key = OpenSSL::PKey::RSA.new(self.pem, self.password)
+
+ @sock = TCPSocket.new(self.host, self.port.to_i)
+ @ssl = OpenSSL::SSL::SSLSocket.new(@sock, @context)
+ @ssl.connect
+
+ return @sock, @ssl
+ end
+
+ def disconnect!
+ @ssl.close
+ @sock.close
+ @ssl = nil
+ @sock = nil
+ end
+
+ def read
+ errors ||= []
+ while error = @ssl.read(6)
+ errors << parse_tuple(error)
+ end
+ errors
+ end
+
+ def write(bytes)
+ if host.include? "sandbox"
+ notification = Notification.parse(bytes)
+ Config.logger.debug "#{Time.now} [#{host}:#{port}] Device: #{notification.device_token.unpack('H*')} sending #{notification.json_payload}"
+ end
+ @ssl.write(notification.to_bytes)
+ end
+
+ def connected?
+ @ssl
+ end
+
+ private
+
+ def parse_tuple(data)
+ packet = data.unpack("c1c1N1")
+ { :command => packet[0], :status => packet[1], :identifier => packet[2] }
+ end
+ end
+ end
+end
diff --git a/lib/racoon/apns/feedback_connection.rb b/lib/racoon/apns/feedback_connection.rb
new file mode 100644
index 0000000..a22f59b
--- /dev/null
+++ b/lib/racoon/apns/feedback_connection.rb
@@ -0,0 +1,29 @@
+# Racoon - A distributed APNs provider
+# Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
+#
+# This module contains the code that connects to the feedback service.
+
+module Racoon
+ module APNS
+ class FeedbackConnection < Connection
+ def initialize(pem, host = 'feedback.push.apple.com', port = 2196, pass = nil)
+ @pem, @host, @port, @pass = pem, host, port, pass
+ end
+
+ def read
+ records ||= []
+ while record = @ssl.read(38)
+ records << parse_tuple(record)
+ end
+ records
+ end
+
+ private
+
+ def parse_tuple(data)
+ feedback = data.unpack("N1n1H*")
+ { :feedback_at => Time.at(feedback[0]), :length => feedback[1], :device_token => feedback[2] }
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/racoon/config.rb b/lib/racoon/config.rb
new file mode 100644
index 0000000..2ede23e
--- /dev/null
+++ b/lib/racoon/config.rb
@@ -0,0 +1,14 @@
+# Racoon - A distributed APNs provider
+# Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
+#
+# Configuration settings.
+
+module Racoon
+ class Config
+ class << self
+ attr_accessor :logger
+ end
+ end
+
+ Config.logger = Logger.new("/dev/null")
+end
\ No newline at end of file
diff --git a/lib/racoon/firehose.rb b/lib/racoon/firehose.rb
new file mode 100644
index 0000000..3641523
--- /dev/null
+++ b/lib/racoon/firehose.rb
@@ -0,0 +1,55 @@
+# Racoon - A distributed APNs provider
+# Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
+#
+# This module contains the firehose which is responsible for maintaining all the open
+# connections to Apple, and sending data over the right ones.
+
+require 'digest/sha1'
+require 'zmqmachine'
+require 'yaml'
+
+module Racoon
+ class Firehose
+ attr_accessor :connections, :feedback_callback, :error_callback
+
+ def initialize(reactor, address = ZM::Address.new('*', 11555, :tcp), &feedback_callback)
+ @connections = {}
+ @reactor = reactor
+ @address = address
+ @feedback_callback = feedback_callback
+ @error_callback = nil
+ end
+
+ def on_attach(socket)
+ socket.bind(@address)
+ end
+
+ def on_readable(socket, messages)
+ messages.each do |message|
+ packet = YAML::load(message.copy_out_string)
+ apns(packet[:project], packet[:bytes])
+ end
+ end
+
+ private
+
+ def apns(project, bytes, retries=2)
+ uri = "gateway.#{project[:sandbox] ? 'sandbox.' : ''}push.apple.com"
+ hash = Digest::SHA1.hexdigest("#{project[:name]}-#{project[:certificate]}")
+
+ begin
+ @connections[hash] ||= { :connection => Racoon::APNS::Connection.new(project[:certificate], uri),
+ :certificate => project[:certificate],
+ :sandbox => project[:sandbox] }
+ connection = @connections[hash][:connection]
+
+ connection.connect! unless connection.connected?
+ connection.write(bytes)
+ connection.read.each(&@error_callback) if @error_callback
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
+ connection.disconnect!
+ retry if (retries -= 1) > 0
+ end
+ end
+ end
+end
diff --git a/lib/racoon/notification.rb b/lib/racoon/notification.rb
new file mode 100644
index 0000000..90ffd26
--- /dev/null
+++ b/lib/racoon/notification.rb
@@ -0,0 +1,98 @@
+# Racoon - A distributed APNs provider
+# Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
+#
+# This module contains the class that represents notifications and all their details.
+
+require 'racoon/payload'
+require 'base64'
+require 'yajl'
+
+module Racoon
+ class Notification
+ include Racoon::Payload
+
+ attr_accessor :identifier, :expiry, :device_token, :alert, :badge, :sound, :custom, :expiry
+
+ def initialize
+ @expiry = 0
+ end
+
+ def payload
+ p = Hash.new
+ [:badge, :alert, :sound, :custom].each do |k|
+ r = send(k)
+ p[k] = r if r
+ end
+ create_payload(p)
+ end
+
+ def json_payload
+ j = Yajl::Encoder.encode(payload)
+ raise PayloadInvalid.new("The payload is larger than allowed: #{j.length}") if j.size > 256
+ j
+ end
+
+ def to_bytes
+ j = json_payload
+ [1, identifier, expiry.to_i, device_token.size, device_token, j.size, j].pack("cNNna*na*")
+ end
+
+ def self.valid?(p)
+ begin
+ Notification.parse(p)
+ rescue PayloadInvalid => p
+ Config.logger.error "PayloadInvalid: #{p}"
+ false
+ rescue RuntimeError => r
+ Config.logger.error "Runtime error: #{r}"
+ false
+ rescue Exception => e
+ Config.logger.error "Unknown error: #{e}"
+ false
+ end
+ end
+
+ def self.parse(p)
+ buffer = p.dup
+ notification = Notification.new
+
+ header = buffer.slice!(0, 11).unpack("cNNn")
+ raise RuntimeError.new("Header of notification is invalid: #{header.inspect}") if header[0] != 1
+
+ # identifier
+ notification.identifier = header[1]
+ notification.expiry = header[2]
+
+ # device token
+ notification.device_token = buffer.slice!(0, 32).unpack("a*").first
+
+ # JSON payload
+ payload_len = buffer.slice!(0, 2).unpack("n")
+ j = buffer.slice!(0, payload_len.last)
+ result = Yajl::Parser.parse(j)
+
+ ['alert', 'badge', 'sound'].each do |k|
+ notification.send("#{k}=", result['aps'][k]) if result['aps'] && result['aps'][k]
+ end
+ result.delete("aps")
+ notification.custom = result
+
+ notification
+ end
+
+ def self.create_from_packet(packet)
+ aps = packet[:notification][:aps]
+
+ notification = Notification.new
+ notification.identifier = packet[:identifier]
+ notification.expiry = packet[:expiry] || 0
+ notification.device_token = packet[:device_token]
+ notification.badge = aps[:badge] if aps.has_key? :badge
+ notification.alert = aps[:alert] if aps.has_key? :alert
+ notification.sound = aps[:sound] if aps.has_key? :sound
+ notification.custom = aps[:custom] if aps.has_key? :custom
+
+ notification
+ end
+ end
+end
diff --git a/lib/apnserver/payload.rb b/lib/racoon/payload.rb
similarity index 75%
rename from lib/apnserver/payload.rb
rename to lib/racoon/payload.rb
index 07e4813..7d7c6e8 100644
--- a/lib/apnserver/payload.rb
+++ b/lib/racoon/payload.rb
@@ -1,4 +1,9 @@
-module ApnServer
+# Racoon - A distributed APNs provider
+# Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
+#
+# APNs payload data
+
+module Racoon
module Payload
PayloadInvalid = Class.new(RuntimeError)
diff --git a/lib/racoon/worker.rb b/lib/racoon/worker.rb
new file mode 100644
index 0000000..3337c07
--- /dev/null
+++ b/lib/racoon/worker.rb
@@ -0,0 +1,38 @@
+# Racoon - A distributed APNs provider
+# Copyright (c) 2011, Jeremy Tregunna, All Rights Reserved.
+#
+# This module contains the worker which processes notifications before sending them off
+# down to the firehose.
+
+require 'ffi-rzmq'
+require 'zmqmachine'
+
+module Racoon
+ class Worker
+ def initialize(reactor, address)
+ @reactor = reactor
+ @address = address
+ @send_queue = []
+ end
+
+ def on_attach(socket)
+ @socket = socket
+
+ socket.connect(@address.to_s)
+ end
+
+ def on_writable(socket)
+ unless @send_queue.empty?
+ message = @send_queue.shift
+ socket.send_message_string(message)
+ else
+ @reactor.deregister_writable(socket)
+ end
+ end
+
+ def send_message(message)
+ @send_queue.push(message)
+ @reactor.register_writable(@socket)
+ end
+ end
+end
diff --git a/racoon-firehose.god b/racoon-firehose.god
new file mode 100644
index 0000000..950fad9
--- /dev/null
+++ b/racoon-firehose.god
@@ -0,0 +1,36 @@
+# Godfile for racoon-firehose
+
+[ 1 ].each do |instance|
+ name = "racoon-firehose-#{instance}"
+ pid_file = "/var/run/#{name}.pid"
+
+ God.watch do |w|
+ w.name = name
+ w.interval = 30.seconds
+ w.start = "/fracas/deploy/racoon/bin/racoon-firehose -d --pid #{pid_file} --log /var/log/#{name}.log"
+ w.stop = "kill -9 `cat #{pid_file}`"
+ w.start_grace = 10.seconds
+ w.pid_file = pid_file
+
+ w.behavior(:clean_pid_file)
+
+ w.start_if do |start|
+ start.condition(:process_running) do |c|
+ c.interval = 5.seconds
+ c.running = false
+ end
+ end
+
+ w.lifecycle do |on|
+ on.condition(:flapping) do |c|
+ c.to_state = [:start, :stop, :start]
+ c.times = 5
+ c.within = 5.minutes
+ c.transition = :unmonitored
+ c.retry_in = 10.minutes
+ c.retry_times = 5
+ c.retry_within = 2.hours
+ end
+ end
+ end
+end
diff --git a/racoon-worker.god b/racoon-worker.god
new file mode 100644
index 0000000..bf79ee2
--- /dev/null
+++ b/racoon-worker.god
@@ -0,0 +1,36 @@
+# Godfile for racoon-worker
+
+[ 1 ].each do |instance|
+ name = "racoon-worker-#{instance}"
+ pid_file = "/var/run/#{name}.pid"
+
+ God.watch do |w|
+ w.name = name
+ w.interval = 30.seconds
+ w.start = "/fracas/deploy/racoon/bin/racoon-worker -d --beanstalk 127.0.0.1:11300 --firehose localhost:11555 --pid #{pid_file} --log /var/log/#{name}.log"
+ w.stop = "kill -9 `cat #{pid_file}`"
+ w.start_grace = 10.seconds
+ w.pid_file = pid_file
+
+ w.behavior(:clean_pid_file)
+
+ w.start_if do |start|
+ start.condition(:process_running) do |c|
+ c.interval = 5.seconds
+ c.running = false
+ end
+ end
+
+ w.lifecycle do |on|
+ on.condition(:flapping) do |c|
+ c.to_state = [:start, :stop, :start]
+ c.times = 5
+ c.within = 5.minutes
+ c.transition = :unmonitored
+ c.retry_in = 10.minutes
+ c.retry_times = 5
+ c.retry_within = 2.hours
+ end
+ end
+ end
+end
diff --git a/racoon.gemspec b/racoon.gemspec
new file mode 100644
index 0000000..c80da74
--- /dev/null
+++ b/racoon.gemspec
@@ -0,0 +1,26 @@
+Gem::Specification.new do |s|
+ s.name = %q{racoon}
+ s.version = "1.0.1"
+ s.platform = Gem::Platform::RUBY
+
+ s.authors = ["Jeremy Tregunna"]
+ s.date = %q{2011-04-24}
+ s.description = %q{A distributed Apple Push Notification Service (APNs) provider developed for hosting multiple projects.}
+ s.email = %q{jeremy.tregunna@me.com}
+ s.executables = ["racoon-send", "racoon-worker", "racoon-firehose"]
+ s.extra_rdoc_files = ["README.mdown"]
+ s.files = Dir.glob("{bin,lib}/**/*") + %w(README.mdown)
+ s.homepage = %q{https://github.com/jeremytregunna/racoon}
+ s.rdoc_options = ["--charset=UTF-8"]
+ s.require_paths = ["lib"]
+ s.rubyforge_project = %q{racoon}
+ s.rubygems_version = %q{1.3.5}
+ s.summary = %q{Distributed Apple Push Notification provider suitable for multi-project hosting.}
+ s.test_files = Dir.glob("spec/**/*")
+
+ s.required_rubygems_version = ">= 1.3.6"
+ s.add_dependency 'yajl-ruby', '>= 0.7.0'
+ s.add_dependency 'beanstalk-client', '>= 1.0.0'
+ s.add_dependency 'ffi-rzmq', '~> 0.8.0'
+ s.add_development_dependency 'bundler', '~> 1.0.0'
+end
diff --git a/spec/models/feedback_client_spec.rb b/spec/models/feedback_client_spec.rb
new file mode 100644
index 0000000..63304f9
--- /dev/null
+++ b/spec/models/feedback_client_spec.rb
@@ -0,0 +1,21 @@
+require 'spec_helper'
+
+module ApnServer
+ describe FeedbackClient do
+ describe "#new" do
+ let(:feedback_client) { ApnServer::FeedbackClient.new('cert.pem', 'feedback.sandbox.push.apple.com', 2196) }
+
+ it "sets the pem path" do
+ feedback_client.pem.should == 'cert.pem'
+ end
+
+ it "sets the host" do
+ feedback_client.host.should == 'feedback.sandbox.push.apple.com'
+ end
+
+ it "sets the port" do
+ feedback_client.port.should == 2196
+ end
+ end
+ end
+end
diff --git a/spec/models/protocol_spec.rb b/spec/models/protocol_spec.rb
deleted file mode 100644
index a819417..0000000
--- a/spec/models/protocol_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-require 'spec_helper'
-
-describe "TestProtocol" do
- before(:each) do
- @server = TestServer.new
- @server.queue = Array.new # fake out EM::Queue
- end
-
- it "adds_notification_to_queue" do
- token = "12345678123456781234567812345678"
- @server.receive_data("\0\0 #{token}\0#{22.chr}{\"aps\":{\"alert\":\"Hi\"}}")
- @server.queue.size.should == 1
- end
-
- it "does_not_add_invalid_notification" do
- @server.receive_data('fakedata')
- @server.queue.should be_empty
- end
-end
diff --git a/spec/support/test_server.rb b/spec/support/test_server.rb
deleted file mode 100644
index 37951c8..0000000
--- a/spec/support/test_server.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'apnserver/protocol'
-
-class TestServer
- attr_accessor :queue
- include ApnServer::Protocol
-
- def address
- [12345, '127.0.0.1']
- end
-end
\ No newline at end of file