WebPush-to-APNs relay for Scatto (push.scatto.social)
  • Go 99.3%
  • Dockerfile 0.7%
Find a file
vinz 45172f7938 Force uid 0 on the database backup copy
Step 3 tells you to back the database up from "an image already on the box",
which is good advice that quietly assumes the image runs as root. Plenty
don't, and `data/` is owned by uid 65532, so the copy dies with

    cp: can't create '/data/relay.db.bak-…': Permission denied

The timing is what makes it worth writing down: the copy happens *after*
`docker compose stop`, so the failure lands with the relay already down and
the outage runs until you notice and retry. It cost about two minutes on the
2026-09-14 deploy, with `keinos/sqlite3` picked as the throwaway image.

`--user 0:0` makes the choice of image stop mattering, which is what the step
was reaching for in the first place. Added to the rollback restore too, which
copies the same direction into the same directory and fails the same way.

`redis:7-alpine` as written was never broken — it is on the box and does run
as root. The trap is only sprung by substituting another image, which this
step explicitly invites, so the example is kept and the flag added rather than
pinning the instruction to one image.

Also adds an `ls -l` on the backup, so a failure is caught at the step that
caused it rather than after the restart.

Verified both ways on the box: the same copy fails without the flag and
succeeds with it.

Kept byte-identical to ~/docu/Relay-Server-DEPLOY.md, which is a copy of this
file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLcABfRAqD95tvasuTibbn
2026-09-14 20:23:15 +02:00
cmd/relay Harden push delivery: dedup badges on retry, drop dead tokens, add limits 2026-07-30 08:05:12 +02:00
deploy Remove unused static legal pages 2026-07-30 07:41:48 +02:00
internal Name the account a forwarded push was sent to 2026-09-14 19:59:50 +02:00
.dockerignore Switch deployment to Docker Compose + host nginx/certbot 2026-07-29 17:37:47 +02:00
.gitignore Correct the runbooks against the box they actually describe 2026-08-05 08:57:02 +02:00
DEPLOY-RELAY.md Remove the handoff notes 2026-08-19 23:46:52 +02:00
DEPLOY.md Force uid 0 on the database backup copy 2026-09-14 20:23:15 +02:00
docker-compose.yml Fix Go build version and resolve host port collision with shop-bot 2026-07-29 18:20:31 +02:00
Dockerfile Fix Go build version and resolve host port collision with shop-bot 2026-07-29 18:20:31 +02:00
go.mod Initial scatto-push-relay: WebPush-to-APNs bridge for Scatto 2026-07-29 17:30:50 +02:00
go.sum Initial scatto-push-relay: WebPush-to-APNs bridge for Scatto 2026-07-29 17:30:50 +02:00
README.md Name the account a forwarded push was sent to 2026-09-14 19:59:50 +02:00
relay.env.example Correct the runbooks against the box they actually describe 2026-08-05 08:57:02 +02:00

scatto-push-relay

A small relay that turns Pixelfed's Web Push notifications into real Apple Push Notification (APNs) pushes for Scatto, a personal iOS Pixelfed client.

Why this exists

Pixelfed (like Mastodon) delivers real-time notifications via the Web Push protocol: the instance encrypts a payload (RFC 8291) and POSTs it to a URL the client registered ("push service" endpoint). Browsers get that URL for free from their vendor's push infrastructure (FCM, Mozilla's push service, etc). A native iOS app has no such thing built in — Apple's push transport is APNs, which is a different, non-Web-Push protocol; third-party apps can't just point Web Push at Apple's servers directly.

This relay is the push service endpoint: it receives the encrypted Web Push payload from Pixelfed, decrypts it, and forwards it to APNs as a native push. It's single-tenant, personal infrastructure — one relay, one Pixelfed account (pix.hever.de), one device.

