Skip to content

Commit

Permalink
feat: v0.7 (#11)
Browse files Browse the repository at this point in the history
* More flexible file location for Certs

* feat: implemented push retries with exponential backoff for APNS

* feat: load APNS cert/key from string contents of file

Also:
Removed exponential backoff due to weirdness APNS server responses.
If an SSL connection is taken offline for a brief while, `:ssl` will
automatically send the payload when available again. The APNS server
response gets captured asynchronously through APNSWorker, but because
there is no table of records for past messages, the response can't be
matched to a notification.

I will probably add this record tracking after the HTTP2 code gets migrated to
Chatterbox. For now any on_response functions just won't fire for
timed-out connections (which should be rare anyway.)

* chore: updated CHANGELOG

* chore: update README

* fix: typo in README
  • Loading branch information
hpopp committed Jun 2, 2016
1 parent 1018f9c commit 4b6ef1a
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 63 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## v0.7.0
* APNS cert/key configs can now either be a file path, full-text string, or `{:your_app, "path/to/file.pem"}` (which looks in the `/priv` directory of your app)
* Fixed APNSWorker crash on `:ssl.send/2` timeout
* Better error-handling for invalid APNS configs

## v0.6.0
* `Pigeon.APNS.Notification.new/3` returns `%Pigeon.APNS.Notification{}` struct
* Configure APNS to use SSL port 2197 with `apns_2197: true` in your `config.exs`
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Add pigeon as a `mix.exs` dependency:
**Note: Pigeon's API will likely change until v1.0**
```elixir
def deps do
[{:pigeon, "~> 0.6.0"}]
[{:pigeon, "~> 0.7.0"}]
end
```

Expand Down Expand Up @@ -69,6 +69,9 @@ When using `Pigeon.GCM.Notification.new/2`, `message_id` and `updated_registrati
apns_2197: true (optional)
```

`apns_cert` and `apns_key` can either be a static file path, full-text string of the file contents (for environment variables), or a tuple like `{:my_app, "certs/cert.pem"}`,
which will use a path relative to the `priv` folder of the given application.

2. Create a notification packet. **Note: Your push topic is generally the app's bundle identifier.**
```elixir
n = Pigeon.APNS.Notification.new("your message", "your device token", "your push topic")
Expand Down
124 changes: 79 additions & 45 deletions lib/pigeon/apns_worker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,86 +6,112 @@ defmodule Pigeon.APNSWorker do
use GenServer
require Logger

def start_link(name, mode, cert, key) do
Logger.debug "Starting #{name}\n\t mode: #{mode}, \
cert: #{cert}, key: #{key}"
GenServer.start_link(__MODULE__, {:ok, mode, cert, key}, name: name)
def start_link(name, config) do
GenServer.start_link(__MODULE__, {:ok, config}, name: name)
end

def stop do
:gen_server.cast(self, :stop)
end
def stop, do: :gen_server.cast(self, :stop)

def init({:ok, config}), do: initialize_worker(config)

def init({:ok, mode, cert, key}) do
case connect_socket(mode, cert, key, 0) do
def initialize_worker(config) do
mode = config[:mode]
case connect_socket(mode, config) do
{:ok, socket} ->
establish_connection(mode, cert, key, socket)
establish_connection(mode, config, socket)
{:error, :timeout} ->
Logger.error "Failed to establish SSL connection. \
Is the certificate signed for :#{mode} mode?"
Logger.error """
Failed to establish SSL connection. Is the certificate signed for :#{mode} mode?
"""
{:stop, {:error, :timeout}}
{:error, :invalid_config} ->
Logger.error """
Invalid configuration.
"""
{:stop, {:error, :invalid_config}}
end
end

def connect_socket(_mode, _cert, _key, 3), do: {:error, :timeout}
def connect_socket(mode, %{cert: cert, certfile: nil, key: key, keyfile: nil}),
do: connect_socket(mode, {:cert, cert}, {:key, key}, 0)
def connect_socket(mode, %{cert: nil, certfile: certfile, key: key, keyfile: nil}),
do: connect_socket(mode, {:certfile, certfile}, {:key, key}, 0)
def connect_socket(mode, %{cert: nil, certfile: certfile, key: nil, keyfile: keyfile}),
do: connect_socket(mode, {:certfile, certfile}, {:keyfile, keyfile}, 0)
def connect_socket(mode, %{cert: cert, certfile: nil, key: nil, keyfile: keyfile}),
do: connect_socket(mode, {:cert, cert}, {:keyfile, keyfile}, 0)
def connect_socket(_mode, _), do: {:error, :invalid_config}

def connect_socket(_mode, _config, 3), do: {:error, :timeout}
def connect_socket(mode, cert, key, tries) do
case HTTP2.connect(mode, cert, key) do
{:ok, socket} ->
{:ok, socket}
{:error, :timeout} ->
connect_socket(mode, cert, key, tries + 1)
{:ok, socket} -> {:ok, socket}
{:error, _} -> connect_socket(mode, cert, key, tries + 1)
end
end

def establish_connection(mode, cert, key, socket) do
def establish_connection(mode, config, socket) do
state = %{
apns_socket: socket,
mode: mode,
cert: cert,
key: key,
config: config,
stream_id: 1
}
HTTP2.send_connection_preface(socket)
HTTP2.establish_connection(socket)
{:ok, state}
end

def handle_cast(:stop, state) do
{ :noreply, state }
end
def handle_cast(:stop, state), do: { :noreply, state }

def handle_cast({:push, :apns, notification}, state) do
%{stream_id: stream_id} = state
send_push(state, notification, nil)
data = package_push(state, notification)
send_push(data, state, notification, nil)
{ :noreply, %{state | stream_id: stream_id + 2 } }
end

def handle_cast({:push, :apns, notification, on_response}, state) do
%{stream_id: stream_id} = state
send_push(state, notification, on_response)
data = package_push(state, notification)
send_push(data, state, notification, on_response)
{ :noreply, %{state | stream_id: stream_id + 2 } }
end

def send_push(state, notification, on_response) do
def package_push(state, notification) do
%{apns_socket: socket, mode: mode, stream_id: stream_id} = state

json = Pigeon.Notification.json_payload(notification.payload)
push_header = HTTP2.push_header_frame(stream_id, mode, notification)
push_data = HTTP2.push_data_frame(stream_id, json)
{push_header, push_data}
end

def send_push({push_header, push_data}, state, notification, on_response) do
%{apns_socket: socket, mode: mode, stream_id: stream_id} = state
:ssl.send(socket, push_header)
:ssl.send(socket, push_data)

{:ok, _headers, payload} = HTTP2.wait_response socket
case HTTP2.wait_response socket do
{:ok, headers, payload} ->
process_response(payload, socket, notification, on_response)
error -> error
end
end

defp process_response(payload, socket, notification, on_response) do
case HTTP2.status_code(payload) do
200 ->
unless on_response == nil do on_response.({:ok, notification}) end
_error ->
{:ok, data_headers, data_payload} = HTTP2.wait_response socket
reason = parse_error(data_payload)
log_error(reason, notification)
unless on_response == nil do on_response.({:error, reason, notification}) end
case HTTP2.wait_response socket do
{:ok, _data_headers, data_payload} ->
reason = parse_error(data_payload)
log_error(reason, notification)
unless on_response == nil do on_response.({:error, reason, notification}) end
_ ->
{:error, :timeout}
end
end
end

Expand Down Expand Up @@ -115,11 +141,15 @@ defmodule Pigeon.APNSWorker do
:bad_priority ->
"The apns-priority value is bad."
:missing_device_token ->
"The device token is not specified in the request :path. Verify that the :path header \
contains the device token."
"""
The device token is not specified in the request :path. Verify that the :path header
contains the device token.
"""
:bad_device_token ->
"The specified device token was bad. Verify that the request contains a valid token and \
that the token matches the environment."
"""
The specified device token was bad. Verify that the request contains a valid token and
that the token matches the environment.
"""
:device_token_not_for_topic ->
"The device token does not match the specified topic."
:unregistered ->
Expand Down Expand Up @@ -147,20 +177,24 @@ defmodule Pigeon.APNSWorker do
:service_unavailable ->
"The service is unavailable."
:missing_topic ->
"The apns-topic header of the request was not specified and was required. The apns-topic \
header is mandatory when the client is connected using a certificate that supports \
multiple topics."
"""
The apns-topic header of the request was not specified and was required. The apns-topic
header is mandatory when the client is connected using a certificate that supports
multiple topics.
"""
:timeout ->
"The SSL connection timed out."
end
end

def handle_info({:ssl, socket, bin}, state) do
Logger.debug("Recv SSL data: #{inspect(bin)}")
{:noreply, state}
end

def handle_info({:ssl_closed, _socket}, state) do
%{apns_socket: _socket, mode: mode, cert: cert, key: key} = state
%{config: config} = state
Logger.debug("Got connection close...")

{:ok, sock} = HTTP2.connect(mode, cert, key)
{:ok, _data} = HTTP2.send_connection_preface(sock)
HTTP2.establish_connection(sock)

{:noreply, %{state | apns_socket: sock}}
initialize_worker(config)
end
end
21 changes: 18 additions & 3 deletions lib/pigeon/http2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ defmodule Pigeon.HTTP2 do

def connect(mode, cert, key) do
uri = mode |> push_uri |> to_char_list
options = [{:certfile, cert},
{:keyfile, key},
options = [cert,
key,
{:password, ''},
{:packet, 0},
{:reuseaddr, false},
Expand Down Expand Up @@ -97,7 +97,9 @@ defmodule Pigeon.HTTP2 do
{:ssl, socket, bin} ->
case wait_payload(socket) do
{:ok, payload} ->
{:ok, bin, payload}
{header, data} = order_data(bin, payload)
{header, data} |> inspect |> Logger.debug
{:ok, header, data}
error ->
error
end
Expand Down Expand Up @@ -125,6 +127,19 @@ defmodule Pigeon.HTTP2 do
end
end

def order_data(first, second) do
cond do
header_frame?(first) -> {first, second}
header_frame?(second) -> {second, first}
true -> {first, second}
end
end

defp header_frame?(bin) do
frame = parse_frame(bin)
frame[:frame_type] == 0x1
end

def parse_frame_type(frame, bin) do
case frame[:frame_type] do
0x0 ->
Expand Down
77 changes: 64 additions & 13 deletions lib/pigeon/supervisor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,77 @@ defmodule Pigeon.Supervisor do

def init(:ok) do
children = []
config = ssl_config

if valid_apns_config? do
apns_mode = Application.get_env(:pigeon, :apns_mode)
apns_cert = Application.get_env(:pigeon, :apns_cert)
apns_key = Application.get_env(:pigeon, :apns_key)
apns = worker(Pigeon.APNSWorker,
[:apns_worker, apns_mode, apns_cert, apns_key],
id: :apns_worker)
if apns_keys? do
if valid_apns_config?(config) do
apns = worker(Pigeon.APNSWorker,
[:apns_worker, config],
id: :apns_worker)

children = [apns]
children = [apns]
else
Logger.error "Error starting :apns_worker. Invalid mode/cert/key configuration."
end
end

supervise(children, strategy: :one_for_one)
end

def valid_apns_config? do
apns_mode = Application.get_env(:pigeon, :apns_mode)
apns_cert = Application.get_env(:pigeon, :apns_cert)
apns_key = Application.get_env(:pigeon, :apns_key)
!is_nil(apns_mode) && !is_nil(apns_cert) && !is_nil(apns_key)
defp config_mode, do: Application.get_env(:pigeon, :apns_mode)
defp config_cert, do: Application.get_env(:pigeon, :apns_cert)
defp config_key, do: Application.get_env(:pigeon, :apns_key)

def ssl_config do
%{
mode: config_mode,
cert: cert(config_cert),
certfile: file_path(config_cert),
key: key(config_key),
keyfile: file_path(config_key)
}
end

defp file_path(nil), do: nil
defp file_path(path) when is_binary(path) do
cond do
:filelib.is_file(path) -> Path.expand(path)
true -> nil
end
end
defp file_path({app_name, path}) when is_atom(app_name),
do: Path.expand(path, :code.priv_dir(app_name))

defp cert({_app_name, _path}), do: nil
defp cert(nil), do: nil
defp cert(bin) do
case :public_key.pem_decode(bin) do
[{:Certificate, cert, _}] -> cert
_ -> nil
end
end

defp key({_app_name, _path}), do: nil
defp key(nil), do: nil
defp key(bin) do
case :public_key.pem_decode(bin) do
[{:RSAPrivateKey, key, _}] -> {:RSAPrivateKey, key}
_ -> nil
end
end

def apns_keys? do
mode = Application.get_env(:pigeon, :apns_mode)
cert = Application.get_env(:pigeon, :apns_cert)
key = Application.get_env(:pigeon, :apns_key)
!is_nil(mode) && !is_nil(cert) && !is_nil(key)
end

def valid_apns_config?(config) do
valid_mode? = (config[:mode] == :dev || config[:mode] == :prod)
valid_cert? = !is_nil(config[:cert] || config[:certfile])
valid_key? = !is_nil(config[:key] || config[:keyfile])
valid_mode? && valid_cert? && valid_key?
end

def push(service, notification) do
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Pigeon.Mixfile do
def project do
[app: :pigeon,
name: "Pigeon",
version: "0.6.0",
version: "0.7.0",
elixir: "~> 1.2",
source_url: "https://github.com/codedge-llc/pigeon",
description: description,
Expand Down

0 comments on commit 4b6ef1a

Please sign in to comment.