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.webpushadds 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 HTTPPOSTto the push resource the client registered.com.openexchange.dav.pushadds the WebDAV-Push protocol to the CalDAV and CardDAV servlets: thetransports,topicandsupported-triggersproperties on calendar and address book collections, thepush-registerrequest, the registration URLs (DELETEto 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
- Discovery. When WebDAV-Push is enabled for a user, every calendar and address book collection exposes the properties
transports(containingweb-pushwith the server'svapid-public-key),topic(a stable, opaque identifier of the collection) andsupported-triggers(content-updatewith depth 1), and theDAVresponse header containswebdav-push. - Registration. The client obtains a push resource and key material from its push service and
POSTs apush-registerdocument to the collection. The middleware validates the request, stores the subscription in the push notification service (transportwebpush, clientwebdav-push) and answers with201 Created, aLocationheader pointing to the registration URL and anExpiresheader with the granted lifetime. Registering the same push resource again for the same collection updates the subscription;DELETEon 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 thewebdav-pushclient to WebDAV-Push topics. - 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-messagedocument naming the collection's topic andPOSTs it to the push resource with theTTL,UrgencyandTopicheaders set. Push services that answer with404or410cause 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 (nothttps, 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.