Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
2 changes: 2 additions & 0 deletions changelog.d/17987.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
MSC4174: add support for WebPush pusher kind.
Copy link
Contributor

Choose a reason for hiding this comment

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

@MatMaul Any interest in continuing this implementation?

WebPush came up recently as an alternative to MSC3013: 'Encrypted Push' in terms of leaking room_id/event_id metadata.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am at it again 💪


97 changes: 97 additions & 0 deletions docs/webpush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@

# WebPush

## Setup & configuration

In the synapse 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 a string labeled `Application Server Key`.

You'll copy the Application Server Key to `vapid_app_server_key` so that
web applications can fetch it through `/capabilities` and use it to subscribe
to the push manager:
Comment on lines 6 to 13
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems like a hassle. Any way to improve this?

Is it possible to make it generate if the file does not exist like we do for signing_key_path?

### `signing_key_path`
Path to the signing key to sign events and federation requests with.
*New in Synapse 1.67*: If this file does not exist, Synapse will create a new signing
key on startup and store it in this file.
Example configuration:
```yaml
signing_key_path: "CONFDIR/SERVERNAME.signing.key"
```

Does it matter if this key changes from time to time?

Copy link

Choose a reason for hiding this comment

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

It matters if the client isn't aware of this change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We would have to edit the config file since we need to also specify vapid_app_server_key.
However if we make vapid_app_server_key also (possibly) take a file as vapid_private_key does, it looks a lot more sane to do the generation on the fly.


```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 server operator can reach you in case they need to notify you
about your usage of their API.

Since for webpush, the push server endpoint is variable and comes from the browser
through the push data, you may not want to have your synapse instance connect to any
random addressable server.
You can use the global options `ip_range_blacklist` and `ip_range_allowlist` to manage that.

A default time-to-live of a day is set for webpush, but you can adjust this by setting
the `ttl: <number of seconds>` configuration option for the pusher.
If notifications can't be delivered by the push server aftet this time, they are dropped.

## 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 must contain
`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).

### `events_only`

As of the time of writing, all webpush-supporting browsers require you to set
`userVisibleOnly: true` when calling (`pushManager.subscribe`)
[https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe], to
(prevent abusing webpush to track users)[https://goo.gl/yqv4Q4] without their
knowledge. With this (mandatory) flag, the browser will show a "site has been
updated in the background" notification if no notifications are visible after
your service worker processes a `push` event. This can easily happen when synapse
sends a push message to clear the unread count, which is not specific
to an event. With `events_only: true` in the pusher data, synapse won't forward
any push message without a event id. This prevents your service worker being
forced to show a notification to push messages that clear the unread count.

### `only_last_per_room`

You can opt in to only receive the last notification per room by setting
`only_last_per_room: true` in the push data. Note that if the first notification
can be delivered before the second one is sent, you will still get both;
it only has an effect when notifications are queued up on the gateway.

### Multiple pushers on one origin

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 non-null values were sent by the homeserver). These are the
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 @@ -113,3 +113,9 @@ ignore_missing_imports = True

[mypy-mypy_zope.*]
ignore_missing_imports = True

[mypy-pywebpush.*]
ignore_missing_imports = True

[mypy-py_vapid.*]
ignore_missing_imports = True
873 changes: 849 additions & 24 deletions poetry.lock

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,17 @@ cache-memory = ["pympler>=1.0"]
# If this is updated, don't forget to update the equivalent lines in
# `dependency-groups.dev` below.
test = ["parameterized>=0.9.0", "idna>=3.3"]
# Webpush support
webpush = [
"pywebpush>=2.0.0",
"py-vapid>=1.7.0",
# aiohttp is required by pywebpush and no version is specified by pywebpush itself,
# so old deps is failing without this.
# aiohttp is not used by synapse impl however, which uses Twisted.
# Version is random and choosen because it is not too recent but not too old either,
# which should accomodate most downstream distros.
"aiohttp>=3.8.0",
]

