WebDAV-Push deprecated

WebDAV-Push for CalDAV and CardDAV

CalDAV and CardDAV have no push mechanism of their own, so clients poll the server for changes. WebDAV-Push (draft-bitfire-webdav-push) closes this gap: a client registers a Web Push subscription for a collection, and the server sends a short, end-to-end encrypted push message to the client's push service whenever the contents of the collection change. The client then performs a regular synchronization of just that collection. The first client implementing the protocol is DAVx⁵ (version 4.4.10 and later) on Android, which receives the messages either via Google FCM or via a UnifiedPush distributor such as ntfy.

The middleware implements the server side in two bundles:

  • com.openexchange.pns.transport.webpush adds a generic Web Push transport to the push notification service. It encrypts messages for the subscribed device (RFC 8291, aes128gcm), authenticates the server towards the push service via VAPID and delivers them with an HTTP POST to the push resource the client registered.
  • com.openexchange.dav.push adds the WebDAV-Push protocol to the CalDAV and CardDAV servlets: the transports, topic and supported-triggers properties on calendar and address book collections, the push-register request, the registration URLs (DELETE to unsubscribe), and the change handlers that turn calendar and contact changes into push notifications.

Only the fact that a collection changed leaves the server. The push message carries the collection's opaque topic (and optionally its new sync token), encrypted so that neither the push service nor anybody in between can read it.

Prerequisites

No registration with Google or any other push service is required. The server identifies itself with a self-generated VAPID key (a P-256 private key; the public key clients get is derived from it). Generate one with OpenSSL:

openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out vapid.pem

In Kubernetes, deploy the key as a secret labeled for the middleware's key store service. This is the preferred way, as it keeps the key out of configuration files, and changes to the secret are picked up at runtime:

kubectl create secret generic webdav-push-vapid --from-file=vapid.pem
kubectl label secret webdav-push-vapid OX_KEYSTORE=webdav-push-vapid

