racoon 0.3.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.
data/README.mdown ADDED
@@ -0,0 +1,172 @@
1
+ # Racoon push notification server
2
+
3
+ This project started off as a fork of [apnserver](https://github.com/bpoweski/apnserver). It
4
+ has since taken on a different path. How does it differ from apnserver? By a few key points:
5
+
6
+ 1. It implements the APNS feedback service;
7
+ 2. Uses Yajl for JSON encoding/decoding rather than ActiveSupport;
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.
11
+
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.
16
+
17
+ It should be noted that the development of this project is independent of the work bpoweski
18
+ is doing on apnserver. If you're looking for that project, [go here](https://github.com/bpoweski/apnserver).
19
+
20
+ ## Description
21
+
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.
28
+
29
+ ## Remaining Tasks & Issues
30
+
31
+ You can see progress by looking at the [issue tracker](https://www.pivotaltracker.com/projects/251991) page for fracas. Any labels related to
32
+ *apnserver* or *racoon* are related to this subproject.
33
+
34
+ ## Preparing Certificates
35
+
36
+ Certificates must be prepared before they can be used with racoon. Unfortunately, Apple
37
+ gives us @.p12@ files instead of @.pem@ files. As such, we need to convert them. This
38
+ can be accomplished by dropping to the command line and running this command:
39
+
40
+ <pre>
41
+ $ openssl pkcs12 -in cert.p12 -out cert.pem -nodes -clcerts
42
+ </pre>
43
+
44
+ This will generate a file suitable for use with this daemon, called @cert.pem@. If you're
45
+ using frac.as, this is the file you would upload to the web service.
46
+
47
+ If you're not using frac.as, then the contents of this file are what you need to use as
48
+ your certificate, not the path to the file.
49
+
50
+ ## APN Server Daemon
51
+
52
+ <pre>
53
+ Usage: racoond [options]
54
+ --beanstalk <csv ip:port mappings>
55
+ The comma-separated list of ip:port for beanstalk servers
56
+
57
+ --pid <pid file path>
58
+ Path used for the PID file. Defaults to /var/run/racoon.pid
59
+
60
+ --log <log file path>
61
+ Path used for the log file. Defaults to /var/log/racoon.log
62
+
63
+ --help
64
+ usage message
65
+
66
+ --daemon
67
+ Runs process as daemon, not available on Windows
68
+ </pre>
69
+
70
+ ## APN Server Client
71
+
72
+ TODO: Document this
73
+
74
+ ## Sending Notifications from Ruby
75
+
76
+ You need to set up a connection to the beanstalkd service. We can do this simply by defining
77
+ the following method:
78
+
79
+ ```ruby
80
+ def beanstalk
81
+ return @beanstalk if @beanstalk
82
+ @beanstalk = Beanstalk::Pool.new ["127.0.0.1:11300"]
83
+ @beanstalk.use "awesome-tube"
84
+ @beanstalk
85
+ end
86
+ ```
87
+
88
+ In this way, whenever we need access to beanstalk, we'll make the connection and set up to use
89
+ the appropriate tube whether the connection is open yet or not.
90
+
91
+ We will also need two pieces of information: A project, and a notification.
92
+
93
+ ### Project
94
+
95
+ A project os comprised of a few pieces of information at a minimum (you may supply more if you
96
+ wish, but racoond will ignore them):
97
+
98
+ ```ruby
99
+ project = { :name => "Awesome project", :certificate => "contents of the generated .pem file" }
100
+ ```
101
+
102
+ ### Notification
103
+
104
+ A notification is a ruby hash containing the things to be sent along, including the device token.
105
+ An example notification may look like this:
106
+
107
+ ```ruby
108
+ notification = { :device_token => "hex encoded device token",
109
+ :aps => { :alert => "Some text",
110
+ :sound => "Audio_file",
111
+ :badge => 42,
112
+ :custom => { :field => "lala",
113
+ :stuff => 42 }
114
+ }
115
+ }
116
+ ```
117
+
118
+ Finally within we can send a push notification using the following code:
119
+
120
+ ```ruby
121
+ beanstalk.yput({ :project => project, :notification => notification, :sandbox => true })
122
+ ```
123
+
124
+ Note that the `sandbox` parameter is used to indicate whether or not we're using a development
125
+ certificate, and as such, should contact Apple's sandbox APN service instead of the production
126
+ certificate. If left out, we assume production.
127
+
128
+ This will schedule the push on beanstalkd. Racoon is constantly polling beanstalkd looking for
129
+ ready jobs it can pop off and process (send to Apple). Using beanstalkd however allows us to
130
+ queue up items, and during peak times, add another **N** more racoon servers to make up any
131
+ backlog, to ensure our messages are sent fast, and that we can scale.
132
+
133
+ ## Installation
134
+
135
+ Racoon is hosted on [rubygems](https://rubygems.org/gems/racoon)
136
+
137
+ <pre>
138
+ $ gem install racoon
139
+ </pre>
140
+
141
+ Adding racoon to your Rails application
142
+
143
+ ```ruby
144
+ gem 'racoon'
145
+ ```
146
+
147
+ ## License
148
+
149
+ (The MIT License)
150
+
151
+ Copyright (c) 2011 Jeremy Tregunna
152
+ Copyright (c) 2011 Ben Poweski
153
+
154
+ Permission is hereby granted, free of charge, to any person obtaining
155
+ a copy of this software and associated documentation files (the
156
+ 'Software'), to deal in the Software without restriction, including
157
+ without limitation the rights to use, copy, modify, merge, publish,
158
+ distribute, sublicense, and/or sell copies of the Software, and to
159
+ permit persons to whom the Software is furnished to do so, subject to
160
+ the following conditions:
161
+
162
+ The above copyright notice and this permission notice shall be
163
+ included in all copies or substantial portions of the Software.
164
+
165
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
166
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
167
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
168
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
169
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
170
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
171
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
172
+
data/bin/apnsend ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env ruby
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+
4
+ require 'logger'
5
+ require 'getoptlong'
6
+ require 'rubygems'
7
+ require 'apnserver'
8
+ require 'base64'
9
+ require 'socket'
10
+
11
+ def usage
12
+ puts "Usage: apnsend [switches] (--b64-token | --hex-token) <token>"
13
+ puts " --server <localhost> the apn server defaults to a locally running apnserverd"
14
+ puts " --port <2195> the port of the apn server"
15
+ puts " --pem <path> the path to the pem file, if a pem is supplied the server defaults to gateway.push.apple.com:2195"
16
+ puts " --pem-passphrase <passphrase> the pem passphrase"
17
+ puts " --alert <message> the message to send"
18
+ puts " --sound <default> the sound to play, defaults to 'default'"
19
+ puts " --badge <number> the badge number"
20
+ puts " --custom <json string> a custom json string to be added to the main object"
21
+ puts " --b64-token <token> a base 64 encoded device token"
22
+ puts " --hex-token <token> a hex encoded device token"
23
+ puts " --help this message"
24
+ end
25
+
26
+ opts = GetoptLong.new(
27
+ ["--server", "-s", GetoptLong::REQUIRED_ARGUMENT],
28
+ ["--port", "-p", GetoptLong::REQUIRED_ARGUMENT],
29
+ ["--pem", "-c", GetoptLong::REQUIRED_ARGUMENT],
30
+ ["--pem-passphrase", "-C", GetoptLong::REQUIRED_ARGUMENT],
31
+ ["--alert", "-a", GetoptLong::REQUIRED_ARGUMENT],
32
+ ["--sound", "-S", GetoptLong::REQUIRED_ARGUMENT],
33
+ ["--badge", "-b", GetoptLong::REQUIRED_ARGUMENT],
34
+ ["--custom", "-j", GetoptLong::REQUIRED_ARGUMENT],
35
+ ["--b64-token", "-B", GetoptLong::REQUIRED_ARGUMENT],
36
+ ["--hex-token", "-H", GetoptLong::REQUIRED_ARGUMENT],
37
+ ["--help", "-h", GetoptLong::NO_ARGUMENT]
38
+ )
39
+
40
+ notification = ApnServer::Notification.new
41
+
42
+ opts.each do |opt, arg|
43
+ case opt
44
+ when '--help'
45
+ usage
46
+ exit
47
+ when '--server'
48
+ ApnServer::Config.host = arg
49
+ when '--port'
50
+ ApnServer::Config.port = arg.to_i
51
+ when '--pem'
52
+ ApnServer::Config.pem = File.read(arg)
53
+ when '--pem-passphrase'
54
+ ApnServer::Config.password = arg
55
+ when '--alert'
56
+ notification.alert = arg
57
+ when '--sound'
58
+ notification.sound = arg
59
+ when '--badge'
60
+ notification.badge = arg.to_i
61
+ when '--custom'
62
+ notification.custom = ActiveSupport::JSON.decode(arg)
63
+ when '--b64-token'
64
+ notification.device_token = Base64::decode64(arg)
65
+ when '--hex-token'
66
+ notification.device_token = arg.scan(/[0-9a-f][0-9a-f]/).map {|s| s.hex.chr}.join
67
+ end
68
+ end
69
+
70
+ if notification.device_token.nil?
71
+ usage
72
+ exit
73
+ else
74
+ notification.push
75
+ end
@@ -0,0 +1,71 @@
1
+ #!/bin/bash
2
+ #
3
+ # /etc/rc.d/init.d/apnserverd
4
+ # apnserverd This shell script takes care of starting and stopping
5
+ # the APN Server Proxy
6
+ #
7
+ # chkconfig: 345 20 80
8
+ # Author: Ben Poweski bpoweski@gmail.com
9
+ #
10
+ # Source function library.
11
+ . /etc/init.d/functions
12
+
13
+ NAME=apnserverd
14
+ APNSERVERD=/usr/bin/$NAME
15
+ PIDFILE=/var/run/$NAME.pid
16
+
17
+ if [ -f /etc/sysconfig/$NAME ]; then
18
+ . /etc/sysconfig/$NAME
19
+ fi
20
+
21
+
22
+ start() {
23
+ echo -n "Starting APN Server: "
24
+ if [ -f $PIDFILE ]; then
25
+ PID=`cat $PIDFILE`
26
+ echo $NAME already running: $PID
27
+ exit 2;
28
+ elif [ -f $PIDFILE ]; then
29
+ PID=`cat $PIDFILE`
30
+ echo $NAME already running: $PID
31
+ exit 2;
32
+ else
33
+ daemon $APNSERVERD $OPTIONS
34
+ RETVAL=$?
35
+ echo
36
+ [ $RETVAL -eq 0 ] && touch /var/lock/subsys/$NAME
37
+ return $RETVAL
38
+ fi
39
+
40
+ }
41
+
42
+ stop() {
43
+ echo -n "Shutting down APN Server: "
44
+ echo
45
+ kill `cat $PIDFILE`
46
+ echo
47
+ rm -f /var/lock/subsys/$NAME
48
+ rm -f $PIDFILE
49
+ return 0
50
+ }
51
+
52
+ case "$1" in
53
+ start)
54
+ start
55
+ ;;
56
+ stop)
57
+ stop
58
+ ;;
59
+ status)
60
+ status $NAME
61
+ ;;
62
+ restart)
63
+ stop
64
+ start
65
+ ;;
66
+ *)
67
+ echo "Usage: {start|stop|status|restart}"
68
+ exit 1
69
+ ;;
70
+ esac
71
+ exit $?
@@ -0,0 +1,116 @@
1
+ #! /bin/sh
2
+ ### BEGIN INIT INFO
3
+ # Provides: apnserverd
4
+ # Required-Start: $remote_fs
5
+ # Required-Stop: $remote_fs
6
+ # Default-Start: 2 3 4 5
7
+ # Default-Stop: 0 1 6
8
+ # Short-Description: Apple Push Notification Server Daemon
9
+ ### END INIT INFO
10
+
11
+ # Author: Philipp Schmid <philipp.schmid@openresearch.com>
12
+
13
+ PATH=/sbin:/usr/sbin:/bin:/usr/bin
14
+ DESC="Apple Push Notification Server Daemon"
15
+ NAME=apnserverd
16
+ DAEMON=/usr/bin/$NAME
17
+ PEMPATH=""
18
+ DAEMON_ARGS="--daemon --pem $PEMPATH"
19
+ PIDFILE=/var/run/$NAME.pid
20
+ SCRIPTNAME=/etc/init.d/$NAME
21
+
22
+ # Exit if the package is not installed
23
+ [ -x "$DAEMON" ] || exit 0
24
+
25
+ # Load the VERBOSE setting and other rcS variables
26
+ . /lib/init/vars.sh
27
+
28
+ # Define LSB log_* functions.
29
+ # Depend on lsb-base (>= 3.0-6) to ensure that this file is present.
30
+ . /lib/lsb/init-functions
31
+
32
+ #
33
+ # Function that starts the daemon/service
34
+ #
35
+ do_start()
36
+ {
37
+ # Return
38
+ # 0 if daemon has been started
39
+ # 1 if daemon was already running
40
+ # 2 if daemon could not be started
41
+ start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \
42
+ || return 1
43
+ start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON -- \
44
+ $DAEMON_ARGS \
45
+ || return 2
46
+ }
47
+
48
+ #
49
+ # Function that stops the daemon/service
50
+ #
51
+ do_stop()
52
+ {
53
+ # Return
54
+ # 0 if daemon has been stopped
55
+ # 1 if daemon was already stopped
56
+ # 2 if daemon could not be stopped
57
+ # other if a failure occurred
58
+ start-stop-daemon --stop --quiet --retry=TERM/30/KILL/5 --pidfile $PIDFILE --name $NAME
59
+ RETVAL="$?"
60
+ [ "$RETVAL" = 2 ] && return 2
61
+ # Wait for children to finish too if this is a daemon that forks
62
+ # and if the daemon is only ever run from this initscript.
63
+ # If the above conditions are not satisfied then add some other code
64
+ # that waits for the process to drop all resources that could be
65
+ # needed by services started subsequently. A last resort is to
66
+ # sleep for some time.
67
+ start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON
68
+ [ "$?" = 2 ] && return 2
69
+ # Many daemons don't delete their pidfiles when they exit.
70
+ rm -f $PIDFILE
71
+ return "$RETVAL"
72
+ }
73
+
74
+
75
+ case "$1" in
76
+ start)
77
+ [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME"
78
+ do_start
79
+ case "$?" in
80
+ 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
81
+ 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
82
+ esac
83
+ ;;
84
+ stop)
85
+ [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME"
86
+ do_stop
87
+ case "$?" in
88
+ 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;;
89
+ 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;;
90
+ esac
91
+ ;;
92
+ restart|force-reload)
93
+ log_daemon_msg "Restarting $DESC" "$NAME"
94
+ do_stop
95
+ case "$?" in
96
+ 0|1)
97
+ do_start
98
+ case "$?" in
99
+ 0) log_end_msg 0 ;;
100
+ 1) log_end_msg 1 ;; # Old process is still running
101
+ *) log_end_msg 1 ;; # Failed to start
102
+ esac
103
+ ;;
104
+ *)
105
+ # Failed to stop
106
+ log_end_msg 1
107
+ ;;
108
+ esac
109
+ ;;
110
+ *)
111
+ echo "Usage: $SCRIPTNAME {start|stop|restart|force-reload}" >&2
112
+ exit 3
113
+ ;;
114
+ esac
115
+
116
+ :
data/bin/racoond ADDED
@@ -0,0 +1,65 @@
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 'eventmachine'
8
+ require 'racoon'
9
+ require 'racoon/server'
10
+ require 'csv'
11
+
12
+ def usage
13
+ puts "Usage: racoond [switches] --beanstalk a.b.c.d:11300,...,w.x.y.z:11300"
14
+ 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"
17
+ puts " --daemon to daemonize the server"
18
+ puts " --help this message"
19
+ end
20
+
21
+ def daemonize
22
+ Daemonize.daemonize(@log_file, 'racoond')
23
+ open(@pid_file,"w") { |f| f.write(Process.pid) }
24
+ open(@pid_file,"w") do |f|
25
+ f.write(Process.pid)
26
+ File.chmod(0644, @pid_file)
27
+ end
28
+ end
29
+
30
+ opts = GetoptLong.new(
31
+ ["--beanstalk", "-b", GetoptLong::REQUIRED_ARGUMENT],
32
+ ["--pid", "-i", GetoptLong::REQUIRED_ARGUMENT],
33
+ ["--log", "-l", GetoptLong::REQUIRED_ARGUMENT],
34
+ ["--help", "-h", GetoptLong::NO_ARGUMENT],
35
+ ["--daemon", "-d", GetoptLong::NO_ARGUMENT]
36
+ )
37
+
38
+ beanstalks = ["127.0.0.1:11300"]
39
+ @pid_file = '/var/run/racoond.pid'
40
+ @log_file = '/var/log/racoond.log'
41
+ daemon = false
42
+
43
+ opts.each do |opt, arg|
44
+ case opt
45
+ when '--help'
46
+ usage
47
+ exit 1
48
+ when '--beanstalk'
49
+ beanstalks = CSV.parse(arg)[0]
50
+ when '--pid'
51
+ @pid_file = arg
52
+ when '--log'
53
+ @log_file = arg
54
+ when '--daemon'
55
+ daemon = true
56
+ end
57
+ end
58
+
59
+ Racoon::Config.logger = Logger.new(@log_file)
60
+
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
+ end
65
+ server.start!
@@ -0,0 +1,42 @@
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
+ Config.logger.debug "#{Time.now} [#{host}:#{port}] Device: #{notification.device_token.unpack('H*')} sending #{notification.json_payload}"
35
+ @ssl.write(notification.to_bytes)
36
+ end
37
+
38
+ def connected?
39
+ @ssl
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,9 @@
1
+ module Racoon
2
+ class Config
3
+ class << self
4
+ attr_accessor :logger
5
+ end
6
+ end
7
+
8
+ Config.logger = Logger.new("/dev/null")
9
+ end
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,87 @@
1
+ require 'racoon/payload'
2
+ require 'base64'
3
+ require 'yajl'
4
+
5
+ module Racoon
6
+ class Notification
7
+ include Racoon::Payload
8
+
9
+ attr_accessor :device_token, :alert, :badge, :sound, :custom
10
+
11
+ def payload
12
+ p = Hash.new
13
+ [:badge, :alert, :sound, :custom].each do |k|
14
+ r = send(k)
15
+ p[k] = r if r
16
+ end
17
+ create_payload(p)
18
+ end
19
+
20
+ def json_payload
21
+ j = Yajl::Encoder.encode(payload)
22
+ raise PayloadInvalid.new("The payload is larger than allowed: #{j.length}") if j.size > 256
23
+ j
24
+ end
25
+
26
+ =begin
27
+ def push
28
+ if Config.pem.nil?
29
+ socket = TCPSocket.new(Config.host || 'localhost', Config.port.to_i || 22195)
30
+ socket.write(to_bytes)
31
+ socket.close
32
+ else
33
+ client = Racoon::Client.new(Config.pem, Config.host || 'gateway.push.apple.com', Config.port.to_i || 2195)
34
+ client.connect!
35
+ client.write(self)
36
+ client.disconnect!
37
+ end
38
+ end
39
+ =end
40
+
41
+ def to_bytes
42
+ j = json_payload
43
+ [0, 0, device_token.size, device_token, 0, j.size, j].pack("ccca*cca*")
44
+ end
45
+
46
+ def self.valid?(p)
47
+ begin
48
+ Notification.parse(p)
49
+ rescue PayloadInvalid => p
50
+ Config.logger.error "PayloadInvalid: #{p}"
51
+ false
52
+ rescue RuntimeError => r
53
+ Config.logger.error "Runtime error: #{r}"
54
+ false
55
+ rescue Exception => e
56
+ Config.logger.error "Unknown error: #{e}"
57
+ false
58
+ end
59
+ end
60
+
61
+ def self.parse(p)
62
+ buffer = p.dup
63
+ notification = Notification.new
64
+
65
+ header = buffer.slice!(0, 3).unpack('ccc')
66
+ if header[0] != 0
67
+ raise RuntimeError.new("Header of notification is invalid: #{header.inspect}")
68
+ end
69
+
70
+ # parse token
71
+ notification.device_token = buffer.slice!(0, 32).unpack('a*').first
72
+
73
+ # parse json payload
74
+ payload_len = buffer.slice!(0, 2).unpack('CC')
75
+ j = buffer.slice!(0, payload_len.last)
76
+ result = Yajl::Parser.parse(j)
77
+
78
+ ['alert', 'badge', 'sound'].each do |k|
79
+ notification.send("#{k}=", result['aps'][k]) if result['aps'] && result['aps'][k]
80
+ end
81
+ result.delete('aps')
82
+ notification.custom = result
83
+
84
+ notification
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,19 @@
1
+ module Racoon
2
+ module Payload
3
+ PayloadInvalid = Class.new(RuntimeError)
4
+
5
+ def create_payload(payload)
6
+ case payload
7
+ when String then { :aps => { :alert => payload } }
8
+ when Hash then create_payload_from_hash(payload)
9
+ end
10
+ end
11
+
12
+ def create_payload_from_hash(payload)
13
+ custom = payload.delete(:custom)
14
+ aps = {:aps => payload }
15
+ aps.merge!(custom) if custom
16
+ aps
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,160 @@
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
+ %w{watch use}.each { |s| @beanstalks[tube].send(s, "racoon-#{tube}") }
19
+ @beanstalks[tube].ignore('default')
20
+ @beanstalks[tube]
21
+ end
22
+
23
+ def start!
24
+ EventMachine::run do
25
+ EventMachine::PeriodicTimer.new(3600) do
26
+ begin
27
+ if beanstalk('feedback').peek_ready
28
+ item = beanstalk('feedback').reserve(1)
29
+ handle_feedback(item)
30
+ end
31
+ rescue Beanstalk::TimedOut
32
+ Config.logger.info "(Beanstalkd) Unable to secure a job, operation timed out."
33
+ end
34
+ end
35
+
36
+ EventMachine::PeriodicTimer.new(60) do
37
+ begin
38
+ if beanstalk('killer').peek_ready
39
+ item = beanstalk('killer').reserve(1)
40
+ purge_client(item)
41
+ end
42
+ rescue Beanstalk::TimedOut
43
+ Config.logger.info "(Beanstalkd) Unable to secure a job, operation timed out."
44
+ end
45
+ end
46
+
47
+ EventMachine::PeriodicTimer.new(1) do
48
+ begin
49
+ if beanstalk('apns').peek_ready
50
+ item = beanstalk('apns').reserve(1)
51
+ handle_job item
52
+ end
53
+ rescue Beanstalk::TimedOut
54
+ Config.logger.info "(Beanstalkd) Unable to secure a job, operation timed out."
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ # Received a notification. job is YAML encoded hash in the following format:
63
+ # job = {
64
+ # :project => {
65
+ # :name => "Foo",
66
+ # :certificate => "contents of a certificate.pem"
67
+ # },
68
+ # :device_token => "0f21ab...def",
69
+ # :notification => notification.json_payload,
70
+ # :sandbox => true # Development environment?
71
+ # }
72
+ def handle_job(job)
73
+ packet = job.ybody
74
+ project = packet[:project]
75
+
76
+ aps = packet[:notification][:aps]
77
+
78
+ notification = Notification.new
79
+ notification.device_token = packet[:device_token]
80
+ notification.badge = aps[:badge] if aps.has_key? :badge
81
+ notification.alert = aps[:alert] if aps.has_key? :alert
82
+ notification.sound = aps[:sound] if aps.has_key? :sound
83
+ notification.custom = aps[:custom] if aps.has_key? :custom
84
+
85
+ if notification
86
+ client = get_client(project[:name], project[:certificate], packet[:sandbox])
87
+ begin
88
+ client.connect! unless client.connected?
89
+ client.write(notification)
90
+
91
+ job.delete
92
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
93
+ Config.logger.error "Caught Error, closing connecting and adding notification back to queue"
94
+
95
+ client.disconnect!
96
+
97
+ # Queue back up the notification
98
+ job.release
99
+ rescue RuntimeError => e
100
+ Config.logger.error "Unable to handle: #{e}"
101
+
102
+ job.delete
103
+ end
104
+ end
105
+ end
106
+
107
+ # Will be a hash with two keys:
108
+ # :certificate and :sandbox.
109
+ def handle_feedback(job)
110
+ begin
111
+ packet = job.ybody
112
+ uri = "feedback.#{packet[:sandbox] ? 'sandbox.' : ''}push.apple.com"
113
+ feedback_client = Racoon::FeedbackClient.new(packet[:certificate], uri)
114
+ feedback_client.connect!
115
+ feedback_client.read.each do |record|
116
+ feedback_callback.call record
117
+ end
118
+ feedback_client.disconnect!
119
+ job.delete
120
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
121
+ Config.logger.error "(Feedback) Caught Error, closing connection"
122
+ feedback_client.disconnect!
123
+ job.release
124
+ rescue RuntimeError => e
125
+ Config.logger.error "(Feedback) Unable to handle: #{e}"
126
+ job.delete
127
+ end
128
+ end
129
+
130
+ def get_client(project_name, certificate, sandbox = false)
131
+ uri = "gateway.#{sandbox ? 'sandbox.' : ''}push.apple.com"
132
+ unless @clients[project_name]
133
+ @clients[project_name] = Racoon::Client.new(certificate, uri)
134
+ # in 18 hours (64800 seconds) we need to schedule this socket to be killed. Long opened
135
+ # sockets don't work.
136
+ beanstalk('killer').yput({:certificate => certificate, :sandbox => sandbox}, 65536, 64800)
137
+ end
138
+ @clients[project_name] ||= Racoon::Client.new(certificate, uri)
139
+ client = @clients[project_name]
140
+
141
+ # If the certificate has changed, but we still are connected using the old certificate,
142
+ # disconnect and reconnect.
143
+ unless client.pem.eql?(certificate)
144
+ client.disconnect! if client.connected?
145
+ @clients[project_name] = Racoon::Client.new(certificate, uri)
146
+ client = @clients[project_name]
147
+ end
148
+
149
+ client
150
+ end
151
+
152
+ def purge_client(job)
153
+ project_name = job.ybody
154
+ client = @clients[project_name]
155
+ client.disconnect! if client
156
+ @clients[project_name] = nil
157
+ job.delete
158
+ end
159
+ end
160
+ end
data/lib/racoon.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'logger'
2
+ require 'racoon/config'
3
+ require 'racoon/payload'
4
+ require 'racoon/notification'
5
+ require 'racoon/client'
6
+ require 'racoon/feedback_client'
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ module ApnServer
4
+ describe Client do
5
+ describe "#new" do
6
+ let(:client) { ApnServer::Client.new('cert.pem', 'gateway.sandbox.push.apple.com', 2196) }
7
+
8
+ it "sets the pem path" do
9
+ client.pem.should == 'cert.pem'
10
+ end
11
+
12
+ it "sets the host" do
13
+ client.host.should == 'gateway.sandbox.push.apple.com'
14
+ end
15
+
16
+ it "sets the port" do
17
+ client.port.should == 2196
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ require 'spec_helper'
2
+
3
+ module ApnServer
4
+ describe FeedbackClient do
5
+ describe "#new" do
6
+ let(:feedback_client) { ApnServer::FeedbackClient.new('cert.pem', 'feedback.sandbox.push.apple.com', 2196) }
7
+
8
+ it "sets the pem path" do
9
+ feedback_client.pem.should == 'cert.pem'
10
+ end
11
+
12
+ it "sets the host" do
13
+ feedback_client.host.should == 'feedback.sandbox.push.apple.com'
14
+ end
15
+
16
+ it "sets the port" do
17
+ feedback_client.port.should == 2196
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ module ApnServer
4
+ describe Notification do
5
+ let(:notification) { Notification.new }
6
+
7
+ describe "#to_bytes" do
8
+ it "generates a byte array" do
9
+ payload = '{"aps":{"alert":"You have not mail!"}}'
10
+ device_token = "12345678123456781234567812345678"
11
+ notification.device_token = device_token
12
+ notification.alert = "You have not mail!"
13
+ expected = [0, 0, device_token.size, device_token, 0, payload.size, payload]
14
+ notification.to_bytes.should == expected.pack("ccca*CCa*")
15
+ end
16
+ end
17
+
18
+ describe "#payload" do
19
+ it "generates the badge element" do
20
+ expected = { :aps => { :badge => 1 }}
21
+ notification.badge = 1
22
+ notification.payload.should == expected
23
+ end
24
+
25
+ it "generates the alert alement" do
26
+ expected = { :aps => { :alert => 'Hi' }}
27
+ notification.alert = 'Hi'
28
+ notification.payload.should == expected
29
+ end
30
+ end
31
+
32
+ describe "#json_payload" do
33
+ it "converts payload to json" do
34
+ expected = '{"aps":{"alert":"Hi"}}'
35
+ notification.alert = 'Hi'
36
+ notification.json_payload.should == expected
37
+ end
38
+
39
+ it "does not allow payloads larger than 256 chars" do
40
+ lambda {
41
+ alert = []
42
+ 256.times { alert << 'Hi' }
43
+ notification.alert = alert.join
44
+ notification.json_payload
45
+ }.should raise_error(Payload::PayloadInvalid)
46
+ end
47
+ end
48
+
49
+ describe "#valid?" do
50
+ it "recognizes a valid request" do
51
+ device_token = '12345678123456781234567812345678'
52
+ payload = '{"aps":{"alert":"You have not mail!"}}'
53
+ request = [0, 0, device_token.size, device_token, 0, payload.size, payload].pack("CCCa*CCa*")
54
+ Notification.valid?(request).should be_true
55
+ notification = Notification.parse(request)
56
+ notification.device_token.should == device_token
57
+ notification.alert.should == "You have not mail!"
58
+ end
59
+
60
+ it "recognizes an invalid request" do
61
+ device_token = '123456781234567812345678'
62
+ payload = '{"aps":{"alert":"You have not mail!"}}'
63
+ request = [0, 0, 32, device_token, 0, payload.size, payload].pack("CCCa*CCa*")
64
+ Notification.valid?(request).should be_false
65
+ end
66
+ end
67
+
68
+ describe "#parse" do
69
+ it "reads a byte array and constructs a notification" do
70
+ device_token = '12345678123456781234567812345678'
71
+ notification.device_token = device_token
72
+ notification.badge = 10
73
+ notification.alert = 'Hi'
74
+ notification.sound = 'default'
75
+ notification.custom = { 'acme1' => "bar", 'acme2' => 42}
76
+
77
+ parsed = Notification.parse(notification.to_bytes)
78
+ [:device_token, :badge, :alert, :sound, :custom].each do |k|
79
+ expected = notification.send(k)
80
+ parsed.send(k).should == expected
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,57 @@
1
+ require 'spec_helper'
2
+
3
+ module ApnServer
4
+ describe Payload do
5
+ describe "#payload.create_payload" do
6
+ let(:payload) { Class.new.send(:include, Payload).new }
7
+
8
+ it "creates a payload_with_simple_string" do
9
+ payload.create_payload('Hi').should == { :aps => { :alert => 'Hi' }}
10
+ end
11
+
12
+ it "creates a payload_with_alert_key" do
13
+ payload.create_payload(:alert => 'Hi').should == { :aps => { :alert => 'Hi' }}
14
+ end
15
+
16
+ it "creates payload with badge_and_alert" do
17
+ payload.create_payload(:alert => 'Hi', :badge => 1).should == { :aps => { :alert => 'Hi', :badge => 1 }}
18
+ end
19
+
20
+ # example 1
21
+ it "test_should_payload.create_payload_with_custom_payload" do
22
+ alert = 'Message received from Bob'
23
+ payload.create_payload(:alert => alert, :custom => { :acme2 => ['bang', 'whiz']}).should == {
24
+ :aps => { :alert => alert },
25
+ :acme2 => [ "bang", "whiz" ]
26
+ }
27
+ end
28
+
29
+ # example 3
30
+ it "test_should_payload.create_payload_with_sound_and_multiple_custom" do
31
+ expected = {
32
+ :aps => {
33
+ :alert => "You got your emails.",
34
+ :badge => 9,
35
+ :sound => "bingbong.aiff"
36
+ },
37
+ :acme1 => "bar",
38
+ :acme2 => 42
39
+ }
40
+ payload.create_payload({
41
+ :alert => "You got your emails.",
42
+ :badge => 9,
43
+ :sound => "bingbong.aiff",
44
+ :custom => { :acme1 => "bar", :acme2 => 42}
45
+ }).should == expected
46
+ end
47
+
48
+ # example 5
49
+ it "test_should_payload.create_payload_with_empty_aps" do
50
+ payload.create_payload(:custom => { :acme2 => [ 5, 8 ] }).should == {
51
+ :aps => {},
52
+ :acme2 => [ 5, 8 ]
53
+ }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ require 'rubygems'
2
+
3
+ # Set up gems listed in the Gemfile.
4
+ gemfile = File.expand_path('../Gemfile', File.dirname(__FILE__))
5
+ begin
6
+ ENV['BUNDLE_GEMFILE'] = gemfile
7
+ require 'bundler'
8
+ Bundler.setup
9
+ rescue Bundler::GemNotFound => e
10
+ STDERR.puts e.message
11
+ STDERR.puts "Try running `bundle install`."
12
+ exit!
13
+ end
14
+
15
+ Bundler.require(:spec)
16
+
17
+ Rspec.configure do |config|
18
+ config.mock_with :rspec
19
+ end
20
+ # Requires supporting files with custom matchers and macros, etc,
21
+ # in ./support/ and its subdirectories.
22
+
23
+ require "apnserver"
24
+ require 'base64'
25
+
26
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: racoon
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Jeremy Tregunna
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-04-23 00:00:00.000000000 -04:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: yajl-ruby
17
+ requirement: &70118651237540 !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ! '>='
21
+ - !ruby/object:Gem::Version
22
+ version: 0.7.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: *70118651237540
26
+ - !ruby/object:Gem::Dependency
27
+ name: beanstalk-client
28
+ requirement: &70118651237080 !ruby/object:Gem::Requirement
29
+ none: false
30
+ requirements:
31
+ - - ! '>='
32
+ - !ruby/object:Gem::Version
33
+ version: 1.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: *70118651237080
37
+ - !ruby/object:Gem::Dependency
38
+ name: bundler
39
+ requirement: &70118651236620 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ~>
43
+ - !ruby/object:Gem::Version
44
+ version: 1.0.0
45
+ type: :development
46
+ prerelease: false
47
+ version_requirements: *70118651236620
48
+ - !ruby/object:Gem::Dependency
49
+ name: eventmachine
50
+ requirement: &70118651236160 !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ! '>='
54
+ - !ruby/object:Gem::Version
55
+ version: 0.12.8
56
+ type: :development
57
+ prerelease: false
58
+ version_requirements: *70118651236160
59
+ description: A toolkit for proxying and sending Apple Push Notifications prepared
60
+ for a hosted environment
61
+ email: jeremy.tregunna@me.com
62
+ executables:
63
+ - racoond
64
+ extensions: []
65
+ extra_rdoc_files:
66
+ - README.mdown
67
+ files:
68
+ - bin/apnsend
69
+ - bin/apnserverd.fedora.init
70
+ - bin/apnserverd.ubuntu.init
71
+ - bin/racoond
72
+ - lib/racoon/client.rb
73
+ - lib/racoon/config.rb
74
+ - lib/racoon/feedback_client.rb
75
+ - lib/racoon/notification.rb
76
+ - lib/racoon/payload.rb
77
+ - lib/racoon/server.rb
78
+ - lib/racoon.rb
79
+ - README.mdown
80
+ - spec/models/client_spec.rb
81
+ - spec/models/feedback_client_spec.rb
82
+ - spec/models/notification_spec.rb
83
+ - spec/models/payload_spec.rb
84
+ - spec/spec_helper.rb
85
+ has_rdoc: true
86
+ homepage: https://github.com/jeremytregunna/racoon
87
+ licenses: []
88
+ post_install_message:
89
+ rdoc_options:
90
+ - --charset=UTF-8
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ none: false
95
+ requirements:
96
+ - - ! '>='
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ! '>='
103
+ - !ruby/object:Gem::Version
104
+ version: 1.3.6
105
+ requirements: []
106
+ rubyforge_project: racoon
107
+ rubygems_version: 1.6.2
108
+ signing_key:
109
+ specification_version: 3
110
+ summary: Apple Push Notification Toolkit for hosted environments
111
+ test_files:
112
+ - spec/models/client_spec.rb
113
+ - spec/models/feedback_client_spec.rb
114
+ - spec/models/notification_spec.rb
115
+ - spec/models/payload_spec.rb
116
+ - spec/spec_helper.rb