mwotton-apnd 0.1.8
Sign up to get free protection for your applications and to get access to all the features.
- data/README.markdown +193 -0
- data/Rakefile +31 -0
- data/bin/apnd +35 -0
- data/lib/apnd.rb +33 -0
- data/lib/apnd/#notification.rb# +193 -0
- data/lib/apnd/cli.rb +195 -0
- data/lib/apnd/daemon.rb +78 -0
- data/lib/apnd/daemon/apple_connection.rb +84 -0
- data/lib/apnd/daemon/protocol.rb +54 -0
- data/lib/apnd/daemon/server_connection.rb +15 -0
- data/lib/apnd/errors.rb +22 -0
- data/lib/apnd/feedback.rb +65 -0
- data/lib/apnd/notification.rb +189 -0
- data/lib/apnd/settings.rb +205 -0
- data/lib/apnd/version.rb +11 -0
- data/test/apnd_test.rb +104 -0
- data/test/test_helper.rb +27 -0
- metadata +144 -0
data/lib/apnd/cli.rb
ADDED
@@ -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
|
data/lib/apnd/daemon.rb
ADDED
@@ -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
|
data/lib/apnd/errors.rb
ADDED
@@ -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
|