Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add experimental support for WebPush #177

Merged
merged 50 commits into from
Mar 17, 2021
Merged
Show file tree
Hide file tree
Changes from 48 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
febbe6d
first draft of webpush pushkin
bwindels Mar 11, 2021
5aa5e96
declare dependencies
bwindels Mar 11, 2021
2cba009
precompute the claims
bwindels Mar 12, 2021
657bacf
allow client to pass session_id for multiple pushers by 1 service worker
bwindels Mar 12, 2021
4dc8e60
apply style suggestion
bwindels Mar 12, 2021
dee1b1f
point to commit hash for fork of pywebpush and add comment why needed
bwindels Mar 12, 2021
b04db8f
english
bwindels Mar 12, 2021
dc904cb
build payload more cautiously, and adopt default_payload like gcm/aspn
bwindels Mar 12, 2021
6d326e7
always send content if present, as the payload is encrypted
bwindels Mar 15, 2021
6f50a86
always send the counts if they are set, even 0
bwindels Mar 15, 2021
f262ed2
check config in ctor
bwindels Mar 15, 2021
c4acf9d
update fork commit hash after PR feedback
bwindels Mar 15, 2021
7ad9133
copy GCM connection handling
bwindels Mar 15, 2021
0e233f2
add documentation for pusher
bwindels Mar 15, 2021
4325f15
update docs
bwindels Mar 15, 2021
34ab6b8
add changelog file
bwindels Mar 15, 2021
cda7fda
fix code style in pushkin
bwindels Mar 15, 2021
8002e5a
fix setup.py code style errors
bwindels Mar 15, 2021
35e0a0e
wrong extension for changelog file
bwindels Mar 15, 2021
538442c
more codestyle fixes
bwindels Mar 15, 2021
eba9c80
reformat to make linter happy
bwindels Mar 15, 2021
3198745
import sorting
bwindels Mar 15, 2021
f81c686
the linter seems to want a newline here ¯\_(ツ)_/¯
bwindels Mar 15, 2021
4115809
upstream PR got merged, update git dependency
bwindels Mar 16, 2021
33ccde8
Merge branch 'master' into bwindels/webpush
bwindels Mar 16, 2021
83def21
ignore type checking for py_vapid and pywebpush
bwindels Mar 16, 2021
8685f2c
2nd attempt to ignore type checking for py_vapid and pywebpush
bwindels Mar 16, 2021
ab65309
3rd attempt to ignore type checking for py_vapid and pywebpush
bwindels Mar 16, 2021
8ded12f
remove git dep, merged upstream PR got released
bwindels Mar 17, 2021
9e3cad8
validate push data better, and reject pushkey if invalid
bwindels Mar 17, 2021
40f5a40
remove comment
bwindels Mar 17, 2021
7531a29
Update sygnal/webpushpushkin.py
bwindels Mar 17, 2021
74d7244
Update sygnal/webpushpushkin.py
bwindels Mar 17, 2021
299363f
wrap any exception from loading vapid priv key at startup
bwindels Mar 17, 2021
5d5cb38
clean up docs
bwindels Mar 17, 2021
10fe061
counts, not count
bwindels Mar 17, 2021
0669242
clean up logging
bwindels Mar 17, 2021
8f0303a
provide docstrings for http wrapper
bwindels Mar 17, 2021
6887687
status code 200 is probably more correct/future-proof here
bwindels Mar 17, 2021
ae5e530
remove obsolete logging
bwindels Mar 17, 2021
e28f783
add docstring for post method parameters
bwindels Mar 17, 2021
0abb95d
black-ify formatting
bwindels Mar 17, 2021
ec88640
remove trailing space
bwindels Mar 17, 2021
5403fca
typo
bwindels Mar 17, 2021
ed7d038
Re-work docstrings.
clokep Mar 17, 2021
65915db
Update some of the documentation.
clokep Mar 17, 2021
4011ad3
Update changelog.
clokep Mar 17, 2021
ee43211
Black-ify
clokep Mar 17, 2021
9ad5991
Clarify documentation.
clokep Mar 17, 2021
96cd23a
Catch a more specific exception.
clokep Mar 17, 2021
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
1 change: 1 addition & 0 deletions changelog.d/177.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add experimental support for WebPush pushkins.
63 changes: 63 additions & 0 deletions docs/applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,66 @@ within FCM's limit.
Please also note that some fields will be unavailable if you registered a pusher
with `event_id_only` format.

### WebPush

#### Setup & configuration

