Skip to content
This repository has been archived by the owner on Jul 13, 2023. It is now read-only.

Commit

Permalink
feat: Add multiple cert handlers for APNs
Browse files Browse the repository at this point in the history
This patch updates APNs handlers to accept platform based cert
configurations. See `configs/autopush_shared.ini.sample`. In addition,
this patch clarifies some argument references for routers (e.g. less
than useful `result` is now slightly more descriptive `uaid_data`)

Custom item names have been normalized to match gcm/fcm.
Document errors cleaned up a bit as well.

BREAKING CHANGE: the APNS configuration options have been altered, see
`configs/autopush_shared.ini.sample` for new APNS configuration
settings.

Closes #655
  • Loading branch information
jrconlin committed Sep 17, 2016
1 parent b1a7e2d commit 7eed1ff
Show file tree
Hide file tree
Showing 15 changed files with 336 additions and 177 deletions.
21 changes: 11 additions & 10 deletions autopush/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,10 +494,11 @@ def _token_valid(self, result):
d.addErrback(self._uaid_not_found_err)
self._db_error_handling(d)

def _uaid_lookup_results(self, result):
def _uaid_lookup_results(self, uaid_data):
"""Process the result of the AWS UAID lookup"""
# Save the whole record
router_key = self.router_key = result.get("router_type", "simplepush")
router_key = self.router_key = uaid_data.get("router_type",
"simplepush")
self._client_info["router_key"] = router_key

try:
Expand Down Expand Up @@ -563,7 +564,7 @@ def _uaid_lookup_results(self, result):
return

if use_simplepush:
self._route_notification(self.version, result, data)
self._route_notification(self.version, uaid_data, data)
return

# Web Push and bridged messages are encrypted binary blobs. We store
Expand All @@ -573,10 +574,10 @@ def _uaid_lookup_results(self, result):
# Generate a message ID, then route the notification.
d = deferToThread(self.ap_settings.fernet.encrypt, ':'.join([
'm', self.uaid, self.chid]).encode('utf8'))
d.addCallback(self._route_notification, result, data, ttl)
d.addCallback(self._route_notification, uaid_data, data, ttl)
return d

def _route_notification(self, version, result, data, ttl=None):
def _route_notification(self, version, uaid_data, data, ttl=None):
self.version = self._client_info['message_id'] = version
warning = ""
# Clean up the header values (remove padding)
Expand All @@ -592,8 +593,8 @@ def _route_notification(self, version, result, data, ttl=None):
ttl=ttl)

d = Deferred()
d.addCallback(self.router.route_notification, result)
d.addCallback(self._router_completed, result, warning)
d.addCallback(self.router.route_notification, uaid_data)
d.addCallback(self._router_completed, uaid_data, warning)
d.addErrback(self._router_fail_err)
d.addErrback(self._response_err)

Expand Down Expand Up @@ -704,7 +705,7 @@ def post(self, router_type="", router_token="", uaid="", chid=""):
if new_uaid:
d = Deferred()
d.addCallback(router.register, router_data=params,
reg_id=router_token, uri=self.request.uri)
app_id=router_token, uri=self.request.uri)
d.addCallback(self._save_router_data, router_type)
d.addCallback(self._create_endpoint)
d.addCallback(self._return_endpoint, new_uaid, router)
Expand Down Expand Up @@ -741,8 +742,8 @@ def put(self, router_type="", router_token="", uaid="", chid=""):

self.add_header("Content-Type", "application/json")
d = Deferred()
d.addCallback(router.register, reg_id=router_token,
router_data=router_data, uri=self.request.uri)
d.addCallback(router.register, router_data=router_data,
app_id=router_token, uri=self.request.uri)
d.addCallback(self._save_router_data, router_type)
d.addCallback(self._success)
d.addErrback(self._router_fail_err)
Expand Down
40 changes: 23 additions & 17 deletions autopush/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ def obsolete_args(parser):
parser.add_argument('--max_message_size', type=int, help="OBSOLETE")
parser.add_argument('--s3_bucket', help='OBSOLETE')
parser.add_argument('--senderid_expry', help='OBSOLETE')
# old APNs args
parser.add_argument('--apns_enabled', help="OBSOLETE")
parser.add_argument('--apns_sandbox', help="OBSOLETE")
parser.add_argument('--apns_cert_file', help="OBSOLETE")
parser.add_argument('--apns_key_file', help="OBSOLETE")


