From 320357079dc3149be572426ea47feea07f67daad Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:18:05 +0100 Subject: [PATCH] chore: bring the live relay into the repository --- docker-compose.yml | 2 + src/apns.test.ts | 157 +++++++++++++++++++++++++++++ src/apns.ts | 196 +++++++++++++++++++++++++++++++++++++ src/fcm.test.ts | 103 +++++++++++++++++++ src/fcm.ts | 124 ++++++++++++++++++----- src/forward-result.test.ts | 77 +++++++++++++++ src/forward-result.ts | 65 ++++++++++++ src/metrics.ts | 11 ++- src/server.ts | 128 +++++++++++++++++++++--- src/store.ts | 6 +- src/types.ts | 15 ++- src/unifiedpush.ts | 26 ++--- src/validation.ts | 10 ++ src/webpush.ts | 13 +-- 14 files changed, 867 insertions(+), 66 deletions(-) create mode 100644 src/apns.test.ts create mode 100644 src/apns.ts create mode 100644 src/fcm.test.ts create mode 100644 src/forward-result.test.ts create mode 100644 src/forward-result.ts diff --git a/docker-compose.yml b/docker-compose.yml index b23884f..d1491a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: - 127.0.0.1:3003:3003 volumes: - ./data:/app/data + env_file: + - .env environment: - NODE_ENV=production - PORT=3003 diff --git a/src/apns.test.ts b/src/apns.test.ts new file mode 100644 index 0000000..46785f7 --- /dev/null +++ b/src/apns.test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import http2 from 'node:http2'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { + apnsJwt, + apnsPayload, + classifyApnsResponse, + resetApnsState, + sendApnsPush, + type ApnsConfig, +} from './apns.js'; +import type { ApnsSubscriptionRecord, ForwardPayload } from './types.js'; + +const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +const config: ApnsConfig = { + keyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + keyId: 'KEY1234567', + teamId: 'TEAM123456', + topic: 'org.bulwarkmail.mobile', +}; + +const payload: ForwardPayload = { + kind: 'jmap-email-push', + accountLabel: 'ada@example.org', + accountId: 'c', + emailIds: ['e1', 'e2'], + changed: { c: { EmailDelivery: 's9' } }, +}; + +const token = 'ab'.repeat(32); +const record: ApnsSubscriptionRecord = { + kind: 'apns', + deviceToken: token, + environment: 'sandbox', + verificationCode: null, + createdAt: 0, + lastPushAt: null, +}; + +describe('apnsPayload', () => { + it('carries ids and a placeholder text, nothing about the message', () => { + const body = apnsPayload(payload) as { + aps: { alert: unknown; 'mutable-content': number; 'thread-id': string }; + body: Record; + }; + assert.deepEqual(Object.keys(body).sort(), ['aps', 'body']); + assert.deepEqual(body.aps.alert, { title: 'Bulwark Mail', body: 'New message' }); + // The extension only runs for mutable notifications. + assert.equal(body.aps['mutable-content'], 1); + assert.equal(body.aps['thread-id'], 'c'); + assert.deepEqual(body.body, { + kind: 'jmap-email-push', + accountLabel: 'ada@example.org', + accountId: 'c', + emailIds: ['e1', 'e2'], + changed: { c: { EmailDelivery: 's9' } }, + }); + }); +}); + +describe('classifyApnsResponse', () => { + it('accepts 200', () => { + assert.deepEqual(classifyApnsResponse(200, ''), { ok: true, status: 200, unregistered: false, reason: undefined }); + }); + + it('drops tokens APNs calls dead', () => { + assert.equal(classifyApnsResponse(410, '{"reason":"Unregistered"}').unregistered, true); + assert.equal(classifyApnsResponse(400, '{"reason":"BadDeviceToken"}').unregistered, true); + assert.equal(classifyApnsResponse(400, '{"reason":"DeviceTokenNotForTopic"}').unregistered, true); + }); + + it('keeps the token for errors on our side', () => { + const r = classifyApnsResponse(403, '{"reason":"ExpiredProviderToken"}'); + assert.equal(r.ok, false); + assert.equal(r.unregistered, false); + assert.equal(r.reason, 'ExpiredProviderToken'); + assert.equal(classifyApnsResponse(429, '{"reason":"TooManyRequests"}').unregistered, false); + }); +}); + +describe('apnsJwt', () => { + beforeEach(() => resetApnsState(config)); + + it('signs ES256 with the key id and team id', () => { + const jwt = apnsJwt(config, 1_700_000_000_000); + const [h, c, s] = jwt.split('.'); + assert.deepEqual(JSON.parse(Buffer.from(h, 'base64url').toString()), { alg: 'ES256', kid: 'KEY1234567' }); + assert.deepEqual(JSON.parse(Buffer.from(c, 'base64url').toString()), { iss: 'TEAM123456', iat: 1_700_000_000 }); + const valid = crypto.verify('sha256', Buffer.from(`${h}.${c}`), { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url')); + assert.equal(valid, true); + }); + + it('reuses a token for 40 minutes, then signs a new one', () => { + const first = apnsJwt(config, 1_700_000_000_000); + assert.equal(apnsJwt(config, 1_700_000_000_000 + 39 * 60_000), first); + assert.notEqual(apnsJwt(config, 1_700_000_000_000 + 41 * 60_000), first); + }); +}); + +describe('sendApnsPush', () => { + let server: http2.Http2Server; + let seen: { headers: http2.IncomingHttpHeaders; body: string } | null; + let answer: { status: number; body: string }; + + beforeEach(async () => { + resetApnsState(config); + seen = null; + answer = { status: 200, body: '' }; + server = http2.createServer(); + server.on('stream', (stream, headers) => { + let body = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { body += chunk; }); + stream.on('end', () => { + seen = { headers, body }; + stream.respond({ ':status': answer.status, 'content-type': 'application/json' }); + stream.end(answer.body); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + process.env.APNS_ORIGIN = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + delete process.env.APNS_ORIGIN; + resetApnsState(null); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('posts an alert push for the device token with the bearer token and topic', async () => { + const result = await sendApnsPush(record, payload); + assert.equal(result.ok, true); + assert.ok(seen); + assert.equal(seen.headers[':path'], `/3/device/${token}`); + assert.equal(seen.headers['apns-topic'], 'org.bulwarkmail.mobile'); + assert.equal(seen.headers['apns-push-type'], 'alert'); + assert.match(String(seen.headers.authorization), /^bearer [\w-]+\.[\w-]+\.[\w-]+$/); + assert.deepEqual(JSON.parse(seen.body), apnsPayload(payload)); + }); + + it('reports a removed app so the relay can drop the subscription', async () => { + answer = { status: 410, body: '{"reason":"Unregistered"}' }; + const result = await sendApnsPush(record, payload); + assert.equal(result.unregistered, true); + assert.equal(result.status, 410); + }); + + it('fails without a key instead of sending', async () => { + resetApnsState(null); + const result = await sendApnsPush(record, payload); + assert.equal(result.ok, false); + assert.equal(result.status, 0); + assert.equal(seen, null); + }); +}); diff --git a/src/apns.ts b/src/apns.ts new file mode 100644 index 0000000..20bb232 --- /dev/null +++ b/src/apns.ts @@ -0,0 +1,196 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import http2 from 'node:http2'; +import path from 'node:path'; +import { failureReason, type SendResult } from './forward-result.js'; +import type { ApnsSubscriptionRecord, ForwardPayload } from './types.js'; + +// Direct delivery to Apple Push Notification service for the iOS app. +// +// Token-based auth: a .p8 signing key (APNS_KEY: inline PEM or a path, +// default /apns-key.p8), its key id (APNS_KEY_ID) and the +// team id (APNS_TEAM_ID). The JWT is reused for 40 minutes; Apple rejects +// one older than an hour and throttles fresh ones more often than every 20. +// +// Content-blind like the other transports: the push carries a fixed +// placeholder text and the ids, never a subject or sender. The app's +// Notification Service Extension fetches those over its own JMAP session on +// the device and rewrites the notification before it is shown. + +const HOSTS = { + production: 'https://api.push.apple.com', + sandbox: 'https://api.sandbox.push.apple.com', +} as const; + +const JWT_LIFETIME_MS = 40 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 15_000; + +export interface ApnsConfig { + keyPem: string; + keyId: string; + teamId: string; + topic: string; +} + +let configPromise: Promise | null = null; +let cachedJwt: { token: string; issuedAt: number } | null = null; +const sessions = new Map(); + +async function loadConfig(): Promise { + const keyId = process.env.APNS_KEY_ID?.trim(); + const teamId = process.env.APNS_TEAM_ID?.trim(); + if (!keyId || !teamId) return null; + const inline = process.env.APNS_KEY?.trim(); + let keyPem: string; + if (inline && inline.startsWith('-----BEGIN')) { + keyPem = inline; + } else { + const file = inline || path.join(process.env.PUSH_DATA_DIR ?? './data', 'apns-key.p8'); + try { + keyPem = await fs.readFile(file, 'utf8'); + } catch { + return null; + } + } + return { keyPem, keyId, teamId, topic: process.env.APNS_TOPIC?.trim() || 'org.bulwarkmail.mobile' }; +} + +/** The APNs configuration, or null when the relay has no key (APNs off). */ +export function getApnsConfig(): Promise { + configPromise ??= loadConfig(); + return configPromise; +} + +/** Test hook: forget the configuration, the JWT and open connections. */ +export function resetApnsState(config?: ApnsConfig | null): void { + configPromise = config === undefined ? null : Promise.resolve(config); + cachedJwt = null; + for (const session of sessions.values()) session.close(); + sessions.clear(); +} + +function base64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64url'); +} + +export function apnsJwt(config: ApnsConfig, now = Date.now()): string { + if (cachedJwt && now - cachedJwt.issuedAt < JWT_LIFETIME_MS) return cachedJwt.token; + const header = base64url(JSON.stringify({ alg: 'ES256', kid: config.keyId })); + const claims = base64url(JSON.stringify({ iss: config.teamId, iat: Math.floor(now / 1000) })); + const signature = crypto.sign('sha256', Buffer.from(`${header}.${claims}`), { + key: config.keyPem, + dsaEncoding: 'ieee-p1363', + }); + const token = `${header}.${claims}.${base64url(signature)}`; + cachedJwt = { token, issuedAt: now }; + return token; +} + +/** + * The notification Apple shows if the app's extension can't fetch the + * message. Only ids leave the relay; `mutable-content` hands the + * notification to the extension first. + */ +export function apnsPayload(payload: ForwardPayload): Record { + return { + aps: { + alert: { title: 'Bulwark Mail', body: 'New message' }, + sound: 'default', + 'mutable-content': 1, + // Groups the notifications of one account. + 'thread-id': payload.accountId || payload.accountLabel, + }, + // expo-notifications hands an app `userInfo.body` as the notification's + // data, so the ids go there; the extension adds the message it showed. + body: { + kind: payload.kind, + accountLabel: payload.accountLabel, + accountId: payload.accountId, + emailIds: payload.emailIds, + changed: payload.changed, + }, + }; +} + +// APNs reasons that mean the token will never work again here: the app was +// removed, or the token belongs to the other environment or another topic. +const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']); + +export function classifyApnsResponse(status: number, body: string): SendResult { + let reason: string | undefined; + try { + reason = body ? (JSON.parse(body) as { reason?: string }).reason : undefined; + } catch { + reason = body || undefined; + } + const ok = status === 200; + return { + ok, + status, + unregistered: status === 410 || (status === 400 && !!reason && DEAD_TOKEN_REASONS.has(reason)), + reason: ok ? undefined : failureReason(reason ?? `HTTP ${status}`), + }; +} + +function sessionFor(origin: string): http2.ClientHttp2Session { + const existing = sessions.get(origin); + if (existing && !existing.closed && !existing.destroyed) return existing; + const session = http2.connect(origin); + const forget = () => { + if (sessions.get(origin) === session) sessions.delete(origin); + }; + session.on('close', forget); + session.on('error', forget); + session.on('goaway', forget); + // Don't keep the process alive just for an idle connection. + session.unref(); + sessions.set(origin, session); + return session; +} + +export async function sendApnsPush( + record: ApnsSubscriptionRecord, + payload: ForwardPayload, +): Promise { + const config = await getApnsConfig(); + if (!config) { + return { ok: false, status: 0, unregistered: false, reason: 'APNs not configured on this relay' }; + } + // APNS_ORIGIN points every push at another server (tests, a local mock). + const origin = process.env.APNS_ORIGIN || HOSTS[record.environment]; + const body = JSON.stringify(apnsPayload(payload)); + const session = sessionFor(origin); + return new Promise((resolve, reject) => { + const req = session.request({ + ':method': 'POST', + ':path': `/3/device/${record.deviceToken}`, + authorization: `bearer ${apnsJwt(config)}`, + 'apns-topic': config.topic, + 'apns-push-type': 'alert', + 'apns-priority': '10', + // A day: mail older than that is better found by opening the app. + 'apns-expiration': String(Math.floor(Date.now() / 1000) + 24 * 3600), + 'content-type': 'application/json', + }); + let status = 0; + let text = ''; + req.setEncoding('utf8'); + req.setTimeout(REQUEST_TIMEOUT_MS, () => req.close(http2.constants.NGHTTP2_CANCEL)); + req.on('response', (headers) => { + status = Number(headers[':status'] ?? 0); + }); + req.on('data', (chunk: string) => { + text += chunk; + }); + req.on('end', () => { + // A stream closed without a response (timeout, reset) has no status. + if (status === 0) { + resolve({ ok: false, status: 0, unregistered: false, reason: 'no response from APNs' }); + return; + } + resolve(classifyApnsResponse(status, text)); + }); + req.on('error', reject); + req.end(body); + }); +} diff --git a/src/fcm.test.ts b/src/fcm.test.ts new file mode 100644 index 0000000..26315f1 --- /dev/null +++ b/src/fcm.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { isSenderMismatch, resetFcmState, sendFcmPush } from './fcm.js'; +import type { FcmSubscriptionRecord, ForwardPayload } from './types.js'; + +const payload = { + kind: 'state', + accountLabel: 'a', + accountId: 'a', + emailIds: [], + changed: {}, +} as unknown as ForwardPayload; + +function record(token: string): FcmSubscriptionRecord { + return { kind: 'fcm', fcmToken: token } as unknown as FcmSubscriptionRecord; +} + +function account(projectId: string) { + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + return { + type: 'service_account', + project_id: projectId, + private_key_id: `${projectId}-key`, + private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + client_email: `relay@${projectId}.iam.gserviceaccount.com`, + token_uri: 'https://oauth.test/token', + }; +} + +// Each fake project accepts only its own tokens (":"), like FCM. +const sends: string[] = []; +const realFetch = globalThis.fetch; +let dir: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fcm-')); + fs.writeFileSync(path.join(dir, 'fcm-service-account.json'), JSON.stringify(account('new-project'))); + fs.writeFileSync(path.join(dir, 'fcm-service-account-old.json'), JSON.stringify(account('old-project'))); + process.env.PUSH_DATA_DIR = dir; + delete process.env.FCM_SERVICE_ACCOUNT_JSON; + resetFcmState(); + sends.length = 0; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + if (url === 'https://oauth.test/token') { + return new Response(JSON.stringify({ access_token: 't', expires_in: 3600 }), { status: 200 }); + } + const project = decodeURIComponent(url.match(/projects\/([^/]+)\/messages:send/)![1]); + const token = JSON.parse(String(init!.body)).message.token as string; + sends.push(project); + if (token.startsWith(`${project}:`)) { + return new Response(JSON.stringify({ name: 'm' }), { status: 200 }); + } + return new Response( + JSON.stringify({ error: { code: 403, status: 'PERMISSION_DENIED', message: 'SenderId mismatch' } }), + { status: 403 }, + ); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('sendFcmPush across Firebase projects', () => { + it('sends a new-project token through the main account only', async () => { + const result = await sendFcmPush(record('new-project:1'), payload); + assert.equal(result.ok, true); + assert.deepEqual(sends, ['new-project']); + }); + + it('falls back to the old project on a sender mismatch, then goes there first', async () => { + const first = await sendFcmPush(record('old-project:1'), payload); + assert.equal(first.ok, true); + assert.deepEqual(sends, ['new-project', 'old-project']); + + sends.length = 0; + const second = await sendFcmPush(record('old-project:1'), payload); + assert.equal(second.ok, true); + assert.deepEqual(sends, ['old-project']); + }); + + it('reports the failure when no project takes the token', async () => { + const result = await sendFcmPush(record('third-project:1'), payload); + assert.equal(result.ok, false); + assert.equal(result.status, 403); + assert.deepEqual(sends, ['new-project', 'old-project']); + }); +}); + +describe('isSenderMismatch', () => { + it('matches FCM\'s answers for a token of another project', () => { + assert.equal(isSenderMismatch(403, 'PERMISSION_DENIED', 'SenderId mismatch'), true); + assert.equal(isSenderMismatch(403, 'SENDER_ID_MISMATCH', ''), true); + assert.equal(isSenderMismatch(403, 'PERMISSION_DENIED', 'The caller does not have permission'), false); + assert.equal(isSenderMismatch(404, 'NOT_FOUND', 'SenderId mismatch'), false); + }); +}); diff --git a/src/fcm.ts b/src/fcm.ts index 604c8ea..95556a2 100644 --- a/src/fcm.ts +++ b/src/fcm.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { failureReason, type SendResult } from './forward-result.js'; import type { FcmSubscriptionRecord, ForwardPayload } from './types.js'; interface ServiceAccount { @@ -12,25 +13,57 @@ interface ServiceAccount { token_uri: string; } -let cachedAccount: ServiceAccount | null = null; -let cachedAccessToken: { token: string; expiresAt: number } | null = null; +/** + * One Firebase project the relay can send through. A token only works with + * the project of the app that issued it, so the relay keeps a project per + * app generation: the main service account (FCM_SERVICE_ACCOUNT_JSON or + * fcm-service-account.json) and any fcm-service-account-.json next to + * it, e.g. the old project while installed apps still hold its tokens. + */ +interface FcmProject { + account: ServiceAccount; + accessToken: { token: string; expiresAt: number } | null; +} -async function loadServiceAccount(): Promise { - if (cachedAccount) return cachedAccount; +let cachedProjects: FcmProject[] | null = null; + +/** The project that last accepted each token, so it is tried first. */ +const projectForToken = new Map(); + +async function readAccount(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, 'utf8')) as ServiceAccount; +} + +async function loadProjects(): Promise { + if (cachedProjects) return cachedProjects; + const dir = process.env.PUSH_DATA_DIR ?? './data'; const inline = process.env.FCM_SERVICE_ACCOUNT_JSON; + let main: ServiceAccount; if (inline && inline.trim().startsWith('{')) { - cachedAccount = JSON.parse(inline) as ServiceAccount; - return cachedAccount; + main = JSON.parse(inline) as ServiceAccount; + } else { + main = await readAccount( + inline && inline.trim().length > 0 ? inline : path.join(dir, 'fcm-service-account.json'), + ); } - const filePath = - inline && inline.trim().length > 0 - ? inline - : path.join(process.env.PUSH_DATA_DIR ?? './data', 'fcm-service-account.json'); - const raw = await fs.readFile(filePath, 'utf8'); - cachedAccount = JSON.parse(raw) as ServiceAccount; - return cachedAccount; + const extraFiles = (await fs.readdir(dir).catch(() => [] as string[])) + .filter((f) => /^fcm-service-account-.+\.json$/.test(f)) + .sort(); + const accounts = [main]; + for (const f of extraFiles) { + const account = await readAccount(path.join(dir, f)); + if (!accounts.some((a) => a.project_id === account.project_id)) accounts.push(account); + } + cachedProjects = accounts.map((account) => ({ account, accessToken: null })); + return cachedProjects; +} + +/** Test hook: forget loaded accounts, tokens and which project took which token. */ +export function resetFcmState(): void { + cachedProjects = null; + projectForToken.clear(); } function base64url(buf: Buffer | string): string { @@ -38,13 +71,13 @@ function base64url(buf: Buffer | string): string { return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } -async function mintAccessToken(): Promise { +async function mintAccessToken(project: FcmProject): Promise { const now = Date.now(); - if (cachedAccessToken && cachedAccessToken.expiresAt - 60_000 > now) { - return cachedAccessToken.token; + if (project.accessToken && project.accessToken.expiresAt - 60_000 > now) { + return project.accessToken.token; } - const account = await loadServiceAccount(); + const { account } = project; const iat = Math.floor(now / 1000); const exp = iat + 3600; const header = { alg: 'RS256', typ: 'JWT', kid: account.private_key_id }; @@ -74,17 +107,22 @@ async function mintAccessToken(): Promise { throw new Error(`FCM oauth2 token failed: ${res.status} ${await res.text()}`); } const body = (await res.json()) as { access_token: string; expires_in: number }; - cachedAccessToken = { + project.accessToken = { token: body.access_token, expiresAt: now + body.expires_in * 1000, }; return body.access_token; } -export interface FcmSendResult { - ok: boolean; - status: number; - unregistered: boolean; +/** + * FCM's answer to a token from another Firebase project: 403 with + * PERMISSION_DENIED "SenderId mismatch" (SENDER_ID_MISMATCH in the details). + */ +export function isSenderMismatch(status: number, errStatus: string, message: string): boolean { + return status === 403 && (errStatus === 'SENDER_ID_MISMATCH' || /sender ?id mismatch/i.test(message)); +} + +export interface FcmSendResult extends SendResult { body?: unknown; } @@ -92,8 +130,34 @@ export async function sendFcmPush( record: FcmSubscriptionRecord, payload: ForwardPayload, ): Promise { - const account = await loadServiceAccount(); - const accessToken = await mintAccessToken(); + const projects = await loadProjects(); + const known = projectForToken.get(record.fcmToken); + const ordered = [ + ...projects.filter((p) => p.account.project_id === known), + ...projects.filter((p) => p.account.project_id !== known), + ]; + let result: FcmSendResult & { senderMismatch?: boolean; projectId?: string } = { + ok: false, + status: 0, + unregistered: false, + }; + for (const project of ordered) { + result = await sendWithProject(project, record, payload); + if (!result.senderMismatch) break; + } + if (result.ok && result.projectId) projectForToken.set(record.fcmToken, result.projectId); + if (result.unregistered) projectForToken.delete(record.fcmToken); + const { senderMismatch: _mismatch, projectId: _project, ...rest } = result; + return rest; +} + +async function sendWithProject( + project: FcmProject, + record: FcmSubscriptionRecord, + payload: ForwardPayload, +): Promise { + const { account } = project; + const accessToken = await mintAccessToken(project); // Data-only push so onMessageReceived always fires — the app enriches it // (sender/subject/avatar) via JMAP fetch before posting a notification. // FCM data values must be strings, hence the JSON-encoded fields. @@ -133,10 +197,11 @@ export async function sendFcmPush( // keep raw text } - const errStatus = + const fcmError = typeof parsed === 'object' && parsed && 'error' in parsed - ? ((parsed as { error?: { status?: string } }).error?.status ?? '') - : ''; + ? (parsed as { error?: { status?: string; message?: string } }).error + : undefined; + const errStatus = fcmError?.status ?? ''; const unregistered = res.status === 404 || errStatus === 'UNREGISTERED' || errStatus === 'NOT_FOUND'; @@ -144,6 +209,11 @@ export async function sendFcmPush( ok: res.ok, status: res.status, unregistered, + senderMismatch: isSenderMismatch(res.status, errStatus, fcmError?.message ?? ''), + projectId: account.project_id, + reason: res.ok + ? undefined + : failureReason(fcmError ? `${errStatus}: ${fcmError.message ?? ''}` : rawBody), body: parsed, }; } diff --git a/src/forward-result.test.ts b/src/forward-result.test.ts new file mode 100644 index 0000000..de5b24d --- /dev/null +++ b/src/forward-result.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + classifyForward, + failureReason, + pushServiceHost, + statusLabel, +} from './forward-result.js'; +import type { SubscriptionRecord } from './types.js'; + +const base = { verificationCode: null, createdAt: 0, lastPushAt: null }; + +describe('classifyForward', () => { + it('prefers unregistered over the status class', () => { + assert.equal(classifyForward({ ok: false, status: 410, unregistered: true }), 'unregistered'); + }); + + it('buckets failures by status class, and no response as fail', () => { + assert.equal(classifyForward({ ok: true, status: 201, unregistered: false }), 'ok'); + assert.equal(classifyForward({ ok: false, status: 429, unregistered: false }), 'http-4xx'); + assert.equal(classifyForward({ ok: false, status: 503, unregistered: false }), 'http-5xx'); + assert.equal(classifyForward({ ok: false, status: 0, unregistered: false }), 'fail'); + }); +}); + +describe('statusLabel', () => { + it('uses the HTTP status, or none when nothing came back', () => { + assert.equal(statusLabel(429), '429'); + assert.equal(statusLabel(0), 'none'); + }); +}); + +describe('pushServiceHost', () => { + it('returns only the host, never the endpoint path', () => { + const web: SubscriptionRecord = { + ...base, + kind: 'web', + webPush: { + endpoint: 'https://updates.push.services.mozilla.com/wpush/v2/secret-token', + keys: { p256dh: 'p', auth: 'a' }, + }, + }; + const up: SubscriptionRecord = { + ...base, + kind: 'up', + endpoint: 'https://ntfy.example.org/upSecretTopic?up=1', + keys: null, + }; + const fcm: SubscriptionRecord = { ...base, kind: 'fcm', fcmToken: 'token' }; + assert.equal(pushServiceHost(web), 'updates.push.services.mozilla.com'); + assert.equal(pushServiceHost(up), 'ntfy.example.org'); + assert.equal(pushServiceHost(fcm), 'fcm.googleapis.com'); + }); +}); + +describe('failureReason', () => { + it('prefers the push service body over the generic web-push message', () => { + const error = Object.assign(new Error('Received unexpected response code'), { + statusCode: 403, + body: '{"reason":"BadJwtToken"}\n', + }); + assert.equal(failureReason(error), '{"reason":"BadJwtToken"}'); + }); + + it('adds the network error code from the cause', () => { + const error = new Error('fetch failed', { cause: { code: 'ECONNRESET' } }); + assert.equal(failureReason(error), 'fetch failed (ECONNRESET)'); + }); + + it('collapses whitespace, truncates long bodies and drops empty ones', () => { + assert.equal(failureReason('\n Too Many Requests\n'), + ' Too Many Requests '); + assert.equal(failureReason('x'.repeat(500))?.length, 201); + assert.equal(failureReason(' '), undefined); + assert.equal(failureReason(null), undefined); + }); +}); diff --git a/src/forward-result.ts b/src/forward-result.ts new file mode 100644 index 0000000..12c8b4d --- /dev/null +++ b/src/forward-result.ts @@ -0,0 +1,65 @@ +import type { SubscriptionRecord } from './types.js'; + +export type ForwardOutcome = 'ok' | 'unregistered' | 'http-4xx' | 'http-5xx' | 'fail'; + +// What a transport reports after trying to deliver one push. `status` is the +// push service's HTTP status, 0 when no response came back. `reason` is a +// short single-line explanation (the service's error body or the network +// error) for the failure log. +export interface SendResult { + ok: boolean; + status: number; + unregistered: boolean; + reason?: string; +} + +const MAX_REASON_CHARS = 200; + +export function classifyForward(result: SendResult): ForwardOutcome { + if (result.unregistered) return 'unregistered'; + if (result.ok) return 'ok'; + if (result.status >= 500) return 'http-5xx'; + if (result.status >= 400) return 'http-4xx'; + return 'fail'; +} + +// Metric label for the push service's answer. "none" covers network errors, +// timeouts and a relay without VAPID keys - nothing came back to look at. +export function statusLabel(status: number): string { + return status > 0 ? String(status) : 'none'; +} + +// Only the host: Web Push and UnifiedPush endpoint paths are bearer +// capabilities and must not end up in logs. +export function pushServiceHost(record: SubscriptionRecord): string { + if (record.kind === 'fcm') return 'fcm.googleapis.com'; + if (record.kind === 'apns') { + return record.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'; + } + const endpoint = record.kind === 'web' ? record.webPush.endpoint : record.endpoint; + try { + return new URL(endpoint).hostname; + } catch { + return 'invalid-endpoint'; + } +} + +// Turn a push service error body, a thrown error or plain text into one +// short log-friendly line. web-push's WebPushError carries the service's +// response body, which says far more than its generic message. +export function failureReason(error: unknown): string | undefined { + let text = ''; + if (typeof error === 'string') { + text = error; + } else if (error instanceof Error) { + const body = (error as { body?: unknown }).body; + text = typeof body === 'string' && body.trim() ? body : error.message; + const code = + (error.cause as { code?: unknown } | undefined)?.code ?? + (error as { code?: unknown }).code; + if (typeof code === 'string' && !text.includes(code)) text += ` (${code})`; + } + text = text.replace(/\s+/g, ' ').trim(); + if (!text) return undefined; + return text.length > MAX_REASON_CHARS ? `${text.slice(0, MAX_REASON_CHARS)}…` : text; +} diff --git a/src/metrics.ts b/src/metrics.ts index 24aa548..311d77c 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -54,8 +54,8 @@ export const pushesReceived = new Counter({ export const pushesForwarded = new Counter({ name: 'relay_pushes_forwarded_total', - help: 'Push forwarding outcomes by transport', - labelNames: ['result', 'transport'] as const, + help: 'Push forwarding outcomes by transport and push service HTTP status ("none" = no response)', + labelNames: ['result', 'transport', 'status'] as const, registers: [registry], }); @@ -66,6 +66,13 @@ export const fcmDurationSeconds = new Histogram({ registers: [registry], }); +export const apnsDurationSeconds = new Histogram({ + name: 'relay_apns_send_duration_seconds', + help: 'Latency of POST to api.push.apple.com (or the sandbox)', + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [registry], +}); + export const webPushDurationSeconds = new Histogram({ name: 'relay_webpush_send_duration_seconds', help: 'Latency of encrypted Web Push delivery', diff --git a/src/server.ts b/src/server.ts index 1524fa6..4e51344 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,10 +3,21 @@ import { URL } from 'node:url'; import { logger } from './logger.js'; import { subscriptionStore } from './store.js'; import { sendFcmPush } from './fcm.js'; +import { getApnsConfig, sendApnsPush } from './apns.js'; import { getVapidPublicKey, sendWebPush } from './webpush.js'; import { sendUnifiedPush } from './unifiedpush.js'; import { isForwardableBody, toForwardPayload } from './payload.js'; import { + classifyForward, + failureReason, + pushServiceHost, + statusLabel, + type ForwardOutcome, + type SendResult, +} from './forward-result.js'; +import { + isValidApnsEnvironment, + isValidApnsToken, isValidFcmToken, isValidSubscriptionId, isValidUnifiedPushEndpoint, @@ -14,6 +25,7 @@ import { isValidWebPushSubscription, } from './validation.js'; import type { + ApnsSubscriptionRecord, FcmSubscriptionRecord, ForwardPayload, JmapPushBody, @@ -31,6 +43,7 @@ import { pushesReceived, pushesForwarded, fcmDurationSeconds, + apnsDurationSeconds, webPushDurationSeconds, upDurationSeconds, } from './metrics.js'; @@ -169,6 +182,51 @@ async function handleRegister( return sendJson(res, 200, { ok: true }); } +// The iOS app. Refused up front when the relay has no APNs key, as Web Push +// is without VAPID keys: the record would be accepted but every push fail. +async function handleRegisterApns( + req: http.IncomingMessage, + res: http.ServerResponse, +): Promise { + if (!(await getApnsConfig())) { + return sendJson(res, 503, { error: 'APNs not configured' }); + } + const body = (await readJson(req)) as + | { subscriptionId?: unknown; deviceToken?: unknown; environment?: unknown; accountLabel?: unknown } + | null + | undefined; + if (!body || typeof body !== 'object') { + return sendJson(res, 400, { error: 'Invalid JSON' }); + } + const { subscriptionId, deviceToken, environment, accountLabel } = body; + if (!isValidSubscriptionId(subscriptionId)) { + return sendJson(res, 400, { error: 'Invalid subscriptionId' }); + } + if (!isValidApnsToken(deviceToken)) { + return sendJson(res, 400, { error: 'Invalid deviceToken' }); + } + if (!isValidApnsEnvironment(environment)) { + return sendJson(res, 400, { error: 'Invalid environment' }); + } + + const existing = await subscriptionStore.get(subscriptionId); + const record: ApnsSubscriptionRecord = { + kind: 'apns', + deviceToken: deviceToken.toLowerCase(), + environment, + // See handleRegister for why we always reset this. + verificationCode: null, + createdAt: existing?.createdAt ?? Date.now(), + lastPushAt: existing?.lastPushAt ?? null, + accountLabel: + typeof accountLabel === 'string' ? accountLabel.slice(0, 120) : undefined, + }; + await subscriptionStore.put(subscriptionId, record); + subscriptionsRegistered.inc({ transport: 'apns' }); + await refreshActiveGauge(); + return sendJson(res, 200, { ok: true }); +} + async function handleRegisterWeb( req: http.IncomingMessage, res: http.ServerResponse, @@ -351,14 +409,25 @@ async function handleJmap( // messages that passed - both become the same content-blind device payload. if (isForwardableBody(body)) { pushesReceived.inc({ type: body['@type'] }); - const result = await dispatch(record, toForwardPayload(body, record.accountLabel)); - let outcome: 'ok' | 'unregistered' | 'http-4xx' | 'http-5xx' | 'fail'; - if (result.unregistered) outcome = 'unregistered'; - else if (result.ok) outcome = 'ok'; - else if (result.status >= 500) outcome = 'http-5xx'; - else if (result.status >= 400) outcome = 'http-4xx'; - else outcome = 'fail'; - pushesForwarded.inc({ result: outcome, transport: record.kind }); + let result: SendResult; + try { + result = await dispatch(record, toForwardPayload(body, record.accountLabel)); + } catch (error) { + // FCM token minting and sending throw on network errors. Count and log + // them like any other failed forward, then answer 500 as before so the + // JMAP server may retry. + const failed = { ok: false, status: 0, unregistered: false, reason: failureReason(error) }; + pushesForwarded.inc({ result: 'fail', transport: record.kind, status: 'none' }); + logForwardFailure(id, record, 'fail', failed); + return sendJson(res, 500, { error: 'Internal server error' }); + } + const outcome = classifyForward(result); + pushesForwarded.inc({ + result: outcome, + transport: record.kind, + status: statusLabel(result.status), + }); + if (outcome !== 'ok') logForwardFailure(id, record, outcome, result); record.lastPushAt = Date.now(); await subscriptionStore.put(id, record); if (result.unregistered) { @@ -366,9 +435,11 @@ async function handleJmap( const reason = record.kind === 'fcm' ? 'fcm-unregistered' - : record.kind === 'up' - ? 'unifiedpush-gone' - : 'webpush-gone'; + : record.kind === 'apns' + ? 'apns-unregistered' + : record.kind === 'up' + ? 'unifiedpush-gone' + : 'webpush-gone'; subscriptionsUnregistered.inc({ reason, transport: record.kind }); await refreshActiveGauge(); } @@ -378,16 +449,43 @@ async function handleJmap( return sendJson(res, 400, { error: 'Unsupported JMAP push type' }); } +// The request log shows a 200 for most failed forwards, so this line is the +// only record of why a device missed a push. +function logForwardFailure( + id: string, + record: SubscriptionRecord, + outcome: ForwardOutcome, + result: SendResult, +): void { + const fields = { + subscriptionId: id, + transport: record.kind, + host: pushServiceHost(record), + outcome, + status: result.status, + reason: result.reason, + }; + // An expired subscription is routine cleanup, not a delivery problem. + if (outcome === 'unregistered') logger.info('relay: push subscription gone', fields); + else logger.warn('relay: push not delivered', fields); +} + async function dispatch( record: SubscriptionRecord, payload: ForwardPayload, -): Promise<{ ok: boolean; status: number; unregistered: boolean }> { +): Promise { if (record.kind === 'fcm') { const timer = fcmDurationSeconds.startTimer(); const result = await sendFcmPush(record, payload); timer(); return result; } + if (record.kind === 'apns') { + const timer = apnsDurationSeconds.startTimer(); + const result = await sendApnsPush(record, payload); + timer(); + return result; + } if (record.kind === 'up') { const timer = upDurationSeconds.startTimer(); const result = await sendUnifiedPush(record, payload); @@ -406,6 +504,7 @@ async function dispatch( async function refreshActiveGauge(): Promise { const counts = await subscriptionStore.sizeByKind(); subscriptionsActive.set({ transport: 'fcm' }, counts.fcm); + subscriptionsActive.set({ transport: 'apns' }, counts.apns); subscriptionsActive.set({ transport: 'web' }, counts.web); subscriptionsActive.set({ transport: 'up' }, counts.up); } @@ -418,6 +517,7 @@ function normalizeRoute(method: string, path: string): string { if (method === 'POST' && path === '/api/push/register') return '/api/push/register'; if (method === 'POST' && path === '/api/push/register/web') return '/api/push/register/web'; if (method === 'POST' && path === '/api/push/register/unifiedpush') return '/api/push/register/unifiedpush'; + if (method === 'POST' && path === '/api/push/register/apns') return '/api/push/register/apns'; if (/^\/api\/push\/register\/[^/]+$/.test(path)) return '/api/push/register/:id'; if (/^\/api\/push\/verify\/[^/]+$/.test(path)) return '/api/push/verify/:id'; if (/^\/api\/push\/active\/[^/]+$/.test(path)) return '/api/push/active/:id'; @@ -490,6 +590,10 @@ const server = http.createServer(async (req, res) => { return await handleRegisterUnifiedPush(req, res); } + if (method === 'POST' && path === '/api/push/register/apns') { + return await handleRegisterApns(req, res); + } + if (method === 'GET' && path === '/api/push/vapid-public-key') { const key = getVapidPublicKey(); if (!key) { diff --git a/src/store.ts b/src/store.ts index 66baab3..13cbed3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -81,17 +81,19 @@ class SubscriptionStore { return Object.keys(this.cache).length; } - async sizeByKind(): Promise<{ fcm: number; web: number; up: number }> { + async sizeByKind(): Promise<{ fcm: number; apns: number; web: number; up: number }> { await this.ensureLoaded(); let fcm = 0; + let apns = 0; let web = 0; let up = 0; for (const record of Object.values(this.cache)) { if (record.kind === 'fcm') fcm++; + else if (record.kind === 'apns') apns++; else if (record.kind === 'web') web++; else if (record.kind === 'up') up++; } - return { fcm, web, up }; + return { fcm, apns, web, up }; } private isExpired(record: SubscriptionRecord): boolean { diff --git a/src/types.ts b/src/types.ts index 6c5fc17..72840df 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,11 @@ -// Records exist in one of three flavours: FCM (mobile app), Web Push (PWA in -// the browser) or UnifiedPush (mobile app without Google Play services). +// Records exist in one of four flavours: FCM (Android app), APNs (iOS app), +// Web Push (PWA in the browser) or UnifiedPush (Android app without Google +// Play services). // Older on-disk records predate the discriminator and only have `fcmToken` - // the loader fills in `kind: 'fcm'` for those. export type SubscriptionRecord = | FcmSubscriptionRecord + | ApnsSubscriptionRecord | WebSubscriptionRecord | UnifiedPushSubscriptionRecord; @@ -19,6 +21,15 @@ export interface FcmSubscriptionRecord extends BaseSubscriptionRecord { fcmToken: string; } +// The iOS app's APNs device token (hex). `environment` is the APNs host the +// token belongs to: TestFlight and App Store builds get production tokens, +// development builds and simulators sandbox ones. +export interface ApnsSubscriptionRecord extends BaseSubscriptionRecord { + kind: 'apns'; + deviceToken: string; + environment: 'production' | 'sandbox'; +} + export interface WebSubscriptionRecord extends BaseSubscriptionRecord { kind: 'web'; webPush: WebPushSubscription; diff --git a/src/unifiedpush.ts b/src/unifiedpush.ts index 48a555b..7d7c5bc 100644 --- a/src/unifiedpush.ts +++ b/src/unifiedpush.ts @@ -1,17 +1,12 @@ import webpush from 'web-push'; import { getVapidPublicKey } from './webpush.js'; +import { failureReason, type SendResult } from './forward-result.js'; import type { ForwardPayload, UnifiedPushSubscriptionRecord } from './types.js'; // Drop the message if the device stays unreachable for an hour - matches the // Web Push transport; the app re-syncs over JMAP on next launch anyway. const TTL_SECONDS = 60 * 60; -export interface UnifiedPushSendResult { - ok: boolean; - status: number; - unregistered: boolean; -} - /** * Deliver a payload to a UnifiedPush endpoint. Modern connectors (>= 3.0) * hand out RFC 8291 keys, so the message goes out as standard encrypted Web @@ -28,7 +23,7 @@ export interface UnifiedPushSendResult { export async function sendUnifiedPush( record: UnifiedPushSubscriptionRecord, payload: ForwardPayload, -): Promise { +): Promise { const body = JSON.stringify(payload); if (record.keys) { @@ -46,7 +41,12 @@ export async function sendUnifiedPush( const status = (error as { statusCode?: number }).statusCode ?? 0; // 404 / 410 mean the distributor dropped the registration - mirrors the // FCM UNREGISTERED branch so the caller deletes the record. - return { ok: false, status, unregistered: status === 404 || status === 410 }; + return { + ok: false, + status, + unregistered: status === 404 || status === 410, + reason: failureReason(error), + }; } } @@ -60,14 +60,16 @@ export async function sendUnifiedPush( }, body, }); - // Drain so the socket can be reused; the body itself is uninteresting. - await res.arrayBuffer().catch(() => undefined); + // Always drain so the socket can be reused; the body only matters as the + // distributor's explanation of a failure. + const text = await res.text().catch(() => ''); return { ok: res.ok, status: res.status, unregistered: res.status === 404 || res.status === 410, + reason: res.ok ? undefined : failureReason(text), }; - } catch { - return { ok: false, status: 0, unregistered: false }; + } catch (error) { + return { ok: false, status: 0, unregistered: false, reason: failureReason(error) }; } } diff --git a/src/validation.ts b/src/validation.ts index 68becaa..adc9c70 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -13,6 +13,16 @@ export function isValidFcmToken(value: unknown): value is string { ); } +// APNs device tokens are 32 bytes today, sent as hex; Apple says not to rely +// on the length, so allow some room. +export function isValidApnsToken(value: unknown): value is string { + return typeof value === 'string' && /^[0-9a-fA-F]{64,200}$/.test(value); +} + +export function isValidApnsEnvironment(value: unknown): value is 'production' | 'sandbox' { + return value === 'production' || value === 'sandbox'; +} + // UnifiedPush endpoints point at whatever distributor server the user chose // (ntfy.sh, a self-hosted ntfy, NextPush, ...) so unlike Web Push there is no // short allowlist of origins - but they must still be https and sanely sized. diff --git a/src/webpush.ts b/src/webpush.ts index 88268f5..8a386f3 100644 --- a/src/webpush.ts +++ b/src/webpush.ts @@ -1,5 +1,6 @@ import webpush from 'web-push'; import { logger } from './logger.js'; +import { failureReason, type SendResult } from './forward-result.js'; import type { ForwardPayload, WebSubscriptionRecord } from './types.js'; let configured = false; @@ -31,18 +32,12 @@ export function getVapidPublicKey(): string | null { return publicKey; } -export interface WebPushSendResult { - ok: boolean; - status: number; - unregistered: boolean; -} - export async function sendWebPush( record: WebSubscriptionRecord, payload: ForwardPayload, -): Promise { +): Promise { if (!ensureConfigured()) { - return { ok: false, status: 0, unregistered: false }; + return { ok: false, status: 0, unregistered: false, reason: 'VAPID keys not configured' }; } // Mirror the FCM payload shape: a wake-up ping plus, at most, the ids of the @@ -60,6 +55,6 @@ export async function sendWebPush( // 404 / 410 mean the push service has dropped the subscription - // mirrors the FCM UNREGISTERED branch so the caller deletes the record. const unregistered = status === 404 || status === 410; - return { ok: false, status, unregistered }; + return { ok: false, status, unregistered, reason: failureReason(error) }; } }