In the sygnal virtualenv, generate the server key pair by running
`vapid --gen --applicationServerKey`. This will generate a `private_key.pem`
(which you'll refer to in the config file with `vapid_private_key`)
and `public_key.pem` file, and also string labeled `Application Server Key`.

You'll copy the Application Server Key to your web application to subscribe
to the push manager:

```js
serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: "...",
});
```

You also need to set an e-mail address in `vapid_contact_email` in the config file,
where the push gateway operator can reach you in case they need to notify you
about your usage of their API.

#### Push key and expected push data

In your web application, [the push manager subscribe method](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)
will return
[a subscription](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription)
with an `endpoint` and `keys` property, the latter containing a `p256dh` and `auth`
property. The `p256dh` key is used as the push key, and the push data is expected
`endpoint` and `auth`. You can also set `default_payload` in the push data;
any properties set in it will be present in the push messages you receive,
so it can be used to pass identifiers specific to your client
(like which account the notification is for).

Also note that because you can only have one push subscription per service worker,
and hence per origin, you might create pushers for different accounts with the same
p256dh push key. To prevent the server from removing other pushers with the same
push key for your other users, you should set `append` to `true` when uploading
your pusher.

#### Notification format

The notification as received by your web application will contain the following keys
(assuming they were sent by the homeserver). These are the
clokep marked this conversation as resolved.
Show resolved Hide resolved
clokep marked this conversation as resolved.
Show resolved Hide resolved
same as specified in [the push gateway spec](https://matrix.org/docs/spec/push_gateway/r0.1.0#post-matrix-push-v1-notify),
but the sub-keys of `counts` (`unread` and `missed_calls`) are flattened into
the notification object.

```
room_id
room_name
room_alias
membership
event_id
sender
sender_display_name
user_is_target
type
content
unread
missed_calls
```
6 changes: 6 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ ignore_missing_imports = True

[mypy-OpenSSL.*]
ignore_missing_imports = True

[mypy-py_vapid]
ignore_missing_imports = True

[mypy-pywebpush]
ignore_missing_imports = True
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def read(fname):
"idna>=2.8",
"psycopg2>=2.8.4",
"importlib_metadata",
"pywebpush>=1.13.0",
"py-vapid>=1.7.0",
Comment on lines +52 to +53
Copy link
Member

Choose a reason for hiding this comment

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

As someone who has no added libraries before, do we care about the licenses of these or anything of that nature? (Probably a question for @matrix-org/synapse-core)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

py-vapid and pywebpush are both MPL2. Should transitive dependencies be checked?

],
long_description=read("README.rst"),
)
274 changes: 274 additions & 0 deletions sygnal/webpushpushkin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
# -*- coding: utf-8 -*-
# Copyright 2021 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
import os.path
from io import BytesIO

from prometheus_client import Gauge, Histogram
from py_vapid import Vapid
from pywebpush import webpush
from twisted.internet.defer import DeferredSemaphore
from twisted.web.client import FileBodyProducer, HTTPConnectionPool, readBody
from twisted.web.http_headers import Headers

from sygnal.helper.context_factory import ClientTLSOptionsFactory
from sygnal.helper.proxy.proxyagent_twisted import ProxyAgent

from .exceptions import PushkinSetupException
from .notifications import ConcurrencyLimitedPushkin

QUEUE_TIME_HISTOGRAM = Histogram(
"sygnal_webpush_queue_time",
"Time taken waiting for a connection to WebPush endpoint",
)

SEND_TIME_HISTOGRAM = Histogram(
"sygnal_webpush_request_time", "Time taken to send HTTP request to WebPush endpoint"
)

PENDING_REQUESTS_GAUGE = Gauge(
"sygnal_pending_webpush_requests",
"Number of WebPush requests waiting for a connection",
)

ACTIVE_REQUESTS_GAUGE = Gauge(
"sygnal_active_webpush_requests", "Number of WebPush requests in flight"
)

logger = logging.getLogger(__name__)

DEFAULT_MAX_CONNECTIONS = 20


class WebpushPushkin(ConcurrencyLimitedPushkin):
"""
Pushkin that relays notifications to Google/Firebase Cloud Messaging.
"""

UNDERSTOOD_CONFIG_FIELDS = {
"type",
"max_connections",
"vapid_private_key",
"vapid_contact_email",
} | ConcurrencyLimitedPushkin.UNDERSTOOD_CONFIG_FIELDS

def __init__(self, name, sygnal, config):
super(WebpushPushkin, self).__init__(name, sygnal, config)

nonunderstood = self.cfg.keys() - self.UNDERSTOOD_CONFIG_FIELDS
if nonunderstood:
logger.warning(
"The following configuration fields are not understood: %s",
nonunderstood,
)

self.http_pool = HTTPConnectionPool(reactor=sygnal.reactor)
self.max_connections = self.get_config(
"max_connections", DEFAULT_MAX_CONNECTIONS
)
self.connection_semaphore = DeferredSemaphore(self.max_connections)
self.http_pool.maxPersistentPerHost = self.max_connections

tls_client_options_factory = ClientTLSOptionsFactory()

# use the Sygnal global proxy configuration
proxy_url = sygnal.config.get("proxy")

self.http_agent = ProxyAgent(
reactor=sygnal.reactor,
pool=self.http_pool,
contextFactory=tls_client_options_factory,
proxy_url_str=proxy_url,
)
self.http_agent_wrapper = HttpAgentWrapper(self.http_agent)

