chore: bring the live relay into the repository

This commit is contained in:
Linus Rath
2026-10-31 18:18:05 +01:00
parent d4f34c0439
commit 320357079d
14 changed files with 867 additions and 66 deletions
+2
View File
@@ -8,6 +8,8 @@ services:
- 127.0.0.1:3003:3003
volumes:
- ./data:/app/data
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=3003
+157
View File
@@ -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<string, unknown>;
};
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<void>((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<void>((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);
});
});
+196
View File
@@ -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 <PUSH_DATA_DIR>/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<ApnsConfig | null> | null = null;
let cachedJwt: { token: string; issuedAt: number } | null = null;
const sessions = new Map<string, http2.ClientHttp2Session>();
async function loadConfig(): Promise<ApnsConfig | null> {
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<ApnsConfig | null> {
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<string, unknown> {
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<SendResult> {
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<SendResult>((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);
});
}
+103
View File
@@ -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 ("<project>:<id>"), 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);
});
});
+97 -27
View File
@@ -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-<name>.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<ServiceAccount> {
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<string, string>();
async function readAccount(filePath: string): Promise<ServiceAccount> {
return JSON.parse(await fs.readFile(filePath, 'utf8')) as ServiceAccount;
}
async function loadProjects(): Promise<FcmProject[]> {
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<string> {
async function mintAccessToken(project: FcmProject): Promise<string> {
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<string> {
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<FcmSendResult> {
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<FcmSendResult & { senderMismatch: boolean; projectId: string }> {
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,
};
}
+77
View File
@@ -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('<html>\n <body>Too Many Requests</body>\n</html>'),
'<html> <body>Too Many Requests</body> </html>');
assert.equal(failureReason('x'.repeat(500))?.length, 201);
assert.equal(failureReason(' '), undefined);
assert.equal(failureReason(null), undefined);
});
});
+65
View File
@@ -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;
}
+9 -2
View File
@@ -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',
+116 -12
View File
@@ -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<void> {
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<SendResult> {
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<void> {
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) {
+4 -2
View File
@@ -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 {
+13 -2
View File
@@ -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;
+14 -12
View File
@@ -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<UnifiedPushSendResult> {
): Promise<SendResult> {
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) };
}
}
+10
View File
@@ -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.
+4 -9
View File
@@ -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<WebPushSendResult> {
): Promise<SendResult> {
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) };
}
}