Besides PEM (PKCS#8 or SEC1), the secret may hold the key DER encoded, as raw 32 byte scalar, or in the base64url encoding common VAPID tooling uses. Outside Kubernetes, the key is configured as a property in that base64url encoding (43 characters):

openssl ec -in vapid.pem -outform DER 2>/dev/null | tail -c +8 | head -c 32 | basenc --base64url -w0 | tr -d '='

The same key has to be used by all nodes of a cluster. Rotating it later invalidates the subscriptions the push services hold: they answer with HTTP 403 for messages signed with the new key until the clients notice the new key on their next collection refresh and re-register. The middleware keeps such subscriptions (they expire within days, and a 403 may as well be caused by a misconfiguration on the server side), and warns about each of them once per hour.

The middleware nodes need outbound HTTPS access to the push services the clients use: fcm.googleapis.com for devices with Google Play services, and whatever UnifiedPush server the users chose (the public ntfy.sh, a self-hosted ntfy or NextPush instance, ...). No inbound connectivity beyond the existing CalDAV/CardDAV endpoint is needed; push services never call the middleware.

Configuration

The middleware is configured through the core-mw chart. The VAPID key is the only secret involved, everything else is plain properties. A minimal setup that offers push to every user:

##
## Middleware Stack chart configuration
##
core-mw:
  properties:
    # the Web Push transport reads the VAPID key from the Kubernetes secret labeled OX_KEYSTORE=webdav-push-vapid
    com.openexchange.pns.transport.webpush.vapid.keyId: "webdav-push-vapid"
    com.openexchange.pns.transport.webpush.vapid.subject: "mailto:hostmaster@example.com"
    # offer WebDAV-Push to CalDAV/CardDAV clients (config-cascade aware, default false)
    com.openexchange.dav.push.enabled: "true"

The secret is not part of the chart values: create it in the release namespace as shown under Prerequisites. Its name and data key are free, the OX_KEYSTORE label is what the key store service looks for, and changes to it are picked up at runtime. Should the key be kept in the configuration instead, for example outside Kubernetes, put it into secretProperties, which the chart renders into a Secret rather than a ConfigMap:

core-mw:
  secretProperties:
    com.openexchange.pns.transport.webpush.vapid.privateKey: "<43 characters, base64url>"

A configured vapid.keyId takes precedence over the property. If its secret cannot be resolved or holds no valid key, the transport stays unavailable rather than silently falling back, which is logged at ERROR level.

To offer push to some users only, use the config cascade through contextSets, like the CalDAV switch itself:

core-mw:
  properties:
    com.openexchange.dav.push.enabled: "false"
  contextSets:
    premium:
      withTags: premium
      com.openexchange.dav.push.enabled: "true"

Further properties control the egress policy and delivery parameters of the transport; they go into properties (or contextSets where config-cascade aware) like the ones above. See the Web Push properties and the WebDAV-Push properties:

Property Default Purpose
com.openexchange.pns.transport.webpush.httpsOnly true Reject push resources that are not https URIs
com.openexchange.pns.transport.webpush.allowedHosts (all) Restrict push resources to a list of push service hosts
com.openexchange.pns.transport.webpush.allowLocalEndpoints false Allow push resources on private or loopback addresses (testing only)
com.openexchange.pns.transport.webpush.ttl 86400 Seconds a push service keeps an undelivered message
com.openexchange.pns.transport.webpush.urgency normal Delivery urgency signaled to the push service
com.openexchange.pns.transport.webpush.maxExpiryDays 30 Upper bound for the lifetime of any Web Push subscription
com.openexchange.pns.transport.webpush.maxSubscriptionsPerUser 100 Subscriptions a user may hold per client, 0 for no limit
com.openexchange.dav.push.maxExpiryDays 7 Maximum lifetime of a subscription before the client has to renew it
com.openexchange.dav.push.topicSecret (derived from the VAPID key) Secret the opaque collection topics are derived with

The HTTP client the transport delivers with has the identifier webpush. Its pool and timeouts are tuned through the generic com.openexchange.httpclient.webpush.* keys described in the HTTP client configuration, and start out with the general defaults (30 seconds to connect and to read, 20 connections in total, 10 per host). For installations with many subscriptions, shorter timeouts (connectTimeout and readTimeout of 5000) and a larger pool (totalConnections 100, connectionsPerRoute 50) keep a slow or unreachable push service from holding notification workers for long.

The transport's per-client and per-topic switches follow the usual pattern of the push notification service, e.g. com.openexchange.pns.transport.webpush.enabled.webdav-push=false disables delivery to WebDAV-Push clients only.

How it works

  1. Discovery. When WebDAV-Push is enabled for a user, every calendar and address book collection exposes the properties transports (containing web-push with the server's vapid-public-key), topic (a stable, opaque identifier of the collection) and supported-triggers (content-update with depth 1), and the DAV response header contains webdav-push.
  2. Registration. The client obtains a push resource and key material from its push service and POSTs a push-register document to the collection. The middleware validates the request, stores the subscription in the push notification service (transport webpush, client webdav-push) and answers with 201 Created, a Location header pointing to the registration URL and an Expires header with the granted lifetime. Registering the same push resource again for the same collection updates the subscription; DELETE on the registration URL removes it. Expired subscriptions are purged automatically. Every registration, including one made through the generic push notification HTTP API, passes the transport's subscription listener, which enforces the push resource policy, valid key material, the lifetime limit and the per-user cap; the DAV side additionally confines the webdav-push client to WebDAV-Push topics.
  3. Notification. Changes to events, tasks and contacts are picked up through the calendar handler and the legacy event infrastructure, mapped to the affected collections of each user (for contacts, also the aggregated address book that some clients get instead of individual address books) and handed to the push notification service, which buffers and aggregates them like all other notifications. The Web Push transport encrypts a small push-message document naming the collection's topic and POSTs it to the push resource with the TTL, Urgency and Topic headers set. Push services that answer with 404 or 410 cause the subscription to be removed.

The push message contains no calendar or contact data. A client that receives it runs a normal sync-collection report against the collection.

Known limitations

  • Calendars shared across contexts. A calendar that a user of another context shares through the cross-context calendar provider advertises a topic and accepts registrations like any other collection, but changes to it do not trigger push notifications for the sharee. Changes are processed in the sharing context, and the sharee is not known there as an affected user. Clients fall back to their regular synchronization interval for such collections.

Suppressing the echo of own changes

A client that changes a collection itself would be notified about that change like everybody else, and then run a sync that finds nothing new. The draft's Push-Dont-Notify request header avoids that round trip: sent along with the modifying request, it names the registration URL (in the exact form returned in the Location header of the registration response) that is not to be notified about the change. DAVx5 sends it with every PUT and DELETE to a collection it holds a subscription for, and the middleware honors it for calendars:

  • The named registration is excluded through the push notification service's source token, so other devices of the same user and other users sharing the collection are still notified. The registration travels as the calendar stack's push token along with the change event, the same channel the App Suite clients use.
  • This covers calendar changes. Contact and task changes are signaled through the legacy event mechanism, which carries no per-request data, so there the acting device is pushed like everybody else and its follow-up sync simply finds nothing new.
  • The draft also allows several registration URLs and the asterisk (no notifications at all). No known client sends either, and both are ignored, as are invalid values; this is logged at DEBUG level.
  • The push notification service buffers notifications for a few seconds and merges them per collection. If another change to the same collection without the header falls into that window, the merged notification is still delivered, which is correct: there is something the client does not know yet.

Logging

Both bundles log their decisions, so that a missing push can be traced from the client's registration to the delivery attempt. Raise the level of these loggers (e.g. via logconf -a -l DEBUG -n com.openexchange.dav.push):

Logger DEBUG TRACE
com.openexchange.dav.push Each push-register with its outcome (registration id, topic, granted expiration, registration URL, or the precondition and reason), DELETEs of registrations, renewals replacing an existing registration, and how many notifications a calendar or contact/task change produced Why a collection does or does not get a topic, the derived topics, per-user skip reasons (push disabled, no significant change), every generated push-message document and the host data used for the registration URL
com.openexchange.pns.transport.webpush Each delivery attempt with target push service, message size, HTTP status and duration, failed deliveries (which never affect the other subscriptions of the same batch), removal of subscriptions the push service reports as gone, VAPID token creation per push service origin, key (re)loading and the HTTP client policy in effect The payload before encryption, the encryption parameters, per-topic enablement decisions and authorization cache hits

Push resource URIs are capability URLs: only scheme, host and the last characters of the path are logged, at any level. Authentication secrets, private keys, the key material of subscribers and complete VAPID tokens are never logged.

Metrics

The Web Push transport records every push message request in the timer appsuite.webpush.requests, tagged with the host of the push service and the outcome: the HTTP status code of the response, IO_ERROR if the push service could not be reached, or ERROR for other failures. A rising share of 403 outcomes after a VAPID key rotation is expected until the clients have re-registered; IO_ERROR and 5xx outcomes for one host indicate a problem with that push service.

Rejected registrations

A registration is rejected with 403 Forbidden and one of the preconditions defined by the draft:

  • push-not-available: WebDAV-Push is disabled for the user, the Web Push transport has no key pair, or the collection does not support push.
  • invalid-subscription: The request is malformed, the key material is invalid, or the push resource violates the configured policy (not https, host not allowed, local address).
  • no-supported-trigger: The client asked for property updates only, which are not supported.

Clients treat all of these as "no push for this collection" and keep polling.