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

feat: Add multiple cert handlers for APNs #660

Merged
merged 1 commit into from
Sep 17, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to get it, a key check should be fine. "if 'apns' in ...."

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?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess not. 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, yeah, remember my cryptic comment about calling self._connect() before? ...

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]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure hope we never, ever, change valid release channel names.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do, thumbscrews. Fortunately ops has the ability to generate more certs. They'll just have to change the app_id they're sending in to use the new cert pair.

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:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: self.messages.pop(err['identifier'], None) might be better; del will throw a KeyError if the entry was already removed from the dict.

Copy link
Member

@bbangert bbangert Sep 16, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That shouldn't happen now that cleanup is run off the event loop.

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