def add_external_router_args(parser):
Expand Down Expand Up @@ -187,18 +192,14 @@ def add_external_router_args(parser):
parser.add_argument('--fcm_senderid', help='SenderID for FCM',
type=str, default="")
# Apple Push Notification system (APNs) for iOS
parser.add_argument('--apns_enabled', help="Enable APNS Bridge",
action="store_true", default=False,
env_var="APNS_ENABLED")
label = "APNS Router:"
parser.add_argument('--apns_sandbox', help="%s Use Dev Sandbox" % label,
action="store_true", default=False,
env_var="APNS_SANDBOX")
parser.add_argument('--apns_cert_file',
help="%s Certificate PEM file" % label,
type=str, env_var="APNS_CERT_FILE")
parser.add_argument('--apns_key_file', help="%s Key PEM file" % label,
type=str, env_var="APNS_KEY_FILE")
# credentials consist of JSON struct containing a channel type
# followed by the settings,
# e.g. {'firefox':{'cert': 'path.cert', 'key': 'path.key',
# 'sandbox': false}, ... }
parser.add_argument('--apns_creds', help="JSON dictionary of "
"APNS settings",
type=str, default="",
env_var="APNS_CREDS")
# UDP
parser.add_argument('--wake_timeout',
help="UDP: idle timeout before closing socket",
Expand Down Expand Up @@ -313,12 +314,14 @@ def make_settings(args, **kwargs):
router_conf["simplepush"] = {"idle": args.wake_timeout,
"server": args.wake_server,
"cert": args.wake_pem}
if args.apns_enabled:
if args.apns_creds:
# if you have the critical elements for each external router, create it
if args.apns_cert_file is not None and args.apns_key_file is not None:
router_conf["apns"] = {"sandbox": args.apns_sandbox,
"cert_file": args.apns_cert_file,
"key_file": args.apns_key_file}
try:
router_conf["apns"] = json.loads(args.apns_creds)
except (ValueError, TypeError):
log.critical(format="Invalid JSON specified for APNS config "
"options")
return
if args.gcm_enabled:
# Create a common gcmclient
try:
Expand Down Expand Up @@ -566,6 +569,9 @@ def endpoint_main(sysargs=None, use_files=True):
# Start the table rotation checker/updater
l = task.LoopingCall(settings.update_rotating_tables)
l.start(60)
if settings.routers.get('apns'):
l = task.LoopingCall(settings.routers['apns']._cleanup)
l.start(10)

reactor.suggestThreadPoolSize(50)
reactor.run()
173 changes: 123 additions & 50 deletions autopush/router/apnsrouter.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""APNS Router"""
import time
import uuid

import apns
from twisted.logger import Logger
from twisted.internet.threads import deferToThread

from autopush.router.interface import RouterException, RouterResponse


Expand All @@ -27,86 +27,159 @@ class APNSRouter(object):
255: 'Unknown',
}

def _connect(self):
"""Connect to APNS"""
self.apns = apns.APNs(use_sandbox=self.config.get("sandbox", False),
cert_file=self.config.get("cert_file"),
key_file=self.config.get("key_file"),
enhanced=True)
def _connect(self, cert_info):
"""Connect to APNS
:param cert_info: APNS certificate configuration info
:type cert_info: dict
:returns: APNs to be stored under the proper release channel name.
:rtype: apns.APNs
"""
# Do I still need to call this in _error?
return apns.APNs(
use_sandbox=cert_info.get("sandbox", False),
cert_file=cert_info.get("cert"),
key_file=cert_info.get("key"),
enhanced=True)

def __init__(self, ap_settings, router_conf):
"""Create a new APNS router and connect to APNS"""
self.ap_settings = ap_settings
self._base_tags = []
self.config = router_conf
self.default_title = router_conf.get("default_title", "SimplePush")
self.default_body = router_conf.get("default_body", "New Alert")
self._connect()
self.log.debug("Starting APNS router...")
self.apns = dict()
self.messages = dict()
self._config = router_conf
self._max_messages = self._config.pop('max_messages', 100)
for rel_channel in self._config:
self.apns[rel_channel] = self._connect(self._config[rel_channel])
self.apns[rel_channel].gateway_server.register_response_listener(
self._error)
self.ap_settings = ap_settings
self.log.debug("Starting APNS router...")

def register(self, uaid, router_data, app_id, *args, **kwargs):
"""Register an endpoint for APNS, on the `app_id` release channel.
def register(self, uaid, router_data, *args, **kwargs):
"""Validate that an APNs instance token is in the ``router_data``"""
This will validate that an APNs instance token is in the
``router_data``,
:param uaid: User Agent Identifier
:type uaid: str
:param router_data: Dict containing router specific configuration info
:type router_data: dict
:param app_id: The release channel identifier for cert info lookup
:type app_id: str
:returns: a modified router_data for the user agent record.
:rtype: dict
"""
if app_id not in self.apns:
raise RouterException("Unknown release channel specified",
status_code=400,
response_body="Unknown release channel")
if not router_data.get("token"):
raise RouterException("No token registered", status_code=500,
response_body="No token registered")
router_data["rel_channel"] = app_id
return router_data

def amend_msg(self, msg, router_data=None):
"""This function is stubbed out for this router"""
return msg

def route_notification(self, notification, uaid_data):
"""Start the APNS notification routing, returns a deferred"""
"""Start the APNS notification routing, returns a deferred
:param notification: Notification data to send
:type notification: dict
:param uaid_data: User Agent specific data
:type uaid_data: dict
"""
router_data = uaid_data["router_data"]
# Kick the entire notification routing off to a thread
return deferToThread(self._route, notification, router_data)

def _route(self, notification, router_data):
"""Blocking APNS call to route the notification"""
token = router_data["token"]
"""Blocking APNS call to route the notification
:param notification: Notification data to send
:type notification: dict
:param router_data: Pre-initialized data for this connection
:type router_data: dict
"""
router_token = router_data["token"]
rel_channel = router_data["rel_channel"]
config = self._config[rel_channel]
if len(self.messages) >= self._max_messages:
raise RouterException("Too many messages in pending queue",
status_code=503,
response_body="Pending buffer full",
)
apns_client = self.apns[rel_channel]
custom = {
"Chid": notification.channel_id,
"Ver": notification.version,
"chid": notification.channel_id,
"ver": notification.version,
}
if notification.data:
custom["Msg"] = notification.data
custom["Con"] = notification.headers["content-encoding"]
custom["Enc"] = notification.headers["encryption"]
custom["body"] = notification.data
custom["con"] = notification.headers["content-encoding"]
custom["enc"] = notification.headers["encryption"]

if "crypto-key" in notification.headers:
custom["Cryptokey"] = notification.headers["crypto-key"]
custom["cryptokey"] = notification.headers["crypto-key"]
elif "encryption-key" in notification.headers:
custom["Enckey"] = notification.headers["encryption-key"]

payload = apns.Payload(alert=router_data.get("title",
self.default_title),
content_available=1,
custom=custom)
now = int(time.time())
self.messages[now] = {"token": token, "payload": payload}
# TODO: Add listener for error handling.
self.apns.gateway_server.register_response_listener(self._error)
self.ap_settings.metrics.increment(
"updates.client.bridge.apns.attempted",
self._base_tags)
custom["enckey"] = notification.headers["encryption-key"]

self.apns.gateway_server.send_notification(token, payload, now)
payload = apns.Payload(
alert=router_data.get("title", config.get('default_title',
'Mozilla Push')),
content_available=1,
custom=custom)
now = time.time()

# cleanup sent messages
if self.messages:
for time_sent in self.messages.keys():
if time_sent < now - self.config.get("expry", 10):
del self.messages[time_sent]
self.ap_settings.metrics.increment(
"updates.client.bridge.apns.succeed",
self._base_tags)
# "apns-id"
msg_id = str(uuid.uuid4())
self.messages[msg_id] = {
"time_sent": now,
"rel_channel": router_data["rel_channel"],
"router_token": router_token,
"payload": payload}

apns_client.gateway_server.send_notification(router_token, payload,
msg_id)
location = "%s/m/%s" % (self.ap_settings.endpoint_url,
notification.version)
self.ap_settings.metrics.increment(
"updates.client.bridge.apns.%s.sent" %
router_data["rel_channel"],
self._base_tags)
return RouterResponse(status_code=201, response_body="",
headers={"TTL": notification.ttl,
"Location": location},
logged_status=200)

def _cleanup(self):
"""clean up pending, but expired messages.
APNs may not always respond with a status code, this will clean out
pending retryable messages.
"""
for msg_id in self.messages.keys():
message = self.messages[msg_id]
expry = self._config[message['rel_channel']].get("expry", 10)
if message["time_sent"] < time.time() - expry:
try:
del self.messages[msg_id]
except KeyError: # pragma nocover
pass

def _error(self, err):
"""Error handler"""
if err['status'] == 0:
Expand All @@ -117,11 +190,11 @@ def _error(self, err):
status=self.errors[err['status']])
if err['status'] in [1, 255]:
self.log.debug("Retrying...")
self._connect()
resend = self.messages.get(err.get('identifier'))
if resend is None:
return
self.apns.gateway_server.send_notification(resend['token'],
resend['payload'],
err['identifier'],
)
apns_client = self.apns[resend["rel_channel"]]
apns_client.gateway_server.send_notification(resend['token'],
resend['payload'],
err['identifier'],
)
7 changes: 4 additions & 3 deletions autopush/router/fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,21 +120,22 @@ def amend_msg(self, msg, data=None):
msg["senderid"] = data.get('creds', {}).get('senderID')
return msg

def register(self, uaid, router_data, reg_id=None, *args, **kwargs):
def register(self, uaid, router_data, app_id=None, *args, **kwargs):
"""Validate that the FCM Instance Token is in the ``router_data``"""
senderid = app_id
# "token" is the GCM registration id token generated by the client.
if "token" not in router_data:
raise self._error("connect info missing FCM Instance 'token'",
status=401,
uri=kwargs.get('uri'),
senderid=repr(reg_id))
senderid=repr(senderid))
# senderid is the remote client's senderID value. This value is
# very difficult for the client to change, and there was a problem
# where some clients had an older, invalid senderID. We need to
# be able to match senderID to it's corresponding auth key.
# If the client has an unexpected or invalid SenderID,
# it is impossible for us to reach them.
if not (reg_id == self.senderID):
if not (senderid == self.senderID):
raise self._error("Invalid SenderID", status=410, errno=105)
# Assign a senderid
router_data["creds"] = {"senderID": self.senderID,
Expand Down
Loading

0 comments on commit 7eed1ff

Please sign in to comment.