Architecture

 ┌──────────┐  registers p256dh/auth   ┌────────────────┐  APNs alert   ┌────────┐
 │  Scatto  │ ───────────────────────▶ │ scatto-push-    │ ────────────▶ │  APNs  │
 │  (iOS)   │      keys via /subscribe │ relay (this repo)│               └────────┘
 └────┬─────┘                          └────────┬────────┘                    │
      │ registers subscription                  ▲ POST /wp/{id}               │
      │ (endpoint + keys)                        │  (encrypted, RFC 8291)      │ push
      ▼                                          │                            ▼
 ┌──────────┐  notification happens (like, ┌──────────────┐            ┌──────────┐
 │ Pixelfed │ ─────────────────────────────▶│              │            │  device  │
 │  (pix.   │  follow, comment, etc.)       │  (Pixelfed's │            └──────────┘
 │ hever.de)│                               │  own push    │
 └──────────┘                               │  sender)     │
                                             └──────────────┘
  1. Scatto asks iOS for a device token (registerForRemoteNotifications).
  2. Scatto calls POST /subscribe on this relay with that device token. The relay generates a fresh P-256 keypair + auth secret for the subscription (RFC 8291 §3.1) and returns {endpoint, p256dh, auth}.
  3. Scatto registers those with Pixelfed via POST /api/v1/push/subscription (the Mastodon-compatible Push API).
  4. From then on, whenever something happens on the account, Pixelfed encrypts a payload and POSTs it to this relay's endpoint (/wp/{id}).
  5. The relay decrypts it with the stored private key + auth secret, extracts title/body, bumps the subscription's badge counter, and sends a native APNs alert to the stored device token carrying that badge number plus any deep-link ids from the payload.

Badge counts

The badge is tracked server-side, in the relay, rather than by the app polling for an unread count. Each subscription owns a badge_count column; every forwarded push increments it and puts the new value in the APNs aps.badge field. That means the icon badge is correct the instant the notification arrives, with the app closed and without it ever waking up — iOS applies aps.badge itself, so this needs no client code at all.

The app clears it with POST /subscribe/{id}/read when the user opens their notifications list, and can suppress it entirely with POST /subscribe/{id}/badges. The counter is per subscription, so two devices on one account keep independent badges and clearing one leaves the other alone.

On its own the counter measures the wrong thing. It knows how many pushes the relay has forwarded since the app last said "read", which is not the same quantity as "notifications you have not seen" — reading in the Pixelfed web UI moves the second and not the first. The two had no way to converge, and since iOS applies aps.badge before any app code runs, the relay's figure is the one on screen whenever the app is closed. The drift was therefore both visible and permanent: recovering from it meant reinstalling the app.

So the app reconciles it. Whenever Scatto recomputes its unread count — on launch, on foreground, on opening the notifications list, on a background refresh — it hands each account's real figure to POST /subscribe/{id}/unread. Increments then continue from truth rather than from a tally of their own, and a badge applied while the app is closed is right rather than merely plausible.

The reconciliation is one-directional and best-effort. The relay never asks Pixelfed anything; it is told. A relay older than the route answers 404 and the app carries on, which is what allows the two to be deployed in either order.

Pixelfed's payload may carry status_id and account_id. The relay copies whichever are present into the APNs payload as top-level custom keys alongside aps, which iOS hands the app as UNNotificationContent.userInfo, so tapping a notification can open the post or profile it refers to. They arrive as strings, not numbers: Pixelfed's ids are large enough to lose precision as JSON doubles, and a client parsing them as numbers would silently deep-link to the wrong post. A follow and a follow request have no status_id, and a DM has neither a public status nor (yet) a conversation view in Scatto, so all three deep-link to the sender's profile.

Naming the recipient

Both ids above describe the other party — whoever liked, followed or wrote. Neither says which account was notified, and a device signed in to several accounts holds a subscription per account, so a tapped push gave the app no way to tell which one it belonged to: it opened under whatever account was already active, asked that account's server for an id belonging to another, and either showed a dead error screen or — when the id happened to exist there too, which is the common case on a single instance — a plausible and entirely wrong profile, with no error to give it away.

The relay already knows the answer. Every subscription is created with an opaque account value (see POST /subscribe), which is what keeps one device's accounts from evicting one another. It is now also forwarded as a top-level account key, letting the app compare before it navigates and offer to switch accounts rather than failing.

The relay still never interprets the value; it echoes back exactly what was subscribed with. A subscription created without one — a client predating account scoping — omits the key entirely, so its payload is byte-for-byte what it was before.

The relay does not interpret notification_type beyond picking a sound and deciding whether to collapse; anything the instance sends is forwarded. That is why follow_request needed no relay change when the fork started sending it. The reverse also holds: the relay's reblog/share handling is ready and has never run, because nothing sends a boost push. Closing that is server-side work in the Pixelfed fork, not here.

Decryption happens server-side, on the relay, not on the device via a Notification Service Extension. That's a deliberate simplification: this relay is infrastructure the app owner fully controls, running for exactly one user, so there's no meaningful trust boundary being crossed by having it see plaintext notification text (e.g. "vinz liked your photo") before forwarding it on. It trades a small amount of server-side trust for a much simpler iOS client (no extra app extension target).

