push: central APNs relay (push.elevenmessenger.com) (#171)
* server: route native push through internal/apns + add relay mode
Extract the APNs provider client (key parse, JWT signing, aps envelope, gone
classification) into internal/apns so the messenger and the new relay share
exactly one copy — the envelope is the E2E contract the iOS NSE mirrors, so a
byte-stability test pins it.
newAPNsClient now picks its transport from env, relay-first:
LCHAT_PUSH_RELAY_URL + _TOKEN -> relaySender (POSTs to push.elevenmessenger.com,
holds no key; the relay's normalised 410 maps back to apns.ErrGone so
prune-on-gone is unchanged); LCHAT_APNS_* -> direct mode (today); neither ->
disabled. apnsFanOut is untouched beyond the sentinel rename.
Also tighten APNs device-token validation to hex (shared ValidDeviceToken,
enforced in Send too): the token is interpolated into the Apple URL path, so
non-hex could inject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* push: add the central APNs relay service + deploy scaffolding
push/ is a small, content-blind APNs signer (sibling of home/): the Apple .p8
lives only here. Instances authenticate with a per-instance bearer token and
POST {device_token, sandbox, thread, blob}; the relay signs and forwards to
Apple. It sees a strict subset of what Apple already sees — never plaintext,
never a decryption key.
- POST /api/push: bearer auth -> per-instance + global rate limit -> 8KB cap ->
hex device-token check; gone-normalised passthrough (Apple 410/BadDeviceToken
-> bare 410 so the instance prunes; internal errors never 410).
- DB-backed admin surface (LCHAT_ADMIN_TOKEN) to mint/list/revoke tokens at
runtime; tokens stored hashed. Fails loud at startup on bad .p8/admin config.
- Content-blind logging (label + outcome + latency only). Port 1749.
- deploy-push.sh mirrors deploy-home.sh (data-safe, disk-preserving);
deploy-instance.sh now forwards LCHAT_PUSH_RELAY_URL/_TOKEN for migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: document the push relay (design + trade-offs)
docs/push-relay.md explains the shape (relay holds the .p8, instances hold a
bearer token), why E2E survives (Apple auth != decryption key), and the three
honest costs: metadata concentration, a single point of failure for iOS push,
and leaked-token spam capped by the E2E blob. A short CLAUDE.md note anchors it
under the load-bearing-encryption rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 files changed,
+1347
−116
CLAUDE.md+10 −0apns.go+87 −111apns_relay_test.go+42 −0deploy-instance.sh+8 −3deploy-push.sh+63 −0docs/push-relay.md+115 −0internal/apns/apns.go+199 −0internal/apns/apns_test.go+119 −0main.go+4 −2push/Dockerfile+21 −0push/app.toml+34 −0push/db.go+144 −0push/main.go+312 −0push/push_test.go+189 −0
diff --git a/CLAUDE.md b/CLAUDE.md| index 212e9bf..968cff3 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -43,6 +43,16 @@ Save-passkey card — so they can't be E2E-encrypted like profile/group photos. |
| Keep branding to this narrow set; anything that is *user* content still gets |
| encrypted client-side. |
| +Native iOS push goes through a central APNs relay (`push.elevenmessenger.com`, |
| +`push/`) so the Apple `.p8` signing key lives in exactly one place instead of on |
| +every instance. Like the admin token, the `.p8` is *auth*, not a decryption key: |
| +the relay forwards the same ciphertext blob Apple already sees (device token + |
| +opaque thread id + sealed bytes), never plaintext and never a usable key — |
| +instances authenticate to it with a per-instance bearer token. The one copy of |
| +the signer + aps envelope lives in `internal/apns/` (shared by the messenger and |
| +the relay so they can't drift). Web Push is unaffected (per-instance VAPID, no |
| +shared secret). See `docs/push-relay.md`. |
| + |
| ## Architecture |
| Single static Go binary; the frontend and a pure-Go SQLite engine are compiled |
diff --git a/apns.go b/apns.go| index 1f7141e..af2b9ea 100644 |
| --- a/apns.go |
| +++ b/apns.go |
| @@ -2,13 +2,7 @@ package main |
| import ( |
| "bytes" |
| - "crypto/ecdsa" |
| - "crypto/rand" |
| - "crypto/sha256" |
| - "crypto/x509" |
| - "encoding/base64" |
| "encoding/json" |
| - "encoding/pem" |
| "errors" |
| "fmt" |
| "io" |
| @@ -18,6 +12,8 @@ import ( |
| "strings" |
| "sync" |
| "time" |
| + |
| + "lchat/internal/apns" |
| ) |
| // APNs support for the native iOS app ("Eleven"). Mirrors the Web Push path: |
| @@ -26,13 +22,21 @@ import ( |
| // Keychain, so the E2EE rule holds end to end (Apple relays ciphertext). |
| // |
| // Configured entirely by env; with no key configured the whole transport is a |
| -// no-op, so instances without an APNs key behave exactly as before. |
| +// no-op, so instances without an APNs key behave exactly as before. Two |
| +// transports (see newAPNsClient): direct mode holds the .p8 and calls Apple |
| +// itself; relay mode holds no key and POSTs to push.elevenmessenger.com, which |
| +// signs on its behalf — the p8 then lives only on the relay. |
| // |
| +// Direct mode: |
| // LCHAT_APNS_KEY — the .p8 auth key (PEM), or base64 of it |
| // LCHAT_APNS_KEY_ID — the 10-char key id |
| // LCHAT_APNS_TEAM_ID — the Apple team id |
| // LCHAT_APNS_TOPIC — the app's bundle id |
| // |
| +// Relay mode (takes precedence when both are set): |
| +// LCHAT_PUSH_RELAY_URL — the relay base, e.g. https://push.elevenmessenger.com |
| +// LCHAT_PUSH_RELAY_TOKEN — this instance's per-instance bearer token |
| +// |
| // Tokens are stored per device-session token (like Web Push subscriptions), so |
| // the same sender/active-user suppression rules apply. A token registered from |
| // a debug build sets sandbox=1 and is delivered via Apple's sandbox gateway. |
| @@ -91,126 +95,98 @@ func (db *DB) deleteAPNsTokensForToken(token string) error { |
| return err |
| } |
| -// apnsClient signs and sends. nil when unconfigured (transport disabled). |
| -type apnsClient struct { |
| - key *ecdsa.PrivateKey |
| - keyID string |
| - teamID string |
| - topic string |
| - http *http.Client |
| - |
| - mu sync.Mutex |
| - jwt string |
| - jwtAge time.Time |
| +// apnsSender abstracts "deliver this ciphertext blob to this device". Two |
| +// implementations, chosen from env by newAPNsClient: directSender signs with a |
| +// local .p8 and calls Apple; relaySender holds no key and POSTs to the central |
| +// push relay. nil means APNs is disabled (Web Push only), exactly as before. |
| +type apnsSender interface { |
| + send(deviceToken string, sandbox bool, threadID, blob string) error |
| } |
| -// newAPNsClient reads the env config; returns nil (and logs why) when absent. |
| -func newAPNsClient() *apnsClient { |
| - raw := os.Getenv("LCHAT_APNS_KEY") |
| - keyID := os.Getenv("LCHAT_APNS_KEY_ID") |
| - teamID := os.Getenv("LCHAT_APNS_TEAM_ID") |
| - topic := os.Getenv("LCHAT_APNS_TOPIC") |
| - if raw == "" || keyID == "" || teamID == "" || topic == "" { |
| - return nil // not configured — Web Push only |
| - } |
| - if !strings.Contains(raw, "BEGIN") { // base64-packed PEM (easier to pass as env) |
| - if dec, err := base64.StdEncoding.DecodeString(raw); err == nil { |
| - raw = string(dec) |
| - } |
| - } |
| - block, _ := pem.Decode([]byte(raw)) |
| - if block == nil { |
| - log.Println("apns: LCHAT_APNS_KEY is not PEM; APNs disabled") |
| - return nil |
| - } |
| - parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) |
| - if err != nil { |
| - log.Println("apns: bad key:", err, "— APNs disabled") |
| - return nil |
| - } |
| - ec, ok := parsed.(*ecdsa.PrivateKey) |
| - if !ok { |
| - log.Println("apns: key is not ECDSA; APNs disabled") |
| - return nil |
| - } |
| - log.Printf("apns: configured (team %s, key %s, topic %s)", teamID, keyID, topic) |
| - return &apnsClient{ |
| - key: ec, keyID: keyID, teamID: teamID, topic: topic, |
| - http: &http.Client{Timeout: 15 * time.Second}, |
| - } |
| -} |
| +// directSender is today's transport: the .p8 lives in this instance's env and |
| +// the shared client (internal/apns) signs + calls Apple directly. |
| +type directSender struct{ c *apns.Client } |
| -// bearer returns a cached provider JWT, re-signed when older than ~50 minutes |
| -// (Apple accepts 20–60; a stale token gets ExpiredProviderToken). |
| -func (c *apnsClient) bearer() (string, error) { |
| - c.mu.Lock() |
| - defer c.mu.Unlock() |
| - if c.jwt != "" && time.Since(c.jwtAge) < 50*time.Minute { |
| - return c.jwt, nil |
| - } |
| - b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } |
| - header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": c.keyID}) |
| - claims, _ := json.Marshal(map[string]any{"iss": c.teamID, "iat": time.Now().Unix()}) |
| - signing := b64(header) + "." + b64(claims) |
| - digest := sha256.Sum256([]byte(signing)) |
| - r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:]) |
| - if err != nil { |
| - return "", err |
| - } |
| - // JOSE wants the raw fixed-width r||s, not ASN.1. |
| - sig := make([]byte, 64) |
| - r.FillBytes(sig[:32]) |
| - s.FillBytes(sig[32:]) |
| - c.jwt = signing + "." + b64(sig) |
| - c.jwtAge = time.Now() |
| - return c.jwt, nil |
| +func (d directSender) send(deviceToken string, sandbox bool, threadID, blob string) error { |
| + return d.c.Send(deviceToken, sandbox, threadID, blob) |
| } |
| -var errAPNsGone = errors.New("apns: token gone") |
| +// relaySender forwards to push.elevenmessenger.com. It posts the same |
| +// {device_token, sandbox, thread, blob} the relay needs — all opaque, no key — |
| +// authenticated with this instance's per-instance bearer token. The relay |
| +// normalises Apple's "token gone" to HTTP 410, which we map back to |
| +// apns.ErrGone so apnsFanOut's existing prune-on-gone path is untouched. Any |
| +// other non-2xx (relay auth/rate-limit/error) is a plain error and NEVER a |
| +// prune signal. |
| +type relaySender struct { |
| + url string // full endpoint, e.g. https://push.elevenmessenger.com/api/push |
| + token string |
| + http *http.Client |
| +} |
| -// send posts one alert to one device. mutable-content lets the app's |
| -// notification extension decrypt and rewrite the placeholder text. |
| -func (c *apnsClient) send(deviceToken string, sandbox bool, threadID, blob string) error { |
| - payload, _ := json.Marshal(map[string]any{ |
| - "aps": map[string]any{ |
| - "alert": map[string]string{"title": "New message"}, |
| - "mutable-content": 1, |
| - "sound": "default", |
| - "thread-id": threadID, |
| - }, |
| - // Same keys as the Web Push payload (sw.js) — the NSE mirrors it. |
| - "thread": threadID, |
| - "blob": blob, |
| +func (r relaySender) send(deviceToken string, sandbox bool, threadID, blob string) error { |
| + body, _ := json.Marshal(map[string]any{ |
| + "device_token": deviceToken, "sandbox": sandbox, "thread": threadID, "blob": blob, |
| }) |
| - host := "https://api.push.apple.com" |
| - if sandbox { |
| - host = "https://api.sandbox.push.apple.com" |
| - } |
| - req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(payload)) |
| + req, err := http.NewRequest("POST", r.url, bytes.NewReader(body)) |
| if err != nil { |
| return err |
| } |
| - token, err := c.bearer() |
| - if err != nil { |
| - return err |
| - } |
| - req.Header.Set("authorization", "bearer "+token) |
| - req.Header.Set("apns-topic", c.topic) |
| - req.Header.Set("apns-push-type", "alert") |
| - req.Header.Set("apns-priority", "10") |
| - resp, err := c.http.Do(req) |
| + req.Header.Set("authorization", "Bearer "+r.token) |
| + req.Header.Set("content-type", "application/json") |
| + resp, err := r.http.Do(req) |
| if err != nil { |
| return err |
| } |
| defer resp.Body.Close() |
| - if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
| + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512)) // drain for reuse |
| + switch { |
| + case resp.StatusCode >= 200 && resp.StatusCode < 300: |
| return nil |
| + case resp.StatusCode == http.StatusGone: |
| + return apns.ErrGone |
| + default: |
| + return fmt.Errorf("apns relay: status %d", resp.StatusCode) |
| } |
| - body, _ := io.ReadAll(io.LimitReader(resp.Body, 400)) |
| - if resp.StatusCode == http.StatusGone || bytes.Contains(body, []byte("BadDeviceToken")) { |
| - return errAPNsGone |
| +} |
| + |
| +// newAPNsClient picks the APNs transport from env, relay first: |
| +// |
| +// LCHAT_PUSH_RELAY_URL + LCHAT_PUSH_RELAY_TOKEN → relay mode (no p8 here) |
| +// LCHAT_APNS_KEY + _KEY_ID + _TEAM_ID + _TOPIC → direct mode (p8 in this env) |
| +// neither → disabled (Web Push only) |
| +// |
| +// Relay-wins lets an instance migrate in two safe steps: set the relay vars (it |
| +// flips to relay while any leftover p8 sits unused), then drop the p8 later. |
| +func newAPNsClient() apnsSender { |
| + if url := os.Getenv("LCHAT_PUSH_RELAY_URL"); url != "" { |
| + if tok := os.Getenv("LCHAT_PUSH_RELAY_TOKEN"); tok != "" { |
| + log.Printf("apns: relay mode via %s", url) |
| + return relaySender{ |
| + url: strings.TrimRight(url, "/") + "/api/push", |
| + token: tok, |
| + http: &http.Client{Timeout: 20 * time.Second}, |
| + } |
| + } |
| + log.Println("apns: LCHAT_PUSH_RELAY_URL set without LCHAT_PUSH_RELAY_TOKEN — ignoring") |
| + } |
| + c, err := apns.New( |
| + os.Getenv("LCHAT_APNS_KEY"), |
| + os.Getenv("LCHAT_APNS_KEY_ID"), |
| + os.Getenv("LCHAT_APNS_TEAM_ID"), |
| + os.Getenv("LCHAT_APNS_TOPIC"), |
| + ) |
| + if err != nil { |
| + // Incomplete config = APNs off (Web Push only), as before. Only shout |
| + // when SOME key material was present but wrong, to aid debugging. |
| + if os.Getenv("LCHAT_APNS_KEY") != "" { |
| + log.Println("apns:", err, "— APNs disabled") |
| + } |
| + return nil |
| } |
| - return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body) |
| + log.Printf("apns: configured (team %s, key %s, topic %s)", |
| + os.Getenv("LCHAT_APNS_TEAM_ID"), os.Getenv("LCHAT_APNS_KEY_ID"), c.Topic()) |
| + return directSender{c: c} |
| } |
| // apnsFanOut applies the selectRecipients rules to APNs tokens (keep the filter |
| @@ -262,7 +238,7 @@ func (s *Server) apnsFanOut(threadID, blob, exceptToken string, skipActive bool, |
| mu.Lock() |
| sent++ |
| mu.Unlock() |
| - case errors.Is(err, errAPNsGone): |
| + case errors.Is(err, apns.ErrGone): |
| _ = s.db.deleteAPNsToken(t.APNsToken) |
| default: |
| log.Println("apns:", err) |
diff --git a/apns_relay_test.go b/apns_relay_test.go| new file mode 100644 |
| index 0000000..a37b8b7 |
| --- /dev/null |
| +++ b/apns_relay_test.go |
| @@ -0,0 +1,42 @@ |
| +package main |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "lchat/internal/apns" |
| +) |
| + |
| +// relaySender must map the relay's normalised 410 back to apns.ErrGone so |
| +// apnsFanOut prunes the dead token — and must NOT treat any other non-2xx as |
| +// gone (that would prune a live token on a transient relay error). |
| +func TestRelaySenderClassifies(t *testing.T) { |
| + var status int |
| + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if got := r.Header.Get("Authorization"); got != "Bearer tok" { |
| + t.Errorf("missing bearer: %q", got) |
| + } |
| + w.WriteHeader(status) |
| + })) |
| + defer stub.Close() |
| + rs := relaySender{url: stub.URL + "/api/push", token: "tok", http: http.DefaultClient} |
| + |
| + status = http.StatusOK |
| + if err := rs.send("aabbccdd", false, "th", "blob"); err != nil { |
| + t.Errorf("200: want nil, got %v", err) |
| + } |
| + status = http.StatusGone |
| + if err := rs.send("aabbccdd", false, "th", "blob"); !errors.Is(err, apns.ErrGone) { |
| + t.Errorf("410: want ErrGone, got %v", err) |
| + } |
| + status = http.StatusTooManyRequests |
| + if err := rs.send("aabbccdd", false, "th", "blob"); err == nil || errors.Is(err, apns.ErrGone) { |
| + t.Errorf("429: want a non-gone error, got %v", err) |
| + } |
| + status = http.StatusBadGateway |
| + if err := rs.send("aabbccdd", false, "th", "blob"); err == nil || errors.Is(err, apns.ErrGone) { |
| + t.Errorf("502: want a non-gone error, got %v", err) |
| + } |
| +} |
diff --git a/deploy-instance.sh b/deploy-instance.sh| index 549381b..bb6b273 100755 |
| --- a/deploy-instance.sh |
| +++ b/deploy-instance.sh |
| @@ -35,10 +35,15 @@ sed -e "s/^name = \"lchat\"\$/name = \"$INSTANCE\"/" \ |
| "$REPO/.miren/app.toml" > "$TMP/.miren/app.toml" |
| echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)" |
| -# APNs for the native app: forward the LCHAT_APNS_* vars when the caller has |
| -# them set (the transport is a no-op on instances deployed without them). |
| +# APNs for the native app: forward whichever transport the caller configured. |
| +# Relay mode (LCHAT_PUSH_RELAY_URL + _TOKEN) points the instance at |
| +# push.elevenmessenger.com and needs no .p8 here; direct mode forwards the raw |
| +# LCHAT_APNS_* signing key (legacy — the p8 lives on the box). The app prefers |
| +# relay when both are set, so an instance can migrate in two steps. All are |
| +# no-ops when unset. |
| EXTRA=() |
| -for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC; do |
| +for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC \ |
| + LCHAT_PUSH_RELAY_URL LCHAT_PUSH_RELAY_TOKEN; do |
| [ -n "${!v:-}" ] && EXTRA+=(-e "$v=${!v}") |
| done |
| miren deploy -d "$TMP" -C "$CLUSTER" -f \ |
diff --git a/deploy-push.sh b/deploy-push.sh| new file mode 100755 |
| index 0000000..23abec5 |
| --- /dev/null |
| +++ b/deploy-push.sh |
| @@ -0,0 +1,63 @@ |
| +#!/usr/bin/env bash |
| +# Deploy the push relay (push/ — the central APNs signer) as its own Miren app. |
| +# Mirrors deploy-home.sh: templates a throwaway app.toml in a temp dir so git |
| +# stays clean; a plain redeploy PRESERVES the disk (which holds the minted |
| +# per-instance tokens — never delete the app to update it). |
| +# |
| +# The Apple .p8 signing key lives ONLY on this app. It is read from the caller's |
| +# env (LCHAT_APNS_KEY + _KEY_ID / _TEAM_ID / _TOPIC — same names deploy-instance.sh |
| +# uses) and passed with `-e` (Miren doesn't inject `-s` sensitive vars into the |
| +# container). Pass LCHAT_APNS_KEY base64-packed; the relay decodes it. |
| +# |
| +# Usage: ./deploy-push.sh <instance> <hostname> [admin-token] |
| +# ./deploy-push.sh push push.elevenmessenger.com |
| +# |
| +# Reuse the admin token on redeploy (omitting it mints a new one, which would |
| +# invalidate the saved token). Recover it with: |
| +# miren env get LCHAT_ADMIN_TOKEN -u -a push -C <cluster> |
| +set -euo pipefail |
| + |
| +INSTANCE="${1:?usage: deploy-push.sh <instance> <hostname> [admin-token]}" |
| +HOST="${2:?usage: deploy-push.sh <instance> <hostname> [admin-token]}" |
| +TOKEN="${3:-$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=')}" |
| +CLUSTER="${MIREN_CLUSTER:-luanchat}" |
| + |
| +for v in LCHAT_APNS_KEY LCHAT_APNS_KEY_ID LCHAT_APNS_TEAM_ID LCHAT_APNS_TOPIC; do |
| + [ -n "${!v:-}" ] || { echo "error: $v must be set (the .p8 signing config lives only on the relay)" >&2; exit 1; } |
| +done |
| + |
| +REPO="$(cd "$(dirname "$0")" && pwd)" |
| +TMP="$(mktemp -d)" |
| +trap 'rm -rf "$TMP"' EXIT |
| + |
| +# Build context = the source, with instance-specific app + disk name. Trim the |
| +# upload context (same reasoning as deploy-home.sh): drop the native ios/ build |
| +# output (~700MB), node_modules, VCS/editor/agent dirs, the local binary, and |
| +# *.db — the local SQLite holding data, which must never leave the machine. |
| +cp -R "$REPO"/. "$TMP"/ |
| +rm -rf "$TMP/.git" "$TMP/ios" "$TMP/.claude" "$TMP/lchat" \ |
| + "$TMP"/*.db "$TMP"/*.db-wal "$TMP"/*.db-shm |
| +find "$TMP" -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true |
| +cp "$REPO/push/Dockerfile" "$TMP/Dockerfile.push" |
| +sed -e "s/^name = \"push\"\$/name = \"$INSTANCE\"/" \ |
| + -e "s/^name = \"push-data\"\$/name = \"${INSTANCE}-data\"/" \ |
| + "$REPO/push/app.toml" > "$TMP/.miren/app.toml" |
| + |
| +echo "Deploying '$INSTANCE' (disk: ${INSTANCE}-data, host: $HOST, cluster: $CLUSTER)" |
| +miren deploy -d "$TMP" -C "$CLUSTER" -f \ |
| + -e LCHAT_PUSH_DB=/data/push.db \ |
| + -e "LCHAT_ADMIN_TOKEN=$TOKEN" \ |
| + -e "LCHAT_APNS_KEY=$LCHAT_APNS_KEY" \ |
| + -e "LCHAT_APNS_KEY_ID=$LCHAT_APNS_KEY_ID" \ |
| + -e "LCHAT_APNS_TEAM_ID=$LCHAT_APNS_TEAM_ID" \ |
| + -e "LCHAT_APNS_TOPIC=$LCHAT_APNS_TOPIC" |
| +miren route set "$HOST" "$INSTANCE" -C "$CLUSTER" |
| + |
| +echo |
| +echo "✅ '$INSTANCE' deployed and routed to $HOST" |
| +echo " Recover the admin token: miren env get LCHAT_ADMIN_TOKEN -u -a $INSTANCE -C $CLUSTER" |
| +echo " Health check: https://$HOST/healthz" |
| +echo " Mint a per-instance token (store it in the instance's 1Password item):" |
| +echo " TOK=\$(miren env get LCHAT_ADMIN_TOKEN -u -a $INSTANCE -C $CLUSTER)" |
| +echo " curl -sX POST https://$HOST/api/admin/tokens -H \"Authorization: Bearer \$TOK\" -d '{\"label\":\"<host>\"}'" |
| +echo " Last step (if needed): add DNS $HOST A -> <server IP> (TLS auto-provisions after)." |
diff --git a/docs/push-relay.md b/docs/push-relay.md| new file mode 100644 |
| index 0000000..37c0f6b |
| --- /dev/null |
| +++ b/docs/push-relay.md |
| @@ -0,0 +1,115 @@ |
| +# The push relay — sharing APNs without sharing the signing key |
| + |
| +Status: **shipped** (`push/`, deployed as `push.elevenmessenger.com`). This is |
| +the design note; the code is the source of truth. |
| + |
| +## The problem |
| + |
| +Native iOS push (`apns.go`) needs Apple's `.p8` signing key. Every lchat |
| +instance pushes to the **same** Apple app (`com.elevenmessenger.messenger`) via |
| +the **same** team's key, so `deploy-instance.sh` used to copy that `.p8` into |
| +**every** instance's env. Two consequences: |
| + |
| +- **Wide blast radius.** The Apple signing key sat on every box. Leak one |
| + instance's env and you can push to any device registered for the app. |
| +- **A blocker for self-hosting.** You can't let a third party run an instance |
| + that sends native push without handing them your Apple identity. |
| + |
| +## The shape |
| + |
| +A small central service — `push/`, one static Go binary like `home/` — that is |
| +the **only place the `.p8` lives**. An instance authenticates with a |
| +per-instance bearer token and POSTs the same `{device_token, sandbox, thread, |
| +blob}` it would otherwise send to Apple; the relay signs the provider JWT and |
| +forwards it. Instances never hold signing capability. |
| + |
| +``` |
| +instance ──(Bearer instance-token)──▶ push.elevenmessenger.com ──(signed JWT)──▶ Apple ──▶ device |
| + {device_token, sandbox, (holds the .p8; (relays |
| + thread, blob=ciphertext} signs per request) ciphertext) |
| +``` |
| + |
| +The messenger picks its transport from env (`newAPNsClient`, `apns.go`), relay |
| +first: `LCHAT_PUSH_RELAY_URL` + `LCHAT_PUSH_RELAY_TOKEN` → relay mode; |
| +`LCHAT_APNS_*` → direct mode (legacy, `.p8` on the box); neither → disabled. |
| +Relay-wins lets an instance migrate in two steps — add the relay vars, then drop |
| +the `.p8` later. |
| + |
| +## Why E2E encryption survives untouched |
| + |
| +The `.p8` is Apple **auth**, not a message-decryption key — the same category as |
| +the admin token in the CLAUDE.md rule. The relay sees a device token, an opaque |
| +`thread` id, a `sandbox` flag, and the **ciphertext** `blob`. That is a *strict |
| +subset of what Apple already sees per notification*. It never sees plaintext, a |
| +thread key, the instance key, or a user's private key. The iOS Notification |
| +Service Extension decrypts the blob on-device with keys from the Keychain — the |
| +relay (like Apple) only ever relays sealed bytes. The one authoritative copy of |
| +the envelope + signer lives in `internal/apns/` so the messenger and the relay |
| +can't drift (a `BuildPayload` byte-stability test pins the NSE contract). |
| + |
| +## What you're trading away (the honest costs) |
| + |
| +1. **Metadata concentration.** A central relay is one place where |
| + `{device token ↔ thread ↔ instance ↔ time}` can be correlated across |
| + instances — the same class of concern as `docs/cross-instance-relay.md`. It's |
| + *metadata, not content*, and no more than Apple already sees. Mitigation: the |
| + relay is content-blind and logs only `{instance label, outcome, latency}` — |
| + never the bearer token, device token, `thread`, `blob`, JWT, or `.p8`, and |
| + never its outbound Apple URL (the device token rides in that URL path). It |
| + persists **no push traffic** — the only state is the minted-token table. |
| + |
| +2. **A single point of failure for iOS push.** The relay is stateful (SQLite + |
| + disk → single instance, like `home/`), so an outage pauses iOS notifications |
| + across the fleet. Accepted because push is **best-effort and off the |
| + message-post path** (`pushNewMessage` fans out in a goroutine — posting never |
| + blocks on push), **Web Push is unaffected** (each instance self-generates its |
| + own VAPID keypair and posts directly to browser push services — no shared |
| + secret, so it never needed a relay), and redeploys are quick. Revisit with an |
| + HA / stateless-auth design only if this bites. |
| + |
| +3. **A leaked instance token = notification spam** to device tokens the holder |
| + already knows, carrying attacker bytes — but the NSE AES-GCM-decrypts with |
| + keys the attacker lacks, so it injects **no readable content** (E2E paying |
| + off). The ceiling is spam, bounded by per-instance + global rate limits, and |
| + the token is revocable at runtime. The topic is fixed by the relay's env, so a |
| + token can't target a different app. |
| + |
| +## The `POST /api/push` contract |
| + |
| +- Auth: `Authorization: Bearer <instance-token>` (looked up by `sha256`; miss → |
| + 401). |
| +- Validation order: auth → per-instance rate limit (keyed by instance label, not |
| + IP — instances share platform IPs) → global backstop → body ≤ 8 KB (413) → |
| + `device_token` matches `^[0-9a-fA-F]{1,200}$` (400 — it's interpolated into the |
| + Apple URL path, so non-hex could inject). |
| +- Body: `{"device_token": "<hex>", "sandbox": bool, "thread": "<id>", "blob": |
| + "<base64url ciphertext>"}`. The relay builds the aps envelope and sets |
| + `apns-topic` from its **own** env. |
| +- Response is a **gone-normalised passthrough**: Apple 410 / `BadDeviceToken` → |
| + HTTP **410** (the instance prunes the token); Apple 2xx → 200; anything else → |
| + 502. Relay-internal errors (401/429/400/413/502) are **never** 410 and never |
| + echo `BadDeviceToken`, so a transient failure can't trigger a false prune. |
| + |
| +## Admin surface (mint / list / revoke tokens) |
| + |
| +Gated by the relay's `LCHAT_ADMIN_TOKEN` (constant-time hash compare). Tokens are |
| +minted at runtime — rotation needs no redeploy: |
| + |
| +``` |
| +POST /api/admin/tokens {label} → {id, label, token} (plaintext once) |
| +GET /api/admin/tokens → [{id,label,created_at,last_used_at}] |
| +DELETE /api/admin/tokens/<id> → {ok} |
| +GET /healthz → 200 (no auth, no secrets) |
| +``` |
| + |
| +Startup fails loud (`log.Fatal`) if the `.p8`/APNs env or admin token is missing |
| +or unparseable — a misconfigured relay is a fleet-wide outage, so it must not |
| +boot silently disabled. An empty token table is fine (mint after first deploy). |
| + |
| +## Deploy |
| + |
| +`./deploy-push.sh push push.elevenmessenger.com` (mirrors `deploy-home.sh`; |
| +data-safe redeploys preserve the disk — never delete the app to update it). The |
| +`.p8` + admin token are read from the caller's env and passed with `-e`. DNS |
| +needs an explicit `push.elevenmessenger.com` A record (no wildcard on that zone, |
| +like `home`). Operator specifics live in `CLAUDE.local.md`. |
diff --git a/internal/apns/apns.go b/internal/apns/apns.go| new file mode 100644 |
| index 0000000..eab833b |
| --- /dev/null |
| +++ b/internal/apns/apns.go |
| @@ -0,0 +1,199 @@ |
| +// Package apns holds the one authoritative APNs provider client: it parses the |
| +// Apple .p8 auth key, signs the ES256 provider JWT, builds the alert envelope, |
| +// and posts to Apple. It exists as its own importable package (not package main) |
| +// so BOTH the messenger (direct mode, apns.go) and the push relay (push/) share |
| +// exactly one copy. |
| +// |
| +// The aps envelope BuildPayload produces is a load-bearing E2E contract: the |
| +// iOS Notification Service Extension mirrors it field-for-field to decrypt the |
| +// ciphertext blob on-device. Apple only ever relays that ciphertext — the p8 is |
| +// Apple *auth*, never a message-decryption key — so routing it through a relay |
| +// does not weaken end-to-end encryption. |
| +package apns |
| + |
| +import ( |
| + "bytes" |
| + "crypto/ecdsa" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "encoding/pem" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// ErrGone means Apple reported the device token is dead (HTTP 410 or a |
| +// BadDeviceToken reason). Callers should prune the token. |
| +var ErrGone = errors.New("apns: token gone") |
| + |
| +// ParseKey turns the .p8 auth key — PEM, or base64 of the PEM (easier to pass |
| +// as an env var) — into an ECDSA private key. |
| +func ParseKey(raw string) (*ecdsa.PrivateKey, error) { |
| + if !strings.Contains(raw, "BEGIN") { // base64-packed PEM |
| + if dec, err := base64.StdEncoding.DecodeString(raw); err == nil { |
| + raw = string(dec) |
| + } |
| + } |
| + block, _ := pem.Decode([]byte(raw)) |
| + if block == nil { |
| + return nil, errors.New("apns: key is not PEM") |
| + } |
| + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) |
| + if err != nil { |
| + return nil, fmt.Errorf("apns: bad key: %w", err) |
| + } |
| + ec, ok := parsed.(*ecdsa.PrivateKey) |
| + if !ok { |
| + return nil, errors.New("apns: key is not ECDSA") |
| + } |
| + return ec, nil |
| +} |
| + |
| +// ValidDeviceToken reports whether t looks like an APNs device token: hex, |
| +// 1..200 chars. Rejecting non-hex is load-bearing, not cosmetic: the token is |
| +// interpolated into the request URL path to Apple (/3/device/<token>), so a |
| +// '/', '.', '?', or CR/LF could rewrite that path or smuggle a header. APNs |
| +// tokens are hex, so this is lossless. Send enforces it; callers should too, at |
| +// the point they accept a token, for a clearer error. |
| +func ValidDeviceToken(t string) bool { |
| + if t == "" || len(t) > 200 { |
| + return false |
| + } |
| + for i := 0; i < len(t); i++ { |
| + c := t[i] |
| + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// Client signs and sends. Safe for concurrent use. |
| +type Client struct { |
| + key *ecdsa.PrivateKey |
| + keyID string |
| + teamID string |
| + topic string |
| + http *http.Client |
| + |
| + // APNs gateways, overridable in tests so a stub can stand in for Apple. |
| + prodHost string |
| + sandboxHost string |
| + |
| + mu sync.Mutex |
| + jwt string |
| + jwtAge time.Time |
| +} |
| + |
| +// New builds a Client from the raw key material + identifiers. It returns an |
| +// error (never a silent nil) so each caller decides whether APNs is optional |
| +// (the messenger falls back to Web Push) or fatal (the relay exits). |
| +func New(rawKey, keyID, teamID, topic string) (*Client, error) { |
| + if rawKey == "" || keyID == "" || teamID == "" || topic == "" { |
| + return nil, errors.New("apns: incomplete config (need key, key id, team id, topic)") |
| + } |
| + key, err := ParseKey(rawKey) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &Client{ |
| + key: key, keyID: keyID, teamID: teamID, topic: topic, |
| + http: &http.Client{Timeout: 15 * time.Second}, |
| + prodHost: "https://api.push.apple.com", |
| + sandboxHost: "https://api.sandbox.push.apple.com", |
| + }, nil |
| +} |
| + |
| +// Topic is the app bundle id this client signs for. |
| +func (c *Client) Topic() string { return c.topic } |
| + |
| +// SetHosts overrides the APNs gateways. Tests only — it lets an httptest server |
| +// stand in for Apple. |
| +func (c *Client) SetHosts(prod, sandbox string) { c.prodHost, c.sandboxHost = prod, sandbox } |
| + |
| +// bearer returns a cached provider JWT, re-signed when older than ~50 minutes |
| +// (Apple accepts 20–60; a stale token gets ExpiredProviderToken). |
| +func (c *Client) bearer() (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if c.jwt != "" && time.Since(c.jwtAge) < 50*time.Minute { |
| + return c.jwt, nil |
| + } |
| + b64 := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } |
| + header, _ := json.Marshal(map[string]string{"alg": "ES256", "kid": c.keyID}) |
| + claims, _ := json.Marshal(map[string]any{"iss": c.teamID, "iat": time.Now().Unix()}) |
| + signing := b64(header) + "." + b64(claims) |
| + digest := sha256.Sum256([]byte(signing)) |
| + r, s, err := ecdsa.Sign(rand.Reader, c.key, digest[:]) |
| + if err != nil { |
| + return "", err |
| + } |
| + // JOSE wants the raw fixed-width r||s, not ASN.1. |
| + sig := make([]byte, 64) |
| + r.FillBytes(sig[:32]) |
| + s.FillBytes(sig[32:]) |
| + c.jwt = signing + "." + b64(sig) |
| + c.jwtAge = time.Now() |
| + return c.jwt, nil |
| +} |
| + |
| +// BuildPayload builds the aps envelope. Exported so it can be pinned by a test |
| +// (the NSE mirrors these exact keys — mutable-content lets the app decrypt and |
| +// rewrite the placeholder title; thread/blob mirror the Web Push payload). |
| +func BuildPayload(threadID, blob string) []byte { |
| + payload, _ := json.Marshal(map[string]any{ |
| + "aps": map[string]any{ |
| + "alert": map[string]string{"title": "New message"}, |
| + "mutable-content": 1, |
| + "sound": "default", |
| + "thread-id": threadID, |
| + }, |
| + "thread": threadID, |
| + "blob": blob, |
| + }) |
| + return payload |
| +} |
| + |
| +// Send posts one alert to one device, returning ErrGone when Apple says the |
| +// token is dead so the caller can prune it. |
| +func (c *Client) Send(deviceToken string, sandbox bool, threadID, blob string) error { |
| + if !ValidDeviceToken(deviceToken) { |
| + return errors.New("apns: invalid device token") // never ErrGone — not a prune signal |
| + } |
| + host := c.prodHost |
| + if sandbox { |
| + host = c.sandboxHost |
| + } |
| + req, err := http.NewRequest("POST", host+"/3/device/"+deviceToken, bytes.NewReader(BuildPayload(threadID, blob))) |
| + if err != nil { |
| + return err |
| + } |
| + token, err := c.bearer() |
| + if err != nil { |
| + return err |
| + } |
| + req.Header.Set("authorization", "bearer "+token) |
| + req.Header.Set("apns-topic", c.topic) |
| + req.Header.Set("apns-push-type", "alert") |
| + req.Header.Set("apns-priority", "10") |
| + resp, err := c.http.Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode >= 200 && resp.StatusCode < 300 { |
| + return nil |
| + } |
| + body, _ := io.ReadAll(io.LimitReader(resp.Body, 400)) |
| + if resp.StatusCode == http.StatusGone || bytes.Contains(body, []byte("BadDeviceToken")) { |
| + return ErrGone |
| + } |
| + return fmt.Errorf("apns: status %d body=%q", resp.StatusCode, body) |
| +} |
diff --git a/internal/apns/apns_test.go b/internal/apns/apns_test.go| new file mode 100644 |
| index 0000000..9c53fd2 |
| --- /dev/null |
| +++ b/internal/apns/apns_test.go |
| @@ -0,0 +1,119 @@ |
| +package apns |
| + |
| +import ( |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/x509" |
| + "encoding/pem" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// testKeyPEM returns a throwaway P-256 key as PKCS8 PEM, the shape apns.New |
| +// expects for a real .p8. |
| +func testKeyPEM(t *testing.T) string { |
| + t.Helper() |
| + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + der, err := x509.MarshalPKCS8PrivateKey(key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) |
| +} |
| + |
| +func newTestClient(t *testing.T, stubURL string) *Client { |
| + t.Helper() |
| + c, err := New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app") |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + c.SetHosts(stubURL, stubURL) |
| + return c |
| +} |
| + |
| +func TestValidDeviceToken(t *testing.T) { |
| + ok := []string{"a", "AABBCCDD", strings.Repeat("f", 200)} |
| + for _, s := range ok { |
| + if !ValidDeviceToken(s) { |
| + t.Errorf("expected %q valid", s) |
| + } |
| + } |
| + bad := []string{"", "xyz", "aa/bb", "aa..bb", "aa\r\nHost:", strings.Repeat("a", 201), "zz"} |
| + for _, s := range bad { |
| + if ValidDeviceToken(s) { |
| + t.Errorf("expected %q invalid", s) |
| + } |
| + } |
| +} |
| + |
| +// TestBuildPayloadStable pins the aps envelope byte-for-byte. The iOS |
| +// Notification Service Extension mirrors these exact keys; if this changes, |
| +// on-device decryption breaks — so a drift must be a deliberate, reviewed edit. |
| +func TestBuildPayloadStable(t *testing.T) { |
| + got := string(BuildPayload("t1", "ciphertext-blob")) |
| + want := `{"aps":{"alert":{"title":"New message"},"mutable-content":1,"sound":"default","thread-id":"t1"},"blob":"ciphertext-blob","thread":"t1"}` |
| + if got != want { |
| + t.Errorf("envelope drift:\n got: %s\nwant: %s", got, want) |
| + } |
| +} |
| + |
| +func TestSendClassifiesResponses(t *testing.T) { |
| + const ( |
| + live = "aaaaaaaa" |
| + dead = "deadbeef" |
| + bad = "badd0000" |
| + err5 = "cccccccc" |
| + ) |
| + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case strings.Contains(r.URL.Path, dead): |
| + w.WriteHeader(http.StatusGone) |
| + case strings.Contains(r.URL.Path, bad): |
| + w.WriteHeader(http.StatusBadRequest) |
| + w.Write([]byte(`{"reason":"BadDeviceToken"}`)) |
| + case strings.Contains(r.URL.Path, err5): |
| + w.WriteHeader(http.StatusInternalServerError) |
| + w.Write([]byte(`{"reason":"InternalServerError"}`)) |
| + default: |
| + w.WriteHeader(http.StatusOK) |
| + } |
| + })) |
| + defer stub.Close() |
| + c := newTestClient(t, stub.URL) |
| + |
| + if err := c.Send(live, false, "th", "blob"); err != nil { |
| + t.Errorf("live token: want nil, got %v", err) |
| + } |
| + if err := c.Send(dead, false, "th", "blob"); !errors.Is(err, ErrGone) { |
| + t.Errorf("410: want ErrGone, got %v", err) |
| + } |
| + if err := c.Send(bad, false, "th", "blob"); !errors.Is(err, ErrGone) { |
| + t.Errorf("BadDeviceToken: want ErrGone, got %v", err) |
| + } |
| + if err := c.Send(err5, false, "th", "blob"); err == nil || errors.Is(err, ErrGone) { |
| + t.Errorf("500: want a non-gone error, got %v", err) |
| + } |
| +} |
| + |
| +// A non-hex token must be rejected before it can reach the URL path, and must |
| +// never be classified as gone. |
| +func TestSendRejectsBadToken(t *testing.T) { |
| + reached := false |
| + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })) |
| + defer stub.Close() |
| + c := newTestClient(t, stub.URL) |
| + err := c.Send("aa/../bb", false, "th", "blob") |
| + if err == nil || errors.Is(err, ErrGone) { |
| + t.Errorf("bad token: want a non-gone error, got %v", err) |
| + } |
| + if reached { |
| + t.Error("bad token reached the upstream server — validation is too late") |
| + } |
| +} |
diff --git a/main.go b/main.go| index ca9cc1a..ca9c0af 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -20,6 +20,8 @@ import ( |
| "strings" |
| "sync" |
| "time" |
| + |
| + "lchat/internal/apns" |
| ) |
| // maxUploadBytes caps an encrypted attachment upload: ~25 MB of plaintext (the |
| @@ -59,7 +61,7 @@ type Server struct { |
| homeOrigin string |
| vapidPub string |
| vapidPriv string |
| - apns *apnsClient // nil unless LCHAT_APNS_* env is configured |
| + apns apnsSender // nil unless APNs (direct p8 or relay) is configured |
| // inflight de-duplicates concurrent unfurls of the same URL. |
| inflight sync.Map |
| // active tracks the last time each device token reported being open + focused |
| @@ -2049,7 +2051,7 @@ func (s *Server) apnsSubscribe(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "not a member") |
| return |
| } |
| - if body.APNsToken == "" || len(body.APNsToken) > 200 { |
| + if !apns.ValidDeviceToken(body.APNsToken) { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
diff --git a/push/Dockerfile b/push/Dockerfile| new file mode 100644 |
| index 0000000..e0cb103 |
| --- /dev/null |
| +++ b/push/Dockerfile |
| @@ -0,0 +1,21 @@ |
| +# The push relay: single static Go binary, built from the repo root so the |
| +# shared go.mod resolves. deploy-push.sh copies this file to the build context |
| +# root as "Dockerfile.push". |
| + |
| +# ---- build ---- |
| +FROM golang:1.23-alpine AS build |
| +WORKDIR /src |
| +COPY go.mod go.sum ./ |
| +RUN go mod download |
| +COPY . . |
| +# Pure-Go SQLite (modernc) means we can build a fully static binary with no cgo. |
| +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /push-relay ./push |
| + |
| +# ---- run ---- |
| +# distroless/static: ~2MB, no shell. The SQLite engine is embedded; the only |
| +# state is the minted-token table on the mounted volume. |
| +FROM gcr.io/distroless/static-debian12 |
| +COPY --from=build /push-relay /push-relay |
| +# Runtime config comes from env: LCHAT_PUSH_DB, LCHAT_ADMIN_TOKEN, LCHAT_APNS_*, ADDR. |
| +EXPOSE 1749 |
| +ENTRYPOINT ["/push-relay"] |
diff --git a/push/app.toml b/push/app.toml| new file mode 100644 |
| index 0000000..240389e |
| --- /dev/null |
| +++ b/push/app.toml |
| @@ -0,0 +1,34 @@ |
| +# Miren deployment config template for the push relay (see deploy-push.sh, which |
| +# copies this to .miren/app.toml with the instance name substituted — mirrors |
| +# how deploy-home.sh / deploy-instance.sh template their app.toml). |
| + |
| +name = "push" |
| + |
| +[build] |
| +# deploy-push.sh places push/Dockerfile at the context root under this name. |
| +dockerfile = "Dockerfile.push" |
| + |
| +[services.web] |
| +# Miren terminates TLS and routes external HTTPS to this container port. |
| +# The relay listens on :1749 by default (or $ADDR), so these must match. |
| +port = 1749 |
| + |
| +# A persistent disk requires fixed concurrency, and the disk suits SQLite's |
| +# single-writer model. NOTE: single instance makes the relay a single point of |
| +# failure for iOS push across the fleet — accepted because push is best-effort |
| +# and off the message-post path, and Web Push is unaffected (docs/push-relay.md). |
| +[services.web.concurrency] |
| +mode = "fixed" |
| +num_instances = 1 |
| + |
| +[[services.web.disks]] |
| +name = "push-data" |
| +mount_path = "/data" |
| +size_gb = 1 |
| + |
| +[[env]] |
| +key = "LCHAT_PUSH_DB" |
| +value = "/data/push.db" |
| + |
| +# LCHAT_ADMIN_TOKEN and the LCHAT_APNS_* signing key are secrets set at deploy |
| +# time by deploy-push.sh via `miren deploy -e` (the .p8 lives ONLY on this app). |
diff --git a/push/db.go b/push/db.go| new file mode 100644 |
| index 0000000..558c6b9 |
| --- /dev/null |
| +++ b/push/db.go |
| @@ -0,0 +1,144 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "time" |
| + |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +// DB holds the relay's only state: the set of per-instance bearer tokens the |
| +// admin has minted. It stores NO push traffic — no device tokens, threads, or |
| +// blobs ever touch disk. Tokens are stored hashed (cf. the messenger's admin |
| +// token), so a stolen DB yields no live credential. |
| +type DB struct{ sql *sql.DB } |
| + |
| +func openDB(path string) (*DB, error) { |
| + d, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + // One writer at a time keeps WAL happy and avoids "database is locked". |
| + d.SetMaxOpenConns(1) |
| + for _, p := range []string{ |
| + "PRAGMA journal_mode=WAL", |
| + "PRAGMA busy_timeout=5000", |
| + "PRAGMA synchronous=NORMAL", |
| + } { |
| + if _, err := d.Exec(p); err != nil { |
| + return nil, err |
| + } |
| + } |
| + db := &DB{sql: d} |
| + if err := db.migrate(); err != nil { |
| + return nil, err |
| + } |
| + return db, nil |
| +} |
| + |
| +func (db *DB) migrate() error { |
| + _, err := db.sql.Exec(` |
| +CREATE TABLE IF NOT EXISTS relay_tokens ( |
| + id TEXT PRIMARY KEY, -- random public handle, used to revoke |
| + label TEXT NOT NULL, -- e.g. "family.luan.chat" — for logs/listing, not auth |
| + token_hash TEXT NOT NULL UNIQUE, -- sha256(bearer token); the secret itself is never stored |
| + created_at INTEGER NOT NULL, |
| + last_used_at INTEGER |
| +);`) |
| + return err |
| +} |
| + |
| +func hashToken(t string) string { |
| + h := sha256.Sum256([]byte(t)) |
| + return hex.EncodeToString(h[:]) |
| +} |
| + |
| +func randToken() (string, error) { |
| + b := make([]byte, 32) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + return base64.RawURLEncoding.EncodeToString(b), nil |
| +} |
| + |
| +// RelayToken is a token row for the admin listing. It deliberately never carries |
| +// the secret (only mintToken ever returns that, once). |
| +type RelayToken struct { |
| + ID string `json:"id"` |
| + Label string `json:"label"` |
| + CreatedAt int64 `json:"created_at"` |
| + LastUsedAt *int64 `json:"last_used_at,omitempty"` |
| +} |
| + |
| +// mintToken creates a per-instance token, stores only its hash, and returns the |
| +// plaintext ONCE (the caller shows it to the operator; it's unrecoverable after). |
| +func (db *DB) mintToken(label string) (id, plaintext string, err error) { |
| + if id, err = randToken(); err != nil { |
| + return "", "", err |
| + } |
| + if plaintext, err = randToken(); err != nil { |
| + return "", "", err |
| + } |
| + _, err = db.sql.Exec( |
| + `INSERT INTO relay_tokens(id, label, token_hash, created_at) VALUES(?, ?, ?, ?)`, |
| + id, label, hashToken(plaintext), time.Now().Unix(), |
| + ) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return id, plaintext, nil |
| +} |
| + |
| +// authInstance resolves a bearer token to its instance label, bumping |
| +// last_used_at. Returns ""/false for an empty or unknown token. |
| +func (db *DB) authInstance(token string) (label string, ok bool) { |
| + if token == "" { |
| + return "", false |
| + } |
| + var id string |
| + err := db.sql.QueryRow( |
| + `SELECT id, label FROM relay_tokens WHERE token_hash = ?`, hashToken(token), |
| + ).Scan(&id, &label) |
| + if err != nil { |
| + return "", false |
| + } |
| + // Best-effort; a failed bump doesn't deny an otherwise-valid token. |
| + _, _ = db.sql.Exec(`UPDATE relay_tokens SET last_used_at = ? WHERE id = ?`, time.Now().Unix(), id) |
| + return label, true |
| +} |
| + |
| +func (db *DB) listTokens() ([]RelayToken, error) { |
| + rows, err := db.sql.Query( |
| + `SELECT id, label, created_at, last_used_at FROM relay_tokens ORDER BY created_at`, |
| + ) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []RelayToken{} |
| + for rows.Next() { |
| + var t RelayToken |
| + var last sql.NullInt64 |
| + if err := rows.Scan(&t.ID, &t.Label, &t.CreatedAt, &last); err != nil { |
| + return nil, err |
| + } |
| + if last.Valid { |
| + t.LastUsedAt = &last.Int64 |
| + } |
| + out = append(out, t) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +func (db *DB) revokeToken(id string) (bool, error) { |
| + res, err := db.sql.Exec(`DELETE FROM relay_tokens WHERE id = ?`, id) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, _ := res.RowsAffected() |
| + return n > 0, nil |
| +} |
diff --git a/push/main.go b/push/main.go| new file mode 100644 |
| index 0000000..afab3bd |
| --- /dev/null |
| +++ b/push/main.go |
| @@ -0,0 +1,312 @@ |
| +// The Eleven push relay: a small, content-blind APNs signing service. Every |
| +// messenger instance pushes to the SAME Apple app via the SAME team's .p8, so |
| +// rather than copy that signing key onto every instance (a wide blast radius, |
| +// and a blocker for third-party self-hosting), the key lives ONLY here. An |
| +// instance authenticates with a per-instance bearer token and POSTs the same |
| +// {device_token, sandbox, thread, blob} it would send to Apple; the relay signs |
| +// the provider JWT and forwards it. Instances never hold signing capability. |
| +// |
| +// It preserves end-to-end encryption: the relay sees a device token, an opaque |
| +// thread id, and the ciphertext blob — a strict subset of what Apple already |
| +// sees per notification — never plaintext and never a message-decryption key. |
| +// The .p8 is Apple *auth*, not a decryption key. See docs/push-relay.md. |
| +package main |
| + |
| +import ( |
| + "crypto/subtle" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "log" |
| + "net/http" |
| + "os" |
| + "sync" |
| + "time" |
| + |
| + "lchat/internal/apns" |
| +) |
| + |
| +// maxPushBytes caps a push request body. Apple rejects alert payloads over |
| +// ~4 KB; this leaves generous headroom for the ciphertext blob + envelope and |
| +// fails oversized requests fast at the relay instead of after a wasted Apple |
| +// round-trip. |
| +const maxPushBytes = 8 << 10 |
| + |
| +type Server struct { |
| + db *DB |
| + apns *apns.Client |
| + |
| + adminHash string // sha256 of LCHAT_ADMIN_TOKEN; compared in constant time |
| + |
| + // Rate limits. perInstance keys by instance label so one instance can't |
| + // exhaust another's budget (never by IP — instances egress from shared |
| + // platform IPs; never by device token — unbounded bucket growth). global is |
| + // a fleet-wide backstop against a single compromised token flooding Apple. |
| + perInstance *rateLimiter |
| + global *rateLimiter |
| +} |
| + |
| +func main() { |
| + addr := flag.String("addr", envOr("ADDR", ":1749"), "listen address (env: ADDR)") |
| + dbPath := flag.String("db", envOr("LCHAT_PUSH_DB", "push.db"), "SQLite path (env: LCHAT_PUSH_DB)") |
| + flag.Parse() |
| + |
| + // Fail loud: a misconfigured relay is a fleet-wide iOS-push outage, so a |
| + // missing/bad key or admin token must stop the boot, not silently disable. |
| + admin := os.Getenv("LCHAT_ADMIN_TOKEN") |
| + if admin == "" { |
| + log.Fatal("push: LCHAT_ADMIN_TOKEN is required") |
| + } |
| + client, err := apns.New( |
| + os.Getenv("LCHAT_APNS_KEY"), |
| + os.Getenv("LCHAT_APNS_KEY_ID"), |
| + os.Getenv("LCHAT_APNS_TEAM_ID"), |
| + os.Getenv("LCHAT_APNS_TOPIC"), |
| + ) |
| + if err != nil { |
| + log.Fatalf("push: APNs config: %v", err) |
| + } |
| + |
| + db, err := openDB(*dbPath) |
| + if err != nil { |
| + log.Fatalf("open db: %v", err) |
| + } |
| + s := &Server{ |
| + db: db, |
| + apns: client, |
| + adminHash: hashToken(admin), |
| + // Generous: a legitimate large-group fan-out is a burst of hundreds. The |
| + // limit is a runaway/quota backstop, not a tight boundary (a token can |
| + // only reach device tokens its instance already holds). |
| + perInstance: newRateLimiter(1000, 500), |
| + global: newRateLimiter(4000, 2000), |
| + } |
| + log.Printf("push relay listening on %s (db: %s, topic: %s)", *addr, *dbPath, client.Topic()) |
| + if err := http.ListenAndServe(*addr, s.handler()); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
| + |
| +// handler wires the routes. A method so tests exercise the real router via |
| +// httptest. |
| +func (s *Server) handler() http.Handler { |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) |
| + mux.HandleFunc("GET /", s.root) |
| + |
| + // The one data endpoint: an instance forwards a push. Bearer instance token. |
| + mux.HandleFunc("POST /api/push", s.pushHandler) |
| + |
| + // Admin surface: mint / list / revoke per-instance tokens at runtime |
| + // (rotation needs no redeploy). Gated by LCHAT_ADMIN_TOKEN. |
| + mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintHandler)) |
| + mux.HandleFunc("GET /api/admin/tokens", s.admin(s.listHandler)) |
| + mux.HandleFunc("DELETE /api/admin/tokens/{id}", s.admin(s.revokeHandler)) |
| + |
| + return securityHeaders(mux) |
| +} |
| + |
| +func (s *Server) root(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| + w.Write([]byte("Eleven push relay. Content-blind APNs signer. See docs/push-relay.md.\n")) |
| +} |
| + |
| +// ---- push ------------------------------------------------------------------ |
| + |
| +func (s *Server) pushHandler(w http.ResponseWriter, r *http.Request) { |
| + // Order matters: auth → per-instance limit → global limit → size → shape. |
| + label, ok := s.db.authInstance(bearerToken(r)) |
| + if !ok { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + if !s.perInstance.allow(label) || !s.global.allow("*") { |
| + http.Error(w, "rate limited", http.StatusTooManyRequests) |
| + return |
| + } |
| + var body struct { |
| + DeviceToken string `json:"device_token"` |
| + Sandbox bool `json:"sandbox"` |
| + Thread string `json:"thread"` |
| + Blob string `json:"blob"` |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, maxPushBytes) |
| + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| + var tooLarge *http.MaxBytesError |
| + if errors.As(err, &tooLarge) { |
| + http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) |
| + return |
| + } |
| + http.Error(w, "bad request", http.StatusBadRequest) |
| + return |
| + } |
| + if !apns.ValidDeviceToken(body.DeviceToken) { |
| + http.Error(w, "bad device token", http.StatusBadRequest) |
| + return |
| + } |
| + |
| + start := time.Now() |
| + err := s.apns.Send(body.DeviceToken, body.Sandbox, body.Thread, body.Blob) |
| + latency := time.Since(start).Milliseconds() |
| + switch { |
| + case err == nil: |
| + // Content-blind logging: instance label + outcome + latency ONLY — never |
| + // the device token, thread, blob, or any key material. |
| + log.Printf("push[%s]: ok (%dms)", label, latency) |
| + w.WriteHeader(http.StatusOK) |
| + case errors.Is(err, apns.ErrGone): |
| + // Normalise "token dead" to a bare 410 so the instance prunes it. The |
| + // body must NOT contain "BadDeviceToken" — the instance also sniffs that |
| + // string, so echoing it from a non-gone path would trigger a false prune. |
| + log.Printf("push[%s]: gone (%dms)", label, latency) |
| + http.Error(w, "gone", http.StatusGone) |
| + default: |
| + // Any other failure: log for us (the apns error carries Apple's status |
| + // but no device token), return a generic 502 that is neither 410 nor |
| + // contains "BadDeviceToken", so the instance does not prune a live token. |
| + log.Printf("push[%s]: upstream error (%dms): %v", label, latency, err) |
| + http.Error(w, "upstream error", http.StatusBadGateway) |
| + } |
| +} |
| + |
| +// ---- admin ----------------------------------------------------------------- |
| + |
| +// admin gates a handler behind the relay admin token (constant-time hash |
| +// compare, matching the messenger's checkAdmin). |
| +func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + if subtle.ConstantTimeCompare([]byte(hashToken(bearerToken(r))), []byte(s.adminHash)) != 1 { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + next(w, r) |
| + } |
| +} |
| + |
| +func (s *Server) mintHandler(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Label string `json:"label"` |
| + } |
| + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&body) != nil || body.Label == "" { |
| + http.Error(w, "label required", http.StatusBadRequest) |
| + return |
| + } |
| + id, token, err := s.db.mintToken(body.Label) |
| + if err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + // The plaintext token is returned exactly once; it is unrecoverable after. |
| + writeJSON(w, http.StatusOK, map[string]any{"id": id, "label": body.Label, "token": token}) |
| +} |
| + |
| +func (s *Server) listHandler(w http.ResponseWriter, r *http.Request) { |
| + tokens, err := s.db.listTokens() |
| + if err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"tokens": tokens}) |
| +} |
| + |
| +func (s *Server) revokeHandler(w http.ResponseWriter, r *http.Request) { |
| + deleted, err := s.db.revokeToken(r.PathValue("id")) |
| + if err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + if !deleted { |
| + http.Error(w, "no such token", http.StatusNotFound) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// ---- middleware & helpers -------------------------------------------------- |
| + |
| +// securityHeaders mirrors the messenger's / home's defense-in-depth headers. |
| +func securityHeaders(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + h := w.Header() |
| + h.Set("X-Content-Type-Options", "nosniff") |
| + h.Set("X-Frame-Options", "DENY") |
| + h.Set("Referrer-Policy", "no-referrer") |
| + h.Set("Strict-Transport-Security", "max-age=31536000") |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +// bearerToken pulls the token out of "Authorization: Bearer <t>". |
| +func bearerToken(r *http.Request) string { |
| + const p = "Bearer " |
| + h := r.Header.Get("Authorization") |
| + if len(h) > len(p) && h[:len(p)] == p { |
| + return h[len(p):] |
| + } |
| + return "" |
| +} |
| + |
| +func writeJSON(w http.ResponseWriter, status int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(status) |
| + if err := json.NewEncoder(w).Encode(v); err != nil { |
| + log.Printf("write json: %v", err) |
| + } |
| +} |
| + |
| +func envOr(key, def string) string { |
| + if v := os.Getenv(key); v != "" { |
| + return v |
| + } |
| + return def |
| +} |
| + |
| +// ---- rate limiting (per-key token bucket, copied from home/main.go) -------- |
| + |
| +type rateLimiter struct { |
| + mu sync.Mutex |
| + buckets map[string]*bucket |
| + burst float64 |
| + perSec float64 |
| +} |
| + |
| +type bucket struct { |
| + tokens float64 |
| + last time.Time |
| +} |
| + |
| +func newRateLimiter(burst, perSec float64) *rateLimiter { |
| + return &rateLimiter{buckets: map[string]*bucket{}, burst: burst, perSec: perSec} |
| +} |
| + |
| +func (rl *rateLimiter) allow(key string) bool { |
| + rl.mu.Lock() |
| + defer rl.mu.Unlock() |
| + now := time.Now() |
| + if len(rl.buckets) > 10000 { |
| + for k, b := range rl.buckets { |
| + if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour { |
| + delete(rl.buckets, k) |
| + } |
| + } |
| + } |
| + b, ok := rl.buckets[key] |
| + if !ok { |
| + b = &bucket{tokens: rl.burst, last: now} |
| + rl.buckets[key] = b |
| + } |
| + b.tokens += now.Sub(b.last).Seconds() * rl.perSec |
| + if b.tokens > rl.burst { |
| + b.tokens = rl.burst |
| + } |
| + b.last = now |
| + if b.tokens < 1 { |
| + return false |
| + } |
| + b.tokens-- |
| + return true |
| +} |
diff --git a/push/push_test.go b/push/push_test.go| new file mode 100644 |
| index 0000000..b04d4ce |
| --- /dev/null |
| +++ b/push/push_test.go |
| @@ -0,0 +1,189 @@ |
| +package main |
| + |
| +import ( |
| + "crypto/ecdsa" |
| + "crypto/elliptic" |
| + "crypto/rand" |
| + "crypto/x509" |
| + "encoding/json" |
| + "encoding/pem" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "lchat/internal/apns" |
| +) |
| + |
| +const testAdmin = "test-admin-token" |
| + |
| +// device tokens the stub Apple server treats specially (all valid hex). |
| +const ( |
| + liveDevice = "aaaaaaaa" |
| + deadDevice = "deadbeef" |
| +) |
| + |
| +func testKeyPEM(t *testing.T) string { |
| + t.Helper() |
| + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + der, _ := x509.MarshalPKCS8PrivateKey(key) |
| + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) |
| +} |
| + |
| +// newTestRelay wires the real router (s.handler) over a temp DB, with the APNs |
| +// client pointed at a stub standing in for Apple. |
| +func newTestRelay(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if strings.Contains(r.URL.Path, deadDevice) { |
| + w.WriteHeader(http.StatusGone) |
| + return |
| + } |
| + w.WriteHeader(http.StatusOK) |
| + })) |
| + c, err := apns.New(testKeyPEM(t), "KID1234567", "TEAM123456", "com.example.app") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c.SetHosts(stub.URL, stub.URL) |
| + |
| + db, err := openDB(filepath.Join(t.TempDir(), "push.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s := &Server{ |
| + db: db, apns: c, adminHash: hashToken(testAdmin), |
| + perInstance: newRateLimiter(1000, 500), |
| + global: newRateLimiter(4000, 2000), |
| + } |
| + srv := httptest.NewServer(s.handler()) |
| + t.Cleanup(func() { srv.Close(); stub.Close(); db.sql.Close() }) |
| + return srv |
| +} |
| + |
| +func do(t *testing.T, method, url, bearer, body string) *http.Response { |
| + t.Helper() |
| + var r io.Reader |
| + if body != "" { |
| + r = strings.NewReader(body) |
| + } |
| + rq, _ := http.NewRequest(method, url, r) |
| + if bearer != "" { |
| + rq.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + resp, err := http.DefaultClient.Do(rq) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return resp |
| +} |
| + |
| +// mint creates an instance token via the admin API and returns (id, token). |
| +func mint(t *testing.T, base, label string) (string, string) { |
| + t.Helper() |
| + resp := do(t, "POST", base+"/api/admin/tokens", testAdmin, `{"label":"`+label+`"}`) |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("mint: status %d", resp.StatusCode) |
| + } |
| + var out struct{ ID, Token string } |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out.Token == "" || out.ID == "" { |
| + t.Fatal("mint returned empty id/token") |
| + } |
| + return out.ID, out.Token |
| +} |
| + |
| +func push(t *testing.T, base, token, deviceToken, extra string) int { |
| + t.Helper() |
| + body := `{"device_token":"` + deviceToken + `","thread":"th","blob":"x"` + extra + `}` |
| + resp := do(t, "POST", base+"/api/push", token, body) |
| + resp.Body.Close() |
| + return resp.StatusCode |
| +} |
| + |
| +func TestPushAuth(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + if got := push(t, base, "", liveDevice, ""); got != http.StatusUnauthorized { |
| + t.Errorf("no token: want 401, got %d", got) |
| + } |
| + if got := push(t, base, "bogus-token", liveDevice, ""); got != http.StatusUnauthorized { |
| + t.Errorf("bad token: want 401, got %d", got) |
| + } |
| + _, tok := mint(t, base, "family.luan.chat") |
| + if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK { |
| + t.Errorf("minted token: want 200, got %d", got) |
| + } |
| +} |
| + |
| +func TestPushRevoke(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + id, tok := mint(t, base, "test.luan.chat") |
| + if got := push(t, base, tok, liveDevice, ""); got != http.StatusOK { |
| + t.Fatalf("pre-revoke: want 200, got %d", got) |
| + } |
| + resp := do(t, "DELETE", base+"/api/admin/tokens/"+id, testAdmin, "") |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("revoke: want 200, got %d", resp.StatusCode) |
| + } |
| + if got := push(t, base, tok, liveDevice, ""); got != http.StatusUnauthorized { |
| + t.Errorf("post-revoke: want 401, got %d", got) |
| + } |
| +} |
| + |
| +func TestPushGonePassthrough(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + _, tok := mint(t, base, "test.luan.chat") |
| + // Apple says the token is dead → relay must return a bare 410 so the |
| + // instance prunes it. |
| + if got := push(t, base, tok, deadDevice, ""); got != http.StatusGone { |
| + t.Errorf("dead device: want 410, got %d", got) |
| + } |
| +} |
| + |
| +func TestPushBadDeviceToken(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + _, tok := mint(t, base, "test.luan.chat") |
| + if got := push(t, base, tok, "zznothex", ""); got != http.StatusBadRequest { |
| + t.Errorf("non-hex device token: want 400, got %d", got) |
| + } |
| +} |
| + |
| +func TestPushOversized(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + _, tok := mint(t, base, "test.luan.chat") |
| + // A blob well over maxPushBytes must 413, never 410 (a false prune). |
| + big := `,"pad":"` + strings.Repeat("a", 9000) + `"` |
| + if got := push(t, base, tok, liveDevice, big); got != http.StatusRequestEntityTooLarge { |
| + t.Errorf("oversized: want 413, got %d", got) |
| + } |
| +} |
| + |
| +func TestAdminRequiresToken(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + for _, tc := range []struct{ method, path string }{ |
| + {"POST", "/api/admin/tokens"}, |
| + {"GET", "/api/admin/tokens"}, |
| + {"DELETE", "/api/admin/tokens/x"}, |
| + } { |
| + resp := do(t, tc.method, base+tc.path, "wrong-admin", `{"label":"x"}`) |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Errorf("%s %s without admin: want 401, got %d", tc.method, tc.path, resp.StatusCode) |
| + } |
| + } |
| +} |
| + |
| +func TestHealthz(t *testing.T) { |
| + base := newTestRelay(t).URL |
| + resp := do(t, "GET", base+"/healthz", "", "") |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Errorf("healthz: want 200, got %d", resp.StatusCode) |
| + } |
| +} |