- Go 98.6%
- Dockerfile 0.8%
- Makefile 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| appserver | ||
| push | ||
| xmpp | ||
| .dockerignore | ||
| .gitignore | ||
| config.go | ||
| config_test.go | ||
| Dockerfile | ||
| go.mod | ||
| LICENSE | ||
| main.go | ||
| main_test.go | ||
| Makefile | ||
| README.md | ||
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
pushModulesare the names clients register with (pushModulein their publish-options). typeisapple,google,webpushordemo(logs and drops).- Calls on iOS go through PushKit, which has its own token: a second
applemodule with the.voiptopic and"pushType": "voip". A module withpushType: "voip"(appleorgoogle) sends calls only (below). isDefaultModule: trueon one module makes it take publishes that name none.ratelimit: {"enabled": false}turns the rate limit off for a module.component.allowedSenderslists the JIDs that may publish ("*"for anyone). Without it, only servers may: JIDs with no localpart and no resource.timeout.xmppconnectionError(default10s) is the shortest time between two connection attempts.
Other keys:
- APNs:
environmentissandboxorproduction;additionalDataadds keys to every payload. - FCM:
fcmSecretPathis the service account's JSON key from the Firebase console;pushType: "voip"makes a module for calls only. - Web Push:
vapidPrivateKeyPathis a P-256 key, made withopenssl 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). blacklistand theratelimittimings are ignored. A dead token is answered withcanceland the server forgets the registration, so there is nothing to block; the rate limit is fixed (above).- Only servers may publish unless
allowedSenderssays otherwise. - The default paths are under
/etc/apushd/.
Licence
MIT (LICENSE).