# The duplication here is awful.
#
Expand Down Expand Up @@ -192,6 +203,10 @@ all = [
# cache-memory
# 1.0 added support for python 3.10, our current minimum supported python version
"pympler>=1.0",
# webpush
"pywebpush>=2.0.0",
"py-vapid>=1.7.0",
"aiohttp>=3.8.0",
# omitted:
# - test: it's useful to have this separate from dev deps in the olddeps job
# - systemd: this is a system-based requirement
Expand Down
53 changes: 53 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
except ImportError:
HAS_AUTHLIB = False

# Determine whether pywebpush is installed.
try:
import pywebpush # noqa: F401

HAS_PYWEBPUSH = True
except ImportError:
HAS_PYWEBPUSH = False

if TYPE_CHECKING:
# Only import this if we're type checking, as it might not be installed at runtime.
from authlib.jose.rfc7517 import JsonWebKey
Expand Down Expand Up @@ -365,6 +373,28 @@ class MSC3866Config:
require_approval_for_new_accounts: bool = False


@attr.s(auto_attribs=True, frozen=True, slots=True)
class MSC4174Config:
"""Configuration for MSC4174: webpush push kind"""

enabled: bool = attr.ib(default=False, validator=attr.validators.instance_of(bool))

@enabled.validator
def _check_enabled(self, attribute: attr.Attribute, value: bool) -> None:
# Only allow enabling MSC4174 if pywebpush is installed
if value and not HAS_PYWEBPUSH:
raise ConfigError(
"MSC4174 is enabled but pywebpush is not installed. "
"Please install pywebpush to use MSC4174.",
("experimental", "msc4174", "enabled"),
)

vapid_contact_email: str = ""
vapid_private_key: str = ""
vapid_app_server_key: str = ""
ttl: int = 12 * 60 * 60


class ExperimentalConfig(Config):
"""Config section for enabling experimental features

Expand Down Expand Up @@ -606,3 +636,26 @@ def read_config(
# Note that sticky events persisted before this feature is enabled will not be
# considered sticky by the local homeserver.
self.msc4354_enabled: bool = experimental.get("msc4354_enabled", False)

# MSC4380: Invite blocking
self.msc4380_enabled: bool = experimental.get("msc4380_enabled", False)

# MSC4174: webpush push kind
raw_msc4174_config = experimental.get("msc4174", {})
self.msc4174 = MSC4174Config(**raw_msc4174_config)
if self.msc4174.enabled:
if not self.msc4174.vapid_contact_email:
raise ConfigError(
"'vapid_contact_email' must be provided when enabling WebPush support",
("experimental", "msc4174", "vapid_contact_email"),
)
if not self.msc4174.vapid_private_key:
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is a file path, we should use the standard idiom: vapid_private_key_path

Copy link
Contributor Author

Choose a reason for hiding this comment

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

raise ConfigError(
"'vapid_private_key' must be provided when enabling WebPush support",
("experimental", "msc4174", "vapid_private_key"),
)
if not self.msc4174.vapid_app_server_key:
raise ConfigError(
"'vapid_app_server_key' must be provided when enabling WebPush support",
("experimental", "msc4174", "vapid_app_server_key"),
)
60 changes: 34 additions & 26 deletions synapse/push/httppusher.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ def __init__(self, hs: "HomeServer", pusher_config: PusherConfig):
self.device_display_name = pusher_config.device_display_name
self.device_id = pusher_config.device_id
self.pushkey_ts = pusher_config.ts
self.data = pusher_config.data
self.backoff_delay = HttpPusher.INITIAL_BACKOFF_SEC
self.failing_since = pusher_config.failing_since
self.timed_call: Optional[IDelayedCall] = None
Expand All @@ -129,9 +128,9 @@ def __init__(self, hs: "HomeServer", pusher_config: PusherConfig):

self.push_jitter_delay_ms = hs.config.push.push_jitter_delay_ms

self.data = pusher_config.data
if self.data is None:
if pusher_config.data is None:
raise PusherConfigException("'data' key can not be null for HTTP pusher")
self.data = pusher_config.data

# Check if badge counts should be disabled for this push gateway
self.disable_badge_count = self.hs.config.experimental.msc4076_enabled and bool(
Expand All @@ -144,22 +143,28 @@ def __init__(self, hs: "HomeServer", pusher_config: PusherConfig):
pusher_config.pushkey,
)

# Validate that there's a URL and it is of the proper form.
if "url" not in self.data:
raise PusherConfigException("'url' required in data for HTTP pusher")

url = self.data["url"]
if not isinstance(url, str):
raise PusherConfigException("'url' must be a string")
url_parts = urllib.parse.urlparse(url)
# Note that the specification also says the scheme must be HTTPS, but
# it isn't up to the homeserver to verify that.
if url_parts.path != "/_matrix/push/v1/notify":
raise PusherConfigException(
"'url' must have a path of '/_matrix/push/v1/notify'"
)
self.url = ""
if pusher_config.kind == "http":
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like we should have a sanity check here that we're not trying to use this class with the wrong pusher config. This also makes it more obvious what pusher kinds this class is used with.

assert pusher_config.kind in (PusherKind.HTTP, PusherKind.WEB_PUSH)

# ...

if pusher_config.kind == PusherKind.HTTP:
     # ...
elif pusher_config.kind == PusherKind.WEB_PUSH:
    pass
else:
    # FIXME: We should use `assert_never` here but for some reason
    # the exhaustive matching doesn't recognize the `Never` here.
    # assert_never(pusher_config.kind)
    raise AssertionError(
        f"Unexpected pusher kind {pusher_config.kind} for HttpPusher"
    )

# Validate that there's a URL and it is of the proper form.
if "url" not in self.data:
raise PusherConfigException("'url' required in data for HTTP pusher")

url = self.data["url"]
if not isinstance(url, str):
raise PusherConfigException("'url' must be a string")
url_parts = urllib.parse.urlparse(url)
# Note that the specification also says the scheme must be HTTPS, but
# it isn't up to the homeserver to verify that.
if url_parts.path != "/_matrix/push/v1/notify":
raise PusherConfigException(
"'url' must have a path of '/_matrix/push/v1/notify'"
)
self.url = url

self.data_minus_url = {}
self.data_minus_url.update(self.data)
del self.data_minus_url["url"]

self.url = url
self.http_client = hs.get_proxied_blocklisted_http_client()
self.data_minus_url = {}
self.data_minus_url.update(self.data)
Expand Down Expand Up @@ -196,7 +201,14 @@ async def _update_badge(self) -> None:
)
if self.badge_count_last_call is None or self.badge_count_last_call != badge:
self.badge_count_last_call = badge
await self._send_badge(badge)
if await self.send_badge(badge):
http_badges_processed_counter.labels(
**{SERVER_NAME_LABEL: self.server_name}
).inc()
else:
http_badges_failed_counter.labels(
**{SERVER_NAME_LABEL: self.server_name}
).inc()

def on_timer(self) -> None:
self._start_processing()
Expand Down Expand Up @@ -529,7 +541,7 @@ async def dispatch_push_event(

return res

async def _send_badge(self, badge: int) -> None:
async def send_badge(self, badge: int) -> bool:
"""
Args:
badge: number of unread messages
Comment on lines 545 to 547
Copy link
Contributor

Choose a reason for hiding this comment

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

We should update comment doc with what the returned bool represents

(same for the other send_badge in WebPushPusher)

Expand All @@ -553,13 +565,9 @@ async def _send_badge(self, badge: int) -> None:
}
try:
await self.http_client.post_json_get_json(self.url, d)
http_badges_processed_counter.labels(
**{SERVER_NAME_LABEL: self.server_name}
).inc()
return True
except Exception as e:
logger.warning(
"Failed to send badge count to %s: %s %s", self.name, type(e), e
)
http_badges_failed_counter.labels(
**{SERVER_NAME_LABEL: self.server_name}
).inc()
return False
9 changes: 9 additions & 0 deletions synapse/push/pusher.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import logging
from typing import TYPE_CHECKING, Callable

import synapse.config.experimental
from synapse.push import Pusher, PusherConfig
from synapse.push.emailpusher import EmailPusher
from synapse.push.httppusher import HttpPusher
Expand All @@ -42,6 +43,14 @@ def __init__(self, hs: "HomeServer"):
"http": HttpPusher
}

if (
synapse.config.experimental.HAS_PYWEBPUSH
and self.config.experimental.msc4174.enabled
):
from synapse.push.webpushpusher import WebPushPusher

self.pusher_types["webpush"] = WebPushPusher

logger.info("email enable notifs: %r", hs.config.email.email_enable_notifs)
if hs.config.email.email_enable_notifs:
self.mailers: dict[str, Mailer] = {}
Expand Down
Loading
Loading