Simple push server implementing XEP-0357
  • Go 98.6%
  • Dockerfile 0.8%
  • Makefile 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-21 07:12:06 +03:00
appserver apushd's own names are under urn:tacky:push: the Web Push feature and form urn:tacky:push:webpush:0 with its command urn:tacky:push:webpush:0#key, and the FCM feature and form urn:tacky:push:fcm:0, in place of urn:another-im:webpush:0 and urn:another-im:fcm:0 (the user, 2026-10-21: no protocol name says another-im; the whole stack's extensions live under urn:tacky). No alias for the old names: the apps that read them change in the same release. make test passes. 2026-10-21 07:12:06 +03:00
push APNs collapses a chat's notifications: mod_apush will add apushCollapse to the notification form of a message push, an opaque base64url key the same for one account's chat (an HMAC, which apushd never interprets), and an alert carrying it goes with apns-collapse-id set to it, so iOS replaces that chat's last notification instead of stacking another. appserver's parsePublish reads it beside apushKind into the new push.Payload.Collapse, keeping it only when it is base64url (A-Z, a-z, 0-9, - and _, unpadded) of at most 64 bytes, APNs' own limit, and otherwise forwarding the push without it; the APNs client sets the header on alert pushes that are not calls only, so a PushKit call, a background wake-up and a call shown as an alert never collapse. FCM and Web Push are untouched (Web Push's Topic stays the client's subscription tag). The README documents the field in What it reads beside apushKind, says what APNs does with it in What it sends, and names it among what a push carries in the fpush section. make test passes (every package 100 % but the root's main, 99.9 % in all; new tests: a 64-byte key is forwarded, an empty one, a 65-byte one, padding, + and /, a space and a non-ASCII letter are not and the push still goes; the header is on a message's alert and absent without a key, on a wake-up, on a call through an alert module and on a PushKit call), with 20 s of FuzzStanza in ./appserver; gofmt finds nothing. 2026-10-19 19:26:13 +03:00
xmpp The App Server and the providers in directories of their own, the XEPs in xmpp/, and the end-to-end test back to anotherim-server (the user: "the apushd structure is strange. why disco* not in xmpp"; "maybe move appserver to the separate dir? senders in xmpp? or?"; "yes do it"). The root is the program only: main.go and config.go, which reads settings.json and makes the modules (newModules) for appserver.New. appserver/ is the App Server: appserver.go (Module, Server, New, Serve, the publish becoming a push and the answer), senders.go, limit.go and disco.go, which now says what is announced (the identity, the features, the Web Push key form and command) and has xmpp/ write it. xmpp/ gains what any component could use, with no opinion of its own: ns.go (the stream's and the XEPs' namespaces), disco.go (Info with identities, features and XEP-0128 forms; Items), form.go (Form, Field, FormValue), command.go (Completed, a one-step XEP-0050 answer) and JID.IsDomain, which senders.go's servers-only rule uses; the policy itself stays in appserver/. The providers implement push.Pusher and move under it as push/apns, push/fcm and push/webpush, and jwt/ becomes push/internal/jwt, which only they use. e2e/ and make test-e2e go: the push end to end tests mod_apush and the deployment more than this daemon, and is anotherim-server's make test-push now, on its compose deployment. Nothing changes in settings.json or on the wire. make test passes (every package 100 %, 99.9 % in all: main's os.Exit; new tests for the xmpp builders, IsDomain, New, and Serve ending with its context), with 20 s of FuzzStanza in ./appserver; make image builds, and anotherim-server's make test-push passes 10 of 10 against it. 2026-10-07 12:47:22 +03:00
.dockerignore The App Server and the providers in directories of their own, the XEPs in xmpp/, and the end-to-end test back to anotherim-server (the user: "the apushd structure is strange. why disco* not in xmpp"; "maybe move appserver to the separate dir? senders in xmpp? or?"; "yes do it"). The root is the program only: main.go and config.go, which reads settings.json and makes the modules (newModules) for appserver.New. appserver/ is the App Server: appserver.go (Module, Server, New, Serve, the publish becoming a push and the answer), senders.go, limit.go and disco.go, which now says what is announced (the identity, the features, the Web Push key form and command) and has xmpp/ write it. xmpp/ gains what any component could use, with no opinion of its own: ns.go (the stream's and the XEPs' namespaces), disco.go (Info with identities, features and XEP-0128 forms; Items), form.go (Form, Field, FormValue), command.go (Completed, a one-step XEP-0050 answer) and JID.IsDomain, which senders.go's servers-only rule uses; the policy itself stays in appserver/. The providers implement push.Pusher and move under it as push/apns, push/fcm and push/webpush, and jwt/ becomes push/internal/jwt, which only they use. e2e/ and make test-e2e go: the push end to end tests mod_apush and the deployment more than this daemon, and is anotherim-server's make test-push now, on its compose deployment. Nothing changes in settings.json or on the wire. make test passes (every package 100 %, 99.9 % in all: main's os.Exit; new tests for the xmpp builders, IsDomain, New, and Serve ending with its context), with 20 s of FuzzStanza in ./appserver; make image builds, and anotherim-server's make test-push passes 10 of 10 against it. 2026-10-07 12:47:22 +03:00
.gitignore apushd, the another push daemon: the XEP-0357 App Server for another.im, one Go program on the standard library alone, after Monal's fpush (the user: "Name it apushd (another push daemon)"; "All we need is working pushes on mobiles and web (inc. PWAs) and nothing more"). It connects to the XMPP server as a component (XEP-0114, the handshake and the stanza reader in xmpp.go over encoding/xml, stanzas capped at 256 KiB and 32 levels), takes a publish only from a server or an allowedSenders JID, reads apushEncrypted (the envelope the server sealed to the device) and apushKind (absent for a message, voip for a call, wake for the keepalive) and delivers through APNs (HTTP/2, an ES256 provider token renewed every 40 minutes, hex tokens only; an alert the notification service extension rewrites, a PushKit call not kept for a device that is off, a wake-up as a silent background push and never to PushKit), FCM (HTTP v1 with an RS256 OAuth assertion; messages and calls at high priority, a call kept 60 s, a wake-up at normal priority) or Web Push (RFC 8291 aes128gcm, RFC 8292 VAPID, version 2 bodies of kind message or call, no wake-ups since a page must show every push, endpoints dialled only on public addresses after DNS, no proxy, no redirects). The server is answered result, cancel for a dead token only, or wait; discovery and the Web Push key command answer clients; the rate limit is a burst of 10 then one per 6 s with calls exempt; 512 stanzas may be in flight and a handler that panics is answered with wait. fpush's settings.json loads, less APNs certificate auth and the blocklist and rate-limit timings. make test runs go vet, the tests under the race detector with at least 99 % of the statements covered (99.9 %: all but main's os.Exit; RFC 8291's own example byte for byte, stand-ins for APNs over HTTP/2, Google's OAuth and FCM, a push service and the XMPP server, every handshake failure, the whole run loop), and 20 s of FuzzStanza; make build gives a 7 MB static binary and make image a scratch image with the CA roots. Checked end to end against anotherim-server's ejabberd with mod_apush (its tests/push.sh): connected as push.push.test, a sealed push received and opened with the device's key. 2026-10-07 08:17:27 +03:00
config.go A module with pushType voip sends calls and nothing else, decided in the App Server for every module rather than left to the provider (prod logs showed msg=pushed module=anotherimIOSVoipdev kind=message: a message published to a PushKit token, which the APNs client then dropped in silence while the log said pushed). iOS ends an app that gets a VoIP push and reports no CallKit call, and in the end stops delivering VoIP pushes to it; mod_apush sends only calls to a registration made with apushCalls=only, but one made without it by an older app build still gets messages and wake-ups. config.go marks such a module (Module.VoIP, for an apple or a google module with pushType voip; the FCM discovery flag is now its negation), and appserver's push drops anything but a call for it before the rate limit, logs it at Info as dropped with the module, the token's fingerprint, the kind and the reason "not a call on a voip module", and answers the publish with a result, since an error would make ejabberd's mod_push remove the registration and with it the device's calls. Wake-ups keep going to the module for messages, as a silent background push on APNs and never to PushKit, as before; the APNs client keeps its own guard. The README says it in the settings, in What it sends and in What it answers. make test passes (every package 100 % but the root's main, 99.9 % in all; new tests: a voip module drops 20 messages and 20 wake-ups with a result each, sends nothing, spends none of the device's burst, and still sends a call; New and the settings mark apple and google voip modules), with 20 s of FuzzStanza in ./appserver. 2026-10-19 19:24:18 +03:00
config_test.go A module with pushType voip sends calls and nothing else, decided in the App Server for every module rather than left to the provider (prod logs showed msg=pushed module=anotherimIOSVoipdev kind=message: a message published to a PushKit token, which the APNs client then dropped in silence while the log said pushed). iOS ends an app that gets a VoIP push and reports no CallKit call, and in the end stops delivering VoIP pushes to it; mod_apush sends only calls to a registration made with apushCalls=only, but one made without it by an older app build still gets messages and wake-ups. config.go marks such a module (Module.VoIP, for an apple or a google module with pushType voip; the FCM discovery flag is now its negation), and appserver's push drops anything but a call for it before the rate limit, logs it at Info as dropped with the module, the token's fingerprint, the kind and the reason "not a call on a voip module", and answers the publish with a result, since an error would make ejabberd's mod_push remove the registration and with it the device's calls. Wake-ups keep going to the module for messages, as a silent background push on APNs and never to PushKit, as before; the APNs client keeps its own guard. The README says it in the settings, in What it sends and in What it answers. make test passes (every package 100 % but the root's main, 99.9 % in all; new tests: a voip module drops 20 messages and 20 wake-ups with a result each, sends nothing, spends none of the device's burst, and still sends a call; New and the settings mark apple and google voip modules), with 20 s of FuzzStanza in ./appserver. 2026-10-19 19:24:18 +03:00
Dockerfile The App Server and the providers in directories of their own, the XEPs in xmpp/, and the end-to-end test back to anotherim-server (the user: "the apushd structure is strange. why disco* not in xmpp"; "maybe move appserver to the separate dir? senders in xmpp? or?"; "yes do it"). The root is the program only: main.go and config.go, which reads settings.json and makes the modules (newModules) for appserver.New. appserver/ is the App Server: appserver.go (Module, Server, New, Serve, the publish becoming a push and the answer), senders.go, limit.go and disco.go, which now says what is announced (the identity, the features, the Web Push key form and command) and has xmpp/ write it. xmpp/ gains what any component could use, with no opinion of its own: ns.go (the stream's and the XEPs' namespaces), disco.go (Info with identities, features and XEP-0128 forms; Items), form.go (Form, Field, FormValue), command.go (Completed, a one-step XEP-0050 answer) and JID.IsDomain, which senders.go's servers-only rule uses; the policy itself stays in appserver/. The providers implement push.Pusher and move under it as push/apns, push/fcm and push/webpush, and jwt/ becomes push/internal/jwt, which only they use. e2e/ and make test-e2e go: the push end to end tests mod_apush and the deployment more than this daemon, and is anotherim-server's make test-push now, on its compose deployment. Nothing changes in settings.json or on the wire. make test passes (every package 100 %, 99.9 % in all: main's os.Exit; new tests for the xmpp builders, IsDomain, New, and Serve ending with its context), with 20 s of FuzzStanza in ./appserver; make image builds, and anotherim-server's make test-push passes 10 of 10 against it. 2026-10-07 12:47:22 +03:00
go.mod apushd, the another push daemon: the XEP-0357 App Server for another.im, one Go program on the standard library alone, after Monal's fpush (the user: "Name it apushd (another push daemon)"; "All we need is working pushes on mobiles and web (inc. PWAs) and nothing more"). It connects to the XMPP server as a component (XEP-0114, the handshake and the stanza reader in xmpp.go over encoding/xml, stanzas capped at 256 KiB and 32 levels), takes a publish only from a server or an allowedSenders JID, reads apushEncrypted (the envelope the server sealed to the device) and apushKind (absent for a message, voip for a call, wake for the keepalive) and delivers through APNs (HTTP/2, an ES256 provider token renewed every 40 minutes, hex tokens only; an alert the notification service extension rewrites, a PushKit call not kept for a device that is off, a wake-up as a silent background push and never to PushKit), FCM (HTTP v1 with an RS256 OAuth assertion; messages and calls at high priority, a call kept 60 s, a wake-up at normal priority) or Web Push (RFC 8291 aes128gcm, RFC 8292 VAPID, version 2 bodies of kind message or call, no wake-ups since a page must show every push, endpoints dialled only on public addresses after DNS, no proxy, no redirects). The server is answered result, cancel for a dead token only, or wait; discovery and the Web Push key command answer clients; the rate limit is a burst of 10 then one per 6 s with calls exempt; 512 stanzas may be in flight and a handler that panics is answered with wait. fpush's settings.json loads, less APNs certificate auth and the blocklist and rate-limit timings. make test runs go vet, the tests under the race detector with at least 99 % of the statements covered (99.9 %: all but main's os.Exit; RFC 8291's own example byte for byte, stand-ins for APNs over HTTP/2, Google's OAuth and FCM, a push service and the XMPP server, every handshake failure, the whole run loop), and 20 s of FuzzStanza; make build gives a 7 MB static binary and make image a scratch image with the CA roots. Checked end to end against anotherim-server's ejabberd with mod_apush (its tests/push.sh): connected as push.push.test, a sealed push received and opened with the device's key. 2026-10-07 08:17:27 +03:00
LICENSE apushd, the another push daemon: the XEP-0357 App Server for another.im, one Go program on the standard library alone, after Monal's fpush (the user: "Name it apushd (another push daemon)"; "All we need is working pushes on mobiles and web (inc. PWAs) and nothing more"). It connects to the XMPP server as a component (XEP-0114, the handshake and the stanza reader in xmpp.go over encoding/xml, stanzas capped at 256 KiB and 32 levels), takes a publish only from a server or an allowedSenders JID, reads apushEncrypted (the envelope the server sealed to the device) and apushKind (absent for a message, voip for a call, wake for the keepalive) and delivers through APNs (HTTP/2, an ES256 provider token renewed every 40 minutes, hex tokens only; an alert the notification service extension rewrites, a PushKit call not kept for a device that is off, a wake-up as a silent background push and never to PushKit), FCM (HTTP v1 with an RS256 OAuth assertion; messages and calls at high priority, a call kept 60 s, a wake-up at normal priority) or Web Push (RFC 8291 aes128gcm, RFC 8292 VAPID, version 2 bodies of kind message or call, no wake-ups since a page must show every push, endpoints dialled only on public addresses after DNS, no proxy, no redirects). The server is answered result, cancel for a dead token only, or wait; discovery and the Web Push key command answer clients; the rate limit is a burst of 10 then one per 6 s with calls exempt; 512 stanzas may be in flight and a handler that panics is answered with wait. fpush's settings.json loads, less APNs certificate auth and the blocklist and rate-limit timings. make test runs go vet, the tests under the race detector with at least 99 % of the statements covered (99.9 %: all but main's os.Exit; RFC 8291's own example byte for byte, stand-ins for APNs over HTTP/2, Google's OAuth and FCM, a push service and the XMPP server, every handshake failure, the whole run loop), and 20 s of FuzzStanza; make build gives a 7 MB static binary and make image a scratch image with the CA roots. Checked end to end against anotherim-server's ejabberd with mod_apush (its tests/push.sh): connected as push.push.test, a sealed push received and opened with the device's key. 2026-10-07 08:17:27 +03:00
main.go The App Server and the providers in directories of their own, the XEPs in xmpp/, and the end-to-end test back to anotherim-server (the user: "the apushd structure is strange. why disco* not in xmpp"; "maybe move appserver to the separate dir? senders in xmpp? or?"; "yes do it"). The root is the program only: main.go and config.go, which reads settings.json and makes the modules (newModules) for appserver.New. appserver/ is the App Server: appserver.go (Module, Server, New, Serve, the publish becoming a push and the answer), senders.go, limit.go and disco.go, which now says what is announced (the identity, the features, the Web Push key form and command) and has xmpp/ write it. xmpp/ gains what any component could use, with no opinion of its own: ns.go (the stream's and the XEPs' namespaces), disco.go (Info with identities, features and XEP-0128 forms; Items), form.go (Form, Field, FormValue), command.go (Completed, a one-step XEP-0050 answer) and JID.IsDomain, which senders.go's servers-only rule uses; the policy itself stays in appserver/. The providers implement push.Pusher and move under it as push/apns, push/fcm and push/webpush, and jwt/ becomes push/internal/jwt, which only they use. e2e/ and make test-e2e go: the push end to end tests mod_apush and the deployment more than this daemon, and is anotherim-server's make test-push now, on its compose deployment. Nothing changes in settings.json or on the wire. make test passes (every package 100 %, 99.9 % in all: main's os.Exit; new tests for the xmpp builders, IsDomain, New, and Serve ending with its context), with 20 s of FuzzStanza in ./appserver; make image builds, and anotherim-server's make test-push passes 10 of 10 against it. 2026-10-07 12:47:22 +03:00
main_test.go The App Server and the providers in directories of their own, the XEPs in xmpp/, and the end-to-end test back to anotherim-server (the user: "the apushd structure is strange. why disco* not in xmpp"; "maybe move appserver to the separate dir? senders in xmpp? or?"; "yes do it"). The root is the program only: main.go and config.go, which reads settings.json and makes the modules (newModules) for appserver.New. appserver/ is the App Server: appserver.go (Module, Server, New, Serve, the publish becoming a push and the answer), senders.go, limit.go and disco.go, which now says what is announced (the identity, the features, the Web Push key form and command) and has xmpp/ write it. xmpp/ gains what any component could use, with no opinion of its own: ns.go (the stream's and the XEPs' namespaces), disco.go (Info with identities, features and XEP-0128 forms; Items), form.go (Form, Field, FormValue), command.go (Completed, a one-step XEP-0050 answer) and JID.IsDomain, which senders.go's servers-only rule uses; the policy itself stays in appserver/. The providers implement push.Pusher and move under it as push/apns, push/fcm and push/webpush, and jwt/ becomes push/internal/jwt, which only they use. e2e/ and make test-e2e go: the push end to end tests mod_apush and the deployment more than this daemon, and is anotherim-server's make test-push now, on its compose deployment. Nothing changes in settings.json or on the wire. make test passes (every package 100 %, 99.9 % in all: main's os.Exit; new tests for the xmpp builders, IsDomain, New, and Serve ending with its context), with 20 s of FuzzStanza in ./appserver; make image builds, and anotherim-server's make test-push passes 10 of 10 against it. 2026-10-07 12:47:22 +03:00
Makefile The App Server and the providers in directories of their own, the XEPs in xmpp/, and the end-to-end test back to anotherim-server (the user: "the apushd structure is strange. why disco* not in xmpp"; "maybe move appserver to the separate dir? senders in xmpp? or?"; "yes do it"). The root is the program only: main.go and config.go, which reads settings.json and makes the modules (newModules) for appserver.New. appserver/ is the App Server: appserver.go (Module, Server, New, Serve, the publish becoming a push and the answer), senders.go, limit.go and disco.go, which now says what is announced (the identity, the features, the Web Push key form and command) and has xmpp/ write it. xmpp/ gains what any component could use, with no opinion of its own: ns.go (the stream's and the XEPs' namespaces), disco.go (Info with identities, features and XEP-0128 forms; Items), form.go (Form, Field, FormValue), command.go (Completed, a one-step XEP-0050 answer) and JID.IsDomain, which senders.go's servers-only rule uses; the policy itself stays in appserver/. The providers implement push.Pusher and move under it as push/apns, push/fcm and push/webpush, and jwt/ becomes push/internal/jwt, which only they use. e2e/ and make test-e2e go: the push end to end tests mod_apush and the deployment more than this daemon, and is anotherim-server's make test-push now, on its compose deployment. Nothing changes in settings.json or on the wire. make test passes (every package 100 %, 99.9 % in all: main's os.Exit; new tests for the xmpp builders, IsDomain, New, and Serve ending with its context), with 20 s of FuzzStanza in ./appserver; make image builds, and anotherim-server's make test-push passes 10 of 10 against it. 2026-10-07 12:47:22 +03:00
README.md apushd's own names are under urn:tacky:push: the Web Push feature and form urn:tacky:push:webpush:0 with its command urn:tacky:push:webpush:0#key, and the FCM feature and form urn:tacky:push:fcm:0, in place of urn:another-im:webpush:0 and urn:another-im:fcm:0 (the user, 2026-10-21: no protocol name says another-im; the whole stack's extensions live under urn:tacky). No alias for the old names: the apps that read them change in the same release. make test passes. 2026-10-21 07:12:06 +03:00

apushd

The another push daemon: the App Server of XEP-0357 for another.im. It connects to the XMPP server as a component (XEP-0114), takes the push the server publishes for a device, and delivers it through Apple (APNs), Google (FCM) or Web Push (browsers, installed web apps, UnifiedPush).

It never sees a message. The server's push module seals a short summary to the device's own key; apushd passes that sealed envelope on unread, so Apple, Google and the push services see only that a device was woken.

It is one Go program using only the standard library.

Build and run

make build      # dist/apushd, a static binary
make test       # go vet, the tests with the race detector (99 % covered at least), 20 s of fuzzing
make image      # dev.another.im/another-im/apushd:latest: the binary and the CA roots
make publish    # push it to the registry (log in to dev.another.im first)

Everything builds in a container (podman, else docker).

apushd /etc/apushd/settings.json

The image reads /etc/apushd/settings.json. Beside anotherim-server it runs in the server's compose deployment, which writes the settings and starts it in the server's network namespace; see that repository's README.

A push end to end, with the server's image and real clients, is anotherim-server's make test-push.

APUSHD_LOG=debug logs more. Device tokens are never logged, only a short fingerprint of each (#1a2b3c4d).

Settings

{
  "component": {
    "componentHostname": "push.example.org",
    "componentKey": "the component secret",
    "serverHostname": "localhost",
    "serverPort": 5347
  },
  "pushModules": {
    "anotherimIOS": {
      "type": "apple",
      "apns": {
        "auth": {"type": "token", "keyFilePath": "/etc/apushd/apns.p8",
                 "keyId": "ABC123", "teamId": "DEF456"},
        "topic": "im.example.app",
        "environment": "production"
      }
    },
    "anotherimAndroid": {
      "type": "google",
      "fcm": {"fcmSecretPath": "/etc/apushd/fcm.json"}
    },
    "anotherimWeb": {
      "type": "webpush",
      "webpush": {"vapidPrivateKeyPath": "/etc/apushd/vapid.pem",
                  "subject": "mailto:admin@example.org"}
    }
  }
}
  • The keys of pushModules are the names clients register with (pushModule in their publish-options).
  • type is apple, google, webpush or demo (logs and drops).
  • Calls on iOS go through PushKit, which has its own token: a second apple module with the .voip topic and "pushType": "voip". A module with pushType: "voip" (apple or google) sends calls only (below).
  • isDefaultModule: true on one module makes it take publishes that name none.
  • ratelimit: {"enabled": false} turns the rate limit off for a module.
  • component.allowedSenders lists the JIDs that may publish ("*" for anyone). Without it, only servers may: JIDs with no localpart and no resource.
  • timeout.xmppconnectionError (default 10s) is the shortest time between two connection attempts.

Other keys:

  • APNs: environment is sandbox or production; additionalData adds keys to every payload.
  • FCM: fcmSecretPath is the service account's JSON key from the Firebase console; pushType: "voip" makes a module for calls only.
  • Web Push: vapidPrivateKeyPath is a P-256 key, made with openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt. ttlSeconds (default a day) is how long a push service keeps a push.

What it reads

A XEP-0357 publish. The node is the device token. From the notification's form it reads three fields and ignores every other one:

field meaning
apushEncrypted the sealed envelope, base64, at most 3072 bytes
apushKind absent for a message, voip for an incoming call, wake for a wake-up with nothing to show (the server's keepalive)
apushCollapse on a message only: an opaque key, the same for one account's chat (an HMAC; apushd never interprets it), base64url of at most 64 bytes; a value longer or with other characters is ignored and the push goes without it

What it sends

service message call wake-up
APNs an alert with placeholder text, mutable-content, and apushEncrypted; the app's notification service extension opens it; apushCollapse becomes apns-collapse-id, so a chat's notification replaces its last one instead of stacking through a PushKit module only: a voip push with apushEncrypted, not kept if the phone is off a silent background push; never to PushKit, which takes calls only
FCM a high-priority data message: apushEncrypted the same plus apushKind: voip, kept a minute apushKind: wake, normal priority
Web Push {"v":2,"kind":"message","t":tag,"enc":envelope} encrypted to the browser (RFC 8291), Urgency: high, a Topic so undelivered summaries replace each other kind: call, no topic not sent: a page must show something for every push

A Web Push token is base64url of {"e": endpoint, "k": p256dh, "a": auth, "t": tag}: the browser's subscription, plus a label the client picks per account and gets back in every push. A client finds the VAPID public key in the daemon's disco#info, or by running the command urn:tacky:push:webpush:0#key.

An FCM token is Firebase's registration token, as the app got it. An app finds the module to register it with in the daemon's disco#info: when a google module for messages is configured (not one with pushType: "voip"), the features include urn:tacky:push:fcm:0 and a XEP-0128 form of that FORM_TYPE carries its name in push-module (the first by name, if there are several). Without one there is neither, and the app knows the server cannot push to it through Google.

feature form field when
urn:tacky:push:webpush:0 vapid-public-key, push-module a webpush module
urn:tacky:push:fcm:0 push-module a google module for messages

A module with pushType: "voip" sends calls and nothing else. iOS ends an app that gets a PushKit push and reports no call, and in the end stops giving it any, and the server's module sends only calls to a registration that asked for calls only (apushCalls=only); one made without it, by an older app, still gets messages and wake-ups published to it. apushd drops those itself, logs them (dropped, with the reason not a call on a voip module) and answers with a result, since an error would make the server remove the registration and with it the device's calls. Wake-ups go to a module for messages, as a silent background push on APNs, never to PushKit.

What it answers

ejabberd's mod_push, mod_another_push and Prosody's mod_cloud_notify keep a registration on a wait error and remove it on any other. So:

  • a push taken, dropped by the rate limit, or dropped because it is not a call and the module is for calls: a result;
  • the push service says the token is dead: cancel, and the server drops the registration;
  • anything else (an outage, a timeout, a key of ours the service refuses): wait.

Limits

  • Stanzas over 256 KiB or nested deeper than 32 levels end the stream; the daemon reconnects.
  • 512 pushes may be in flight; past that a publish is told to wait.
  • Each device gets a burst of 10 pushes, then one every 6 seconds. Calls are never limited.
  • A Web Push endpoint must be https on a public address. The address is checked when it is dialled, after DNS. No proxies, no redirects.
  • An APNs token must be hex.

Code

directory what is in it
the root the program: main.go (start, signals, the connection loop) and config.go (settings.json, and the modules it names)
appserver/ the App Server: a publish becomes a push and the server is told what became of it (appserver.go), who may publish (senders.go), what discovery says (disco.go: the Web Push key, the FCM module), the rate limit (limit.go)
xmpp/ the component stream (handshake, reading stanzas, writing answers), JIDs, and the XEPs it speaks in general: disco, data forms, ad-hoc commands, their namespaces
xmpp/xmpptest/ a stand-in XMPP server for the tests
push/ what every push service shares: the payload, the errors, the demo module
push/apns/, push/fcm/, push/webpush/ the three push services
push/internal/jwt/ the ES256 and RS256 signatures APNs and FCM want

xmpp/ has no opinion of its own: what the daemon announces and whom it obeys is appserver/'s, and xmpp/ writes it out. Each file has its tests beside it (*_test.go); make test fails below 99 % of the statements covered. FuzzStanza (appserver/) feeds random stanzas to the reader and the handler.

Coming from Monal's fpush

apushd reads fpush's settings.json, with these differences:

  • Web Push is a module type of its own (webpush), with the VAPID key offered over disco and an ad-hoc command; an FCM module's name is offered over disco too.
  • A push carries the envelope the server sealed to the device (apushEncrypted), its kind (apushKind) and a message's collapse key (apushCollapse), and nothing else of the publish.
  • Calls go to APNs PushKit (pushType: voip) and at high priority to FCM, and are not kept for a device that is off.
  • APNs certificate (.p12) authentication is gone; use a token key (.p8).
  • blacklist and the ratelimit timings are ignored. A dead token is answered with cancel and the server forgets the registration, so there is nothing to block; the rate limit is fixed (above).
  • Only servers may publish unless allowedSenders says otherwise.
  • The default paths are under /etc/apushd/.

Licence

MIT (LICENSE).