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.
@@ -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