"""APNS Router"""
import socket
import uuid
from typing import Any # noqa
from hyper.http20.exceptions import ConnectionError, HTTP20Error
from twisted.internet.threads import deferToThread
from twisted.logger import Logger
from autopush.exceptions import RouterException
from autopush.metrics import make_tags
from autopush.router.apns2 import (
APNSClient,
APNS_MAX_CONNECTIONS,
)
from autopush.router.interface import RouterResponse
from autopush.types import JSONDict # noqa
# https://github.com/djacobs/PyAPNs
[docs]class APNSRouter(object):
"""APNS Router Implementation"""
log = Logger()
apns = None
[docs] def _connect(self, rel_channel, load_connections=True):
"""Connect to APNS
:param rel_channel: Release channel name (e.g. Firefox. FirefoxBeta,..)
:type rel_channel: str
:param load_connections: (used for testing)
:type load_connections: bool
:returns: APNs to be stored under the proper release channel name.
:rtype: apns.APNs
"""
default_topic = "com.mozilla.org." + rel_channel
cert_info = self.router_conf[rel_channel]
return APNSClient(
cert_file=cert_info.get("cert"),
key_file=cert_info.get("key"),
use_sandbox=cert_info.get("sandbox", False),
max_connections=cert_info.get("max_connections",
APNS_MAX_CONNECTIONS),
topic=cert_info.get("topic", default_topic),
logger=self.log,
metrics=self.metrics,
load_connections=load_connections,
max_retry=cert_info.get('max_retry', 2)
)
[docs] def __init__(self, conf, router_conf, metrics, load_connections=True):
"""Create a new APNS router and connect to APNS
:param conf: Configuration settings
:type conf: autopush.config.AutopushConfig
:param router_conf: Router specific configuration
:type router_conf: dict
:param load_connections: (used for testing)
:type load_connections: bool
"""
self.conf = conf
self.router_conf = router_conf
self.metrics = metrics
self._base_tags = ["platform:apns"]
self.apns = dict()
for rel_channel in router_conf:
self.apns[rel_channel] = self._connect(rel_channel,
load_connections)
self.log.debug("Starting APNS router...")
[docs] def register(self, uaid, router_data, app_id, *args, **kwargs):
# type: (str, JSONDict, str, *Any, **Any) -> None
"""Register an endpoint for APNS, on the `app_id` release channel.
This will validate that an APNs instance token is in the
`router_data`,
:param uaid: User Agent Identifier
:param router_data: Dict containing router specific configuration info
:param app_id: The release channel identifier for cert info lookup
"""
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=400,
response_body="No token registered")
router_data["rel_channel"] = app_id
[docs] def amend_endpoint_response(self, response, router_data):
# type: (JSONDict, JSONDict) -> None
"""Stubbed out for this router"""
[docs] def route_notification(self, notification, uaid_data):
"""Start the APNS notification routing, returns a deferred
:param notification: Notification data to send
:type notification: autopush.endpoint.Notification
: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)
[docs] def _route(self, notification, router_data):
"""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"]
apns_client = self.apns[rel_channel]
# chid MUST MATCH THE CHANNELID GENERATED BY THE REGISTRATION SERVICE
# Currently this value is in hex form.
payload = {
"chid": notification.channel_id.hex,
"ver": notification.version,
}
if notification.data:
payload["con"] = notification.headers.get(
"content-encoding", notification.headers.get("encoding"))
if payload["con"] != "aes128gcm":
if "encryption" in notification.headers:
payload["enc"] = notification.headers["encryption"]
if "crypto_key" in notification.headers:
payload["cryptokey"] = notification.headers["crypto_key"]
elif "encryption_key" in notification.headers:
payload["enckey"] = notification.headers["encryption_key"]
payload["body"] = notification.data
payload['aps'] = router_data.get('aps', {
"mutable-content": 1,
"alert": {
"loc-key": "SentTab.NoTabArrivingNotification.body",
"title-loc-key": "SentTab.NoTabArrivingNotification.title",
}
})
apns_id = str(uuid.uuid4()).lower()
# APNs may force close a connection on us without warning.
# if that happens, retry the message.
try:
apns_client.send(router_token=router_token, payload=payload,
apns_id=apns_id)
except Exception as e:
# We sometimes see strange errors around sending push notifications
# to APNS. We get reports that after a new deployment things work,
# but then after a week or so, messages across the APNS bridge
# start to fail. The connections appear to be working correctly,
# so we don't think that this is a problem related to how we're
# connecting.
if isinstance(e, ConnectionError):
reason = "connection_error"
elif isinstance(e, (HTTP20Error, socket.error)):
reason = "http2_error"
else:
reason = e.extra.get("reason", "unknown")
if isinstance(e, RouterException) and e.status_code in [404, 410]:
raise RouterException(
str(e),
status_code=e.status_code,
errno=106,
response_body="User is no longer registered",
log_exception=False
)
self.metrics.increment("notification.bridge.error",
tags=make_tags(self._base_tags,
application=rel_channel,
reason=reason,
error=502,
errno=0,
))
raise RouterException(
str(e),
status_code=502,
response_body="APNS returned an error processing request",
)
location = "%s/m/%s" % (self.conf.endpoint_url, notification.version)
self.metrics.increment("notification.bridge.sent",
tags=make_tags(self._base_tags,
application=rel_channel))
self.metrics.increment(
"updates.client.bridge.apns.{}.sent".format(
router_data["rel_channel"]
),
tags=self._base_tags
)
self.metrics.increment("notification.message_data",
notification.data_length,
tags=make_tags(self._base_tags,
destination='Direct'))
return RouterResponse(status_code=201, response_body="",
headers={"TTL": notification.ttl,
"Location": location},
logged_status=200)