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