API

POST /subscribe

Called by Scatto when the user enables push notifications — once per account, not once per device, so a phone signed in to two accounts registers twice.

Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>

Request:

{
  "device_token": "<hex APNs device token>",
  "label": "vinz-iphone",
  "account": "vinz@scatto.social"
}

account identifies which signed-in account the subscription belongs to. The relay never interprets it; a fully-qualified handle is the obvious choice, but any value stable per account will do so long as two accounts never share one. Omitting it is allowed and reproduces the old single-subscription-per-device behaviour, which is almost certainly not what you want — see below.

Response 201:

{
  "subscription_id": "…",
  "endpoint": "https://push.scatto.social/wp/…",
  "p256dh": "<base64url>",
  "auth": "<base64url>"
}

endpoint, p256dh, and auth map directly onto the fields Pixelfed's POST /api/v1/push/subscription expects (subscription[endpoint], subscription[keys][p256dh], subscription[keys][auth]).

One subscription per account per device. Registering replaces any existing subscription with the same device_token and account. Re-registering is routine — an app reinstall, or push toggled off and on — and each call must not leave the previous row behind. Those orphans are not merely untidy: every one stays a live push endpoint holding a device token, so anyone who kept an old endpoint URL could still push to the device.

The account half of that key is not cosmetic. Replacement used to be keyed on device_token alone, which gave a device exactly one slot across every account and instance it was signed in to: enabling push for a second account destroyed the first account's endpoint, and the losing side went silent with nothing on the device to explain why — its next delivery just 404'd. A client that omits account puts every registration in the same "" bucket and gets that old behaviour back.

Rows per device are capped (10). Per-account scoping removed the bound the unscoped replacement used to provide, and each row is a live endpoint carrying that device's token; past the cap the oldest are evicted.

DELETE /subscribe/{id}

Called by Scatto when the user disables push or removes the account. Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204.

(Scatto should also call Pixelfed's DELETE /api/v1/push/subscription first — this only tears down the relay's half.)

POST /subscribe/{id}/read

Called by Scatto when the user opens the notifications list. Resets that subscription's badge counter to zero.

Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204.

Clearing an id the relay no longer knows about is also 204, not 404 — same as DELETE /subscribe/{id}. A client tidying up a subscription the server has already dropped has nothing to fix, so there is no error worth reporting.

POST /subscribe/{id}/unread

Called by Scatto whenever it recomputes an account's unread count. Sets that subscription's badge counter outright, so the next push counts on from it. Body {"count": 7}.

Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204, including for an unknown id, matching the other subscription routes.

This is the general case of /read, which is the same statement with the count fixed at zero. It exists because the relay can only count what it has forwarded, and that quantity drifts from the server's own notion of unread the moment anything is read outside the app — see Badge counts above.

Per subscription rather than per device: the relay already sums a device's subscriptions when it sends, so each account reports only its own count and the total takes care of itself.

Negative counts are clamped to zero rather than rejected. APNs refuses a negative badge, and one bad call should not leave a subscription poisoned.

Not to be confused with /badges below, which is a different question — that one is whether to show a badge at all, this one is what it should say.

POST /subscribe/{id}/badges

Called by Scatto when the user changes the "show application badge" preference. Body {"enabled": true|false}.

Headers: Authorization: Bearer <SUBSCRIBE_TOKEN>. Response 204, including for an unknown id, matching the other subscription routes.

This has to live server-side because iOS applies aps.badge from the push payload at the OS level, before any app code runs and even while the app is closed — an app-local preference cannot suppress it. When disabled the relay omits aps.badge; the alert, sound and deep-link data are unaffected.

The counter keeps incrementing while badges are suppressed, so re-enabling shows the true unread count rather than resuming from zero.

POST /wp/{id}

Called by Pixelfed, never by Scatto directly. Body is an RFC 8291-encrypted Web Push payload (aes128gcm content coding). No bearer auth on this route — per the Web Push protocol, the unguessable {id} in the URL is the credential, same as any real push service endpoint.

Decrypted payload fields the relay reads:

{
  "notification_type": "like",
  "title": "New Like",
  "body": "vinz liked your post",
  "status_id": "988149955134320972",
  "account_id": "961576165188272129"
}

status_id/account_id are optional; null or absent both mean "no deep-link target" and are simply omitted from the APNs payload. Unknown fields are ignored, so Pixelfed can add more without a relay change.

