diff --git a/Gemfile b/Gemfile index bf16f02..5c79005 100644 --- a/Gemfile +++ b/Gemfile @@ -2,9 +2,10 @@ 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 "fracassandra", ">= 0.3.4" group :spec do gem "rspec" -end \ No newline at end of file +end diff --git a/Gemfile.lock b/Gemfile.lock index 408394e..74704f3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,9 @@ GEM remote: http://rubygems.org/ specs: - activesupport (3.0.3) daemons (1.0.10) diff-lcs (1.1.2) eventmachine (0.12.10) - i18n (0.5.0) rspec (2.3.0) rspec-core (~> 2.3.0) rspec-expectations (~> 2.3.0) @@ -14,13 +12,13 @@ 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) daemons eventmachine - i18n rspec + yajl-ruby diff --git a/README.textile b/README.textile index 8546420..28587c9 100644 --- a/README.textile +++ b/README.textile @@ -1,54 +1,64 @@ -h1. Apple Push Notification Server Toolkit +h1. Apple Push Notification Server Toolkit for frac.as -* http://github.com/bpoweski/apnserver +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: -h2. Description +# It implements the APNS feedback service; +# Uses Yajl for JSON encoding/decoding rather than ActiveSupport; +# Expects certificates as strings instead of paths to files; +# Does not assume there is only one certificate; and +# Receives packets containing notifications from beanstalkd instead of a listening socket. -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. +The above changes were made because of the need for an APNS provider to replace the current +provider used by "Diligent Street":http://www.diligentstreet.com/ with something more robust. As such, it needs to be +suitable for a hosted environment, where multiple—unrelated—users of the service will be +using it. -h2. Remaining Tasks +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. - * Implement feedback service mechanism - * Implement robust notification sending in reactor periodic scheduler +h2. Description -h2. Issues Fixed +frac-apnserver is a server and a set of command line programs to send push notifications to iOS +devices. Apple recommends to maintain an open connection to the push notification service, and +refrain from opening up and tearing down SSL connections repeatedly. As such, a separate daemon +is introduced that has messages queued up (beanstalkd) for consumption by this daemon. This +decouples the APNS server from your backend system. Those notifications are sent over a +persistent connection to Apple. - * 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. Remaining Tasks & Issues -h2. APN Server Daemon +You can see progress by looking at the "Pivotal Tracker":https://www.pivotaltracker.com/projects/251991 page for fracas. Any labels related to +_apnserver_ are related to this subproject. + +h2. Preparing Certificates + +Certificates must be prepared before they can be used with frac-apnserver. 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:
-
-Usage: apnserverd [options] --pem /path/to/pem
- --bind-address bind address (defaults to 0.0.0.0)
- bind address of the server daemon
+
+ $ openssl pkcs12 -in cert.p12 -out cert.pem -nodes -clcerts
+
+
- --proxy-port port
- the port that the daemon will listen on (defaults to 22195)
+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.
- --server server
- APN Server (defaults to gateway.push.apple.com)
+h2. APN Server Daemon
- --port port of the APN Server
- APN server port (defaults to 2195)
+
+
+Usage: apnserverd [options] --beanstalk
+ --beanstalk
+ The comma-separated list of ip:port for beanstalk servers
- --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
+ --pid
+ Path used for the PID file. Defaults to /var/run/apnserver.pid
- --pem-passphrase passphrase
- The PEM passphrase to decode key.
+ --log
+ Path used for the log file. Defaults to /var/log/apnserver.log
--help
usage message
@@ -153,6 +163,7 @@ h2. License
(The MIT License)
Copyright (c) 2011 Ben Poweski
+Copyright (c) 2011 Jeremy Tregunna
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
diff --git a/apnserver.gemspec b/apnserver.gemspec
index e872c70..b466ddb 100644
--- a/apnserver.gemspec
+++ b/apnserver.gemspec
@@ -1,25 +1,26 @@
Gem::Specification.new do |s|
s.name = %q{apnserver}
- s.version = "0.2.1"
+ s.version = "0.3.0"
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.authors = ["Jeremy Tregunna"]
+ s.date = %q{2011-03-27}
+ s.description = %q{A toolkit for proxying and sending Apple Push Notifications prepared for a hosted environment}
+ s.email = %q{jeremy.tregunna@me.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.homepage = %q{https://github.com/jeremytregunna/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.summary = %q{Apple Push Notification Toolkit for hosted environments}
s.test_files = Dir.glob("spec/**/*")
s.required_rubygems_version = ">= 1.3.6"
- s.add_dependency 'activesupport', '~> 3.0.0'
+ s.add_dependency 'yajl-ruby', '>= 0.7.0'
+ s.add_dependency 'beanstalk-client', '>= 1.0.0'
s.add_development_dependency 'bundler', '~> 1.0.0'
s.add_development_dependency 'eventmachine', '>= 0.12.8'
end
diff --git a/bin/apnsend b/bin/apnsend
index 5e55e6c..f39ef97 100755
--- a/bin/apnsend
+++ b/bin/apnsend
@@ -49,7 +49,7 @@ opts.each do |opt, arg|
when '--port'
ApnServer::Config.port = arg.to_i
when '--pem'
- ApnServer::Config.pem = arg
+ ApnServer::Config.pem = File.read(arg)
when '--pem-passphrase'
ApnServer::Config.password = arg
when '--alert'
diff --git a/bin/apnserverd b/bin/apnserverd
index 82119ba..ed2a96b 100755
--- a/bin/apnserverd
+++ b/bin/apnserverd
@@ -4,16 +4,14 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'getoptlong'
require 'rubygems'
require 'daemons'
+require 'eventmachine'
require 'apnserver'
require 'apnserver/server'
+require 'csv'
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 "Usage: apnserverd [switches] --beanstalk a.b.c.d:11300,...,w.x.y.z:11300"
+ puts " --beanstalk <127.0.0.1:11300> csv list of ip:port for beanstalk servers"
puts " --pid Time.at(feedback[0]), :length => feedback[1], :device_token => feedback[2] }
+ end
+ end
+end
\ No newline at end of file
diff --git a/lib/apnserver/notification.rb b/lib/apnserver/notification.rb
index 1a66189..9fa4e03 100644
--- a/lib/apnserver/notification.rb
+++ b/lib/apnserver/notification.rb
@@ -1,12 +1,11 @@
require 'apnserver/payload'
require 'base64'
-require 'active_support/ordered_hash'
-require 'active_support/json'
+require 'yajl'
module ApnServer
class Config
class << self
- attr_accessor :host, :port, :pem, :password, :logger
+ attr_accessor :logger, :project_name, :project_beanstalk_pool, :project_pem_location, :project_use_sandbox
end
end
@@ -26,22 +25,33 @@ def payload
end
def json_payload
- j = ActiveSupport::JSON.encode(payload)
+ j = Yajl::Encoder.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
+ def push(b64_device_token)
+ safe_payload = self.payload
+
+ # Force conversion to lower 127 bits - non-ascii characters appear to
+ # be cause EXPECTED_CRLF. Haven't looked further into the issue. FIXME.
+ if safe_payload[:aps].has_key? :alert
+ converter = Iconv.new('ASCII//IGNORE//TRANSLIT', 'UTF-8')
+ safe_payload[:aps][:alert] = converter.iconv(safe_payload[:aps][:alert]).unpack('U*').select{ |cp| cp < 127 }.pack('U*')
+ end
+
+ job = {
+ :project => {
+ :name => Config.project_name,
+ :certificate => Config.project_pem_location,
+ },
+ :sandbox => Config.project_use_sandbox,
+ :notification => safe_payload,
+ :device_token => b64_device_token,
+ }
+
+ beanstalk = Beanstalk::Pool.new(Config.project_beanstalk_pool)
+ beanstalk.yput(job)
end
def to_bytes
@@ -55,9 +65,11 @@ def self.valid?(p)
rescue PayloadInvalid => p
Config.logger.error "PayloadInvalid: #{p}"
false
- rescue RuntimeError
+ rescue RuntimeError => r
+ Config.logger.error "Runtime error: #{r}"
false
rescue Exception => e
+ Config.logger.error "Unknown error: #{e}"
false
end
end
@@ -77,7 +89,7 @@ def self.parse(p)
# parse json payload
payload_len = buffer.slice!(0, 2).unpack('CC')
j = buffer.slice!(0, payload_len.last)
- result = ActiveSupport::JSON.decode(j)
+ result = Yajl::Parser.parse(j)
['alert', 'badge', 'sound'].each do |k|
notification.send("#{k}=", result['aps'][k]) if result['aps'] && result['aps'][k]
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
index 9a1d19f..cde6957 100644
--- a/lib/apnserver/server.rb
+++ b/lib/apnserver/server.rb
@@ -1,41 +1,161 @@
+require 'beanstalk-client'
+require 'yajl'
+require 'base64'
+
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
+ attr_accessor :beanstalkd_uris, :feedback_callback
+
+ def initialize(beanstalkd_uris = ["127.0.0.1:11300"], &feedback_blk)
+ @clients = {}
+ @feedback_callback = feedback_blk
+ @beanstalkd_uris = beanstalkd_uris
+ end
+
+ def beanstalk
+ @@beanstalk ||= Beanstalk::Pool.new @beanstalkd_uris
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(28800) do
+ #begin
+ #@feedback_client = nil # Until we pull in DB support
+ #@feedback_client.connect! unless @feedback_client.connected?
+ #@feedback_client.read.each do |record|
+ #feedback_callback.call record
+ #end
+ #@feedback_client.disconnect!
+ #rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
+ #Config.logger.error "(Feedback) Caught Error, closing connection"
+ #@feedback_client.disconnect!
+ #rescue RuntimeError => e
+ #Config.logger.error "(Feedback) Unable to handle: #{e}"
+ #end
+ #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
+ begin
+ if beanstalk.peek_ready
+ item = beanstalk.reserve(1)
+ handle_job item
end
+ rescue Beanstalk::TimedOut
+ Config.logger.info "(Beanstalkd) Unable to secure a job, operation timed out."
end
end
end
end
+
+ private
+
+ # Jobs should be posted as a YAML encoded hash in the following format:
+ #
+ # @job = {
+ # :project => {
+ # :name => "example",
+ # :certificate => "/path/to/pem.pem",
+ # },
+ # :sandbox => Rails.env.development?,
+ # :notification => notification.json_payload,
+ # :device_token => notification.device_token, # Base64 encoded
+ # }
+ #
+ # Receipt UUID's may be added in later.
+
+ def handle_job(job)
+ packet = job.ybody
+ project = packet[:project]
+
+ # Build new notification object from our hash
+ apn_data = packet[:notification][:aps]
+
+ notification = Notification.new
+ notification.device_token = Base64.decode64(packet[:device_token])
+
+ notification.badge = apn_data[:badge] if apn_data.has_key?(:badge)
+ notification.alert = apn_data[:alert] if apn_data.has_key?(:alert)
+ notification.sound = apn_data[:sound] if apn_data.has_key?(:sound)
+ notification.custom = apn_data[:custom] if apn_data.has_key?(:custom)
+
+ # TODO skip this file read unless necessary
+ certificate_data = File.read(project[:certificate])
+
+ if notification
+
+ client = get_client(project[:name], certificate_data, packet[:sandbox])
+
+ begin
+
+ Config.logger.debug "Connection already open" if client.connected?
+
+ # TODO - FIXME - WTF Does Apple want us to do? Persist or tear-down?
+ # We can't seem to keep a server instance running for more than 1 day
+ # when we try to persist. UGH!
+
+ # Old - allow socket to persist
+ #client.connect! unless client.connected?
+ #client.write(notification)
+ #job.delete
+
+ # New - create and tear down a socket for each notification
+ client.connect!
+ client.write(notification)
+ client.disconnect!
+
+ # Finished!
+ job.delete
+
+ Config.logger.info "Notification should've been deleted, keeping socket open."
+
+ # TODO: Find the receipt and update the sent_at property.
+ #if receipt = PushLog[packet[:receipt_uuid]]
+ # receipt.sent_at = Time.now.to_i.to_s
+ # receipt.save
+ #end
+
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError, Errno::ECONNRESET
+
+ Config.logger.error "Caught Error, closing connecting and adding notification back to queue."
+ client.disconnect! if client.connected?
+ # Queue back up the notification
+ job.release
+ Config.logger.info "Notification should've been released."
+
+ rescue RuntimeError => e
+
+ Config.logger.error "Unable to handle: #{e}"
+ # TODO: Find the receipt and write the failed_at property.
+ #if receipt = PushLog[packet[:receipt_uuid]]
+ # receipt.failed_at = Time.now.to_i.to_s
+ # receipt.save
+ #end
+ job.delete
+ end
+ else
+ Config.logger.error "Unable to create payload, deleting message."
+ job.delete
+ end
+ rescue Exception => e
+ Config.logger.error "#{$!} -- Printing Backtrace:\n #{e.backtrace.join "\n"}, deleting job"
+ job.delete
+ end
+
+ def get_client(project_name, certificate, sandbox = false)
+ uri = "gateway.#{sandbox ? 'sandbox.' : ''}push.apple.com"
+ @clients[project_name] ||= ApnServer::Client.new(certificate, uri)
+ client = @clients[project_name]
+
+ # If the certificate has changed, but we still are connected using the old certificate,
+ # disconnect and reconnect.
+ unless client.pem.eql?(certificate)
+ client.disconnect! if client.connected?
+ @clients[project_name] = ApnServer::Client.new(certificate, uri)
+ client = @clients[project_name]
+ end
+
+ client
+ 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/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