mwotton-apnd 0.1.8
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.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
|