privkey_filename = self.get_config("vapid_private_key")
if not privkey_filename:
raise PushkinSetupException("'vapid_private_key' not set in config")
if not os.path.exists(privkey_filename):
raise PushkinSetupException("path in 'vapid_private_key' does not exist")
try:
self.vapid_private_key = Vapid.from_file(private_key_file=privkey_filename)
except BaseException as e:
raise PushkinSetupException("invalid 'vapid_private_key' file") from e
vapid_contact_email = self.get_config("vapid_contact_email")
if not vapid_contact_email:
raise PushkinSetupException("'vapid_contact_email' not set in config")
self.vapid_claims = {"sub": "mailto:{}".format(vapid_contact_email)}

async def _dispatch_notification_unlimited(self, n, device, context):
p256dh = device.pushkey
if not isinstance(device.data, dict):
logger.warn(
"device.data is not a dict for pushkey %s, rejecting pushkey", p256dh
)
return [device.pushkey]

endpoint = device.data.get("endpoint")
auth = device.data.get("auth")

if not p256dh or not endpoint or not auth:
logger.warn(
"subscription info incomplete "
+ "(p256dh: %s, endpoint: %s, auth: %s), rejecting pushkey",
p256dh,
endpoint,
auth,
)
return [device.pushkey]

subscription_info = {
"endpoint": endpoint,
"keys": {"p256dh": p256dh, "auth": auth},
}
payload = WebpushPushkin._build_payload(n, device)
data = json.dumps(payload)

# we use the semaphore to actually limit the number of concurrent
# requests, since the HTTPConnectionPool will actually just lead to more
# requests being created but not pooled – it does not perform limiting.
with QUEUE_TIME_HISTOGRAM.time():
with PENDING_REQUESTS_GAUGE.track_inprogress():
await self.connection_semaphore.acquire()

try:
with SEND_TIME_HISTOGRAM.time():
with ACTIVE_REQUESTS_GAUGE.track_inprogress():
response_wrapper = webpush(
subscription_info=subscription_info,
data=data,
vapid_private_key=self.vapid_private_key,
vapid_claims=self.vapid_claims,
requests_session=self.http_agent_wrapper,
)
response = await response_wrapper.deferred
await readBody(response)
finally:
self.connection_semaphore.release()

# assume 4xx is permanent and 5xx is temporary
if 400 <= response.code < 500:
return [device.pushkey]
return []

@staticmethod
def _build_payload(n, device):
"""
Build the payload data to be sent.

Args:
n: Notification to build the payload for.
device (Device): Device information to which the constructed payload
will be sent.

Returns:
JSON-compatible dict
"""
payload = {}

default_payload = device.data.get("default_payload")
if isinstance(default_payload, dict):
payload.update(default_payload)

for attr in [
"room_id",
"room_name",
"room_alias",
"membership",
"event_id",
"sender",
"sender_display_name",
"user_is_target",
"type",
"content",
]:
value = getattr(n, attr, None)
if value:
payload[attr] = value

counts = getattr(n, "counts", None)
if counts is not None:
for attr in ["unread", "missed_calls"]:
count_value = getattr(counts, attr, None)
if count_value is not None:
payload[attr] = count_value

return payload


class HttpAgentWrapper:
clokep marked this conversation as resolved.
Show resolved Hide resolved
"""
Provide a post method that matches the API expected from pywebpush.
"""

def __init__(self, http_agent):
self.http_agent = http_agent

def post(self, endpoint, data, headers, timeout):
clokep marked this conversation as resolved.
Show resolved Hide resolved
"""
Convert the requests-like API to a Twisted API call.

Args:
endpoint (str):
The full http url to post to
data (bytes):
the (encrypted) binary body of the request
headers (py_vapid.CaseInsensitiveDict):
A (costume) dictionary with the headers.
timeout (int)
Ignored for now
"""
body_producer = FileBodyProducer(BytesIO(data))
# Convert the headers to the camelcase version.
headers = {
b"User-Agent": ["sygnal"],
b"Content-Encoding": [headers["content-encoding"]],
b"Authorization": [headers["authorization"]],
b"TTL": [headers["ttl"]],
}
deferred = self.http_agent.request(
b"POST",
endpoint.encode(),
headers=Headers(headers),
bodyProducer=body_producer,
)
return HttpResponseWrapper(deferred)


class HttpResponseWrapper:
clokep marked this conversation as resolved.
Show resolved Hide resolved
"""
Provide a response object that matches the API expected from pywebpush.
pywebpush expects a synchronous API, while we use an asynchronous API.

To keep pywebpush happy we present it with some hardcoded values that
make its assertions pass while the async network call is happening
in the background.

Attributes:
deferred (Deferred):
The deferred to await the actual response after calling pywebpush.
status_code (int):
Defined to be 200 so the pywebpush check to see if is below 202
passes.
text (str):
Set to None as pywebpush references this field for its logging.
"""

status_code = 200
text = None

def __init__(self, deferred):
self.deferred = deferred