What reaches APNs is those fields plus account, which comes from the subscription rather than from this body — Pixelfed has no idea a device holds several, so only the relay can say which one it just pushed to:

{
  "aps": { "alert": { "title": "…", "body": "…" }, "badge": 3, "sound": "…" },
  "notification_type": "follow_request",
  "account_id": "961576165188272129",
  "account": "988381240561664001@scatto.social"
}

A failure to bump the badge counter is logged but does not fail the request — the notification still goes out, just without a badge. Losing the alert entirely over a counter is the worse outcome.

Delivery failures are split by whether they're worth retrying. A transient APNs error (503, throttling, a network blip) answers 502: Pixelfed retries the same push, and the relay first reverts the badge bump it made for this attempt, so a notification that took three tries to land still counts as one, not three. A permanent rejection — BadDeviceToken, Unregistered (the app was uninstalled), or DeviceTokenNotForTopic — means the device token will never accept another push, so the relay deletes the subscription and answers 404, the "dead endpoint" signal that tells Pixelfed to stop retrying and forget it. Otherwise a stale token would fail every push forever while the orphaned subscription lingered.

Request bodies are capped (64 KB here, 8 KB on the subscription routes) — comfortably above any real payload, but low enough that a leaked endpoint URL can't be used to exhaust memory with an oversized body.

Rate limited per subscription id: burst of 20, refilling at one every two seconds, answering 429 beyond that. The endpoint URL is the only credential this route has, so a URL that leaked — an old subscription, a log line, a database copy — could otherwise be used to hammer the device indefinitely. Buckets are only created for ids that exist in the store, so unknown ids can't grow the limiter's memory.

A note on SUBSCRIBE_TOKEN

The token is shared with the Scatto app and therefore ships inside the app binary; treat it as public. It gates creating subscriptions, which is what it is good for. It is deliberately not the only thing protecting DELETE /subscribe/{id} or POST /subscribe/{id}/read — both also require the subscription id, which is 128 bits of unguessable randomness and never leaves the device that owns it. If this relay is ever pointed at more than one user, that reasoning stops holding and the token needs to become a real per-account credential.

GET /healthz

Plain 200 ok — used for deploy verification and uptime checks.

Repo layout

cmd/relay/          main.go — wiring only
internal/webpush/   RFC 8291 / RFC 8188 receiver-side crypto (keygen + decrypt)
internal/store/     SQLite-backed subscription storage
internal/apns/      APNs token-auth client (wraps sideshow/apns2)
internal/server/    HTTP handlers
internal/config/    env-var config loading
Dockerfile          multi-stage build -> distroless nonroot image
docker-compose.yml  the deployed service definition
relay.env.example   env template (copy to ./relay.env, not committed)
deploy/             nginx vhost snippet for host nginx + certbot

Local development

go build ./...
go test ./...

internal/webpush has a self-contained encrypt/decrypt round-trip test (decrypt_test.go) that independently implements the RFC 8291 sender role to validate the receiver logic this service actually ships — this is the part worth trusting before deploying, since a subtle bug there just means silently-dropped notifications with no obvious symptom.

internal/store covers the badge counter, including that it survives a restart, stays independent per subscription, and that Open upgrades a database created before badge support existed.

The binary is CGO-free (modernc.org/sqlite is a pure-Go SQLite driver), so it cross-compiles trivially and needs no libc in its container image:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o relay ./cmd/relay

Changing the schema

store.Open creates the table with CREATE TABLE IF NOT EXISTS, which does nothing at all to a database that already has that table. Adding a column to that statement therefore only affects fresh installs; a deployed relay keeps its old schema and every query touching the new column fails at runtime with no such column.

New columns need an explicit migration alongside the CREATE TABLE, run on every start and safe to repeat — see ensureColumn, which checks PRAGMA table_info and issues an ALTER TABLE ... ADD COLUMN only when the column is missing (used for both badge_count and badges_enabled). TestOpenAddsBadgeCountToPreExistingDatabase and TestOpenAddsBadgesEnabledToPreExistingDatabase each build a pre-migration database and assert the upgrade, so this stays honest.

Deployment

Runs as a Docker Compose service (Dockerfile builds a multi-stage, distroless, nonroot image — no shell, no package manager, nothing beyond the static binary) behind host nginx + certbot, matching how the rest of this VPS's services are already deployed. See DEPLOY.md for the full runbook, docker-compose.yml for the service definition, and deploy/nginx-push.scatto.social.conf for the vhost.