mwotton-apnd 0.1.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,195 @@
1
+ require 'daemons'
2
+ require 'optparse'
3
+
4
+ module APND
5
+ class CLI #:nodoc: all
6
+
7
+ #
8
+ # Run apnd push
9
+ #
10
+ def self.push(argv)
11
+ help = <<-HELP
12
+ Usage:
13
+ apnd push [OPTIONS] --token <token>
14
+
15
+ HELP
16
+
17
+ options = {}
18
+
19
+ opts = OptionParser.new do |opt|
20
+ opt.banner = help
21
+
22
+ opt.separator "Required Arguments:\n"
23
+
24
+ opt.on('--token [TOKEN]', "Set Notification's iPhone token to TOKEN") do |token|
25
+ options[:token] = token
26
+ end
27
+
28
+ opt.separator "\nOptional Arguments:\n"
29
+
30
+ opt.on('--alert [MESSAGE]', "Set Notification's alert to MESSAGE") do |alert|
31
+ options[:alert] = alert
32
+ end
33
+
34
+ opt.on('--sound [SOUND]', "Set Notification's sound to SOUND") do |sound|
35
+ options[:sound] = sound
36
+ end
37
+
38
+ opt.on('--badge [NUMBER]', "Set Notification's badge number to NUMBER") do |badge|
39
+ options[:badge] = badge.to_i
40
+ end
41
+
42
+ opt.on('--custom [JSON]', "Set Notification's custom data to JSON") do |custom|
43
+ begin
44
+ options[:custom] = JSON.parse(custom)
45
+ rescue JSON::ParserError => e
46
+ puts "Invalid JSON: #{e}"
47
+ exit -1
48
+ end
49
+ end
50
+
51
+ opt.on('--host [HOST]', "Send Notification to HOST, usually the one running APND (default is 'localhost')") do |host|
52
+ options[:host] = host
53
+ end
54
+
55
+ opt.on('--port [PORT]', 'Send Notification on PORT (default is 22195)') do |port|
56
+ options[:port] = port.to_i
57
+ end
58
+
59
+ opt.separator "\nHelp:\n"
60
+
61
+ opt.on('--help', 'Show this message') do
62
+ puts opt
63
+ exit
64
+ end
65
+ end
66
+
67
+ begin
68
+ opts.parse!
69
+ if options.empty?
70
+ puts opts
71
+ exit
72
+ end
73
+
74
+ unless options[:token]
75
+ raise OptionParser::MissingArgument, "must specify --token"
76
+ end
77
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument
78
+ puts "#{$0}: #{$!.message}"
79
+ puts "#{$0}: try '#{$0} --help' for more information"
80
+ exit
81
+ end
82
+
83
+ # Configure Notification upstream host/port
84
+ APND::Notification.upstream_host = options.delete(:host) if options[:host]
85
+ APND::Notification.upstream_port = options.delete(:port) if options[:port]
86
+
87
+ APND::Notification.create(options)
88
+ end
89
+
90
+ #
91
+ # Run apnd daemon
92
+ #
93
+ def self.daemon(argv)
94
+ help = <<-HELP
95
+ Usage:
96
+ apnd daemon --apple-cert </path/to/cert>
97
+
98
+ HELP
99
+
100
+ options = {}
101
+
102
+ opts = OptionParser.new do |opt|
103
+ opt.banner = help
104
+
105
+ opt.separator "Required Arguments:\n"
106
+
107
+ opt.on('--apple-cert [PATH]', 'PATH to APN certificate from Apple') do |cert|
108
+ options[:apple_cert] = cert
109
+ end
110
+
111
+ opt.separator "\nOptional Arguments:\n"
112
+
113
+ opt.on('--apple-host [HOST]', "Connect to Apple at HOST (default is gateway.sandbox.push.apple.com)") do |host|
114
+ options[:apple_host] = host
115
+ end
116
+
117
+ opt.on('--apple-port [PORT]', 'Connect to Apple on PORT (default is 2195)') do |port|
118
+ options[:apple_port] = port.to_i
119
+ end
120
+
121
+ opt.on('--apple-cert-pass [PASSWORD]', 'PASSWORD for APN certificate from Apple') do |pass|
122
+ options[:apple_cert_pass] = pass
123
+ end
124
+
125
+ opt.on('--daemon-port [PORT]', 'Run APND on PORT (default is 22195)') do |port|
126
+ options[:daemon_port] = port.to_i
127
+ end
128
+
129
+ opt.on('--daemon-bind [ADDRESS]', 'Bind APND to ADDRESS (default is 0.0.0.0)') do |bind|
130
+ options[:daemon_bind] = bind
131
+ end
132
+
133
+ opt.on('--daemon-log-file [PATH]', 'PATH to APND log file (default is /var/log/apnd.log)') do |log|
134
+ options[:daemon_log_file] = log
135
+ end
136
+
137
+ opt.on('--daemon-timer [SECONDS]', 'Set APND queue refresh time to SECONDS (default is 30)') do |seconds|
138
+ options[:daemon_timer] = seconds.to_i
139
+ end
140
+
141
+ opt.on('--foreground', 'Run APND in foreground without daemonizing') do
142
+ options[:foreground] = true
143
+ end
144
+
145
+ opt.separator "\nHelp:\n"
146
+
147
+ opt.on('--help', 'Show this message') do
148
+ puts opt
149
+ exit
150
+ end
151
+ end
152
+
153
+ begin
154
+ opts.parse!
155
+ if options.empty?
156
+ puts opts
157
+ exit
158
+ end
159
+
160
+ unless options[:apple_cert]
161
+ raise OptionParser::MissingArgument, "must specify --apple-cert"
162
+ end
163
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument
164
+ puts "#{$0}: #{$!.message}"
165
+ puts "#{$0}: try '#{$0} --help' for more information"
166
+ exit
167
+ end
168
+
169
+ APND.configure do |config|
170
+ # Setup AppleConnection
171
+ config.apple.cert = options[:apple_cert] if options[:apple_cert]
172
+ config.apple.cert_pass = options[:apple_cert_pass] if options[:apple_cert_pass]
173
+ config.apple.host = options[:apple_host] if options[:apple_host]
174
+ config.apple.port = options[:apple_port] if options[:apple_port]
175
+
176
+ # Setup Daemon
177
+ config.daemon.bind = options[:daemon_bind] if options[:daemon_bind]
178
+ config.daemon.port = options[:daemon_port] if options[:daemon_port]
179
+ config.daemon.log_file = options[:daemon_log_file] if options[:daemon_log_file]
180
+ config.daemon.timer = options[:daemon_timer] if options[:daemon_timer]
181
+ end
182
+
183
+ if APND.settings.apple.cert.nil?
184
+ puts opts
185
+ exit
186
+ else
187
+ unless options[:foreground]
188
+ Daemonize.daemonize(APND.settings.daemon.log_file, 'apnd')
189
+ end
190
+ APND::Daemon.run!
191
+ end
192
+
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,78 @@
1
+ require 'eventmachine'
2
+
3
+ module APND
4
+ #
5
+ # The APND::Daemon maintains a persistent secure connection with Apple,
6
+ # (APND::Daemon::AppleConnection). Notifications are queued and periodically
7
+ # writen to the AppleConnection
8
+ #
9
+ class Daemon
10
+ autoload :Protocol, 'apnd/daemon/protocol'
11
+ autoload :AppleConnection, 'apnd/daemon/apple_connection'
12
+ autoload :ServerConnection, 'apnd/daemon/server_connection'
13
+
14
+ #
15
+ # Create a new Daemon and run it
16
+ #
17
+ def self.run!
18
+ server = APND::Daemon.new
19
+ server.run!
20
+ end
21
+
22
+ #
23
+ # Create a connection to Apple and a new EM queue
24
+ #
25
+ def initialize
26
+ @queue = EM::Queue.new
27
+ @apple = APND::Daemon::AppleConnection.new
28
+ @bind = APND.settings.daemon.bind
29
+ @port = APND.settings.daemon.port
30
+ @timer = APND.settings.daemon.timer
31
+ end
32
+
33
+ #
34
+ # Run the daemon
35
+ #
36
+ def run!
37
+ EventMachine::run do
38
+ APND.logger "Starting APND Daemon v#{APND::Version} on #{@bind}:#{@port}"
39
+ EventMachine::start_server(@bind, @port, APND::Daemon::ServerConnection) do |server|
40
+ server.queue = @queue
41
+ end
42
+
43
+ EventMachine::PeriodicTimer.new(@timer) do
44
+ process_notifications!
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ #
52
+ # Sends each notification in the queue upstream to Apple
53
+ #
54
+ def process_notifications!
55
+ count = @queue.size
56
+ if count > 0
57
+ APND.logger "Queue has #{count} item#{count == 1 ? '' : 's'}"
58
+ @apple.connect!
59
+ count.times do
60
+ @queue.pop do |notification|
61
+ begin
62
+ APND.logger "Sending notification for #{notification.token}"
63
+ @apple.write(notification.to_bytes)
64
+ rescue Errno::EPIPE, OpenSSL::SSL::SSLError
65
+ APND.logger "Error, notification has been added back to the queue"
66
+ @queue.push(notification)
67
+ @apple.reconnect!
68
+ rescue RuntimeError => error
69
+ APND.logger "Error: #{error}"
70
+ end
71
+ end
72
+ end
73
+ @apple.disconnect!
74
+ end
75
+ end
76
+
77
+ end
78
+ end
@@ -0,0 +1,84 @@
1
+ require 'openssl'
2
+ require 'socket'
3
+
4
+ module APND
5
+ #
6
+ # Daemon::AppleConnection handles the persistent connection between
7
+ # APND and Apple
8
+ #
9
+ class Daemon::AppleConnection
10
+ attr_reader :ssl, :sock
11
+
12
+ #
13
+ # Setup a new connection
14
+ #
15
+ def initialize(params = {})
16
+ @options = {
17
+ :cert => APND.settings.apple.cert,
18
+ :cert_pass => APND.settings.apple.cert_pass,
19
+ :host => APND.settings.apple.host,
20
+ :port => APND.settings.apple.port.to_i
21
+ }.merge(params)
22
+ end
23
+
24
+ #
25
+ # Returns true if the connection to Apple is open
26
+ #
27
+ def connected?
28
+ ! @ssl.nil?
29
+ end
30
+
31
+ #
32
+ # Connect to Apple over SSL
33
+ #
34
+ def connect!
35
+ cert = File.read(@options[:cert])
36
+ context = OpenSSL::SSL::SSLContext.new
37
+ context.key = OpenSSL::PKey::RSA.new(cert, @options[:cert_pass])
38
+ context.cert = OpenSSL::X509::Certificate.new(cert)
39
+
40
+ @sock = TCPSocket.new(@options[:host], @options[:port])
41
+ @ssl = OpenSSL::SSL::SSLSocket.new(@sock, context)
42
+ @ssl.sync = true
43
+ @ssl.connect
44
+ end
45
+
46
+ #
47
+ # Close connection
48
+ #
49
+ def disconnect!
50
+ @ssl.close
51
+ @sock.close
52
+ @ssl = nil
53
+ @sock = nil
54
+ end
55
+
56
+ #
57
+ # Disconnect/connect to Apple
58
+ #
59
+ def reconnect!
60
+ disconnect!
61
+ connect!
62
+ end
63
+
64
+ #
65
+ # Establishes a connection if needed and yields it
66
+ #
67
+ # Ex: open { |conn| conn.write('write to socket') }
68
+ #
69
+ def open(&block)
70
+ unless connected?
71
+ connect!
72
+ end
73
+
74
+ yield @ssl
75
+ end
76
+
77
+ #
78
+ # Write to the connection socket
79
+ #
80
+ def write(raw)
81
+ open { |conn| conn.write(raw) }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,54 @@
1
+ require 'socket'
2
+
3
+ module APND
4
+ #
5
+ # Daemon::Protocol handles incoming APNs
6
+ #
7
+ module Daemon::Protocol
8
+
9
+ #
10
+ # Called when a client connection is opened
11
+ #
12
+ def post_init
13
+ @address = ::Socket.unpack_sockaddr_in(self.get_peername)
14
+ APND.logger "#{@address.last}:#{@address.first} opened connection"
15
+ end
16
+
17
+ #
18
+ # Called when a client connection is closed
19
+ #
20
+ # Checks @buffer for any pending notifications to be
21
+ # queued
22
+ #
23
+ def unbind
24
+ # totally broken.
25
+ @buffer.chomp!
26
+ while(@buffer.length > 0) do
27
+ # 3 bytes for header
28
+ # 32 bytes for token
29
+ # 2 bytes for json length
30
+
31
+ # taking the last is acceptable because we know it's never
32
+ # longer than 256 bytes from the apple documentation.
33
+ json_length = @buffer.slice(35,37).unpack('CC').last
34
+ chunk = @buffer.slice!(0,json_length + 3 + 32 + 2)
35
+ if notification = APND::Notification.valid?(chunk)
36
+ APND.logger "#{@address.last}:#{@address.first} added new Notification to queue"
37
+ queue.push(notification)
38
+ else
39
+ APND.logger "#{@address.last}:#{@address.first} submitted invalid Notification"
40
+ end
41
+ @buffer.strip!
42
+ end
43
+ APND.logger "#{@address.last}:#{@address.first} closed connection"
44
+ end
45
+
46
+ #
47
+ # Add incoming notification(s) to @buffer
48
+ #
49
+ def receive_data(data)
50
+ APND.logger "#{@address.last}:#{@address.first} buffering data"
51
+ (@buffer ||= "") << data
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,15 @@
1
+ module APND
2
+ #
3
+ # Daemon::ServerConnection links APND::Daemon::Protocol to EM
4
+ #
5
+ class Daemon::ServerConnection < ::EventMachine::Connection
6
+
7
+ include APND::Daemon::Protocol
8
+
9
+ #
10
+ # Queue should be the EventMachine queue, see APND::Daemon
11
+ #
12
+ attr_accessor :queue
13
+
14
+ end
15
+ end
@@ -0,0 +1,22 @@
1
+ module APND
2
+ module Errors #:nodoc: all
3
+
4
+ #
5
+ # Raised if APN payload is larger than 256 bytes
6
+ #
7
+ class InvalidPayload < StandardError
8
+ def initialize(message)
9
+ super("Payload is larger than 256 bytes: '#{message}'")
10
+ end
11
+ end
12
+
13
+ #
14
+ # Raised when parsing a Notification with an invalid header
15
+ #
16
+ class InvalidNotificationHeader < StandardError
17
+ def initialize(header)
18
+ super("Invalid Notification header: #{header.inspect}")
19
+ end
20
+ end
21
+ end
22
+ end