fix: proxy direct APNs HTTP2 sessions

This commit is contained in:
jesse-merhi
2026-05-04 02:30:38 +10:00
committed by clawsweeper
parent 5efbb3078a
commit 543bbcba9d
5 changed files with 385 additions and 1 deletions

View File

@@ -0,0 +1,106 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
class FakeSocket extends EventEmitter {
public readonly writes: string[] = [];
public readonly unshifted: Buffer[] = [];
public destroyed = false;
constructor(private readonly response: string) {
super();
}
write(data: string): void {
this.writes.push(data);
queueMicrotask(() => this.emit("data", Buffer.from(this.response, "latin1")));
}
destroy(): void {
this.destroyed = true;
}
unshift(data: Buffer): void {
this.unshifted.push(data);
}
}
const { connectSpy, nextSocket } = vi.hoisted(() => {
let nextSocket: FakeSocket | undefined;
return {
connectSpy: vi.fn(() => {
if (!nextSocket) {
throw new Error("nextSocket not set");
}
const socket = nextSocket;
queueMicrotask(() => socket.emit("connect"));
return socket;
}),
nextSocket: (socket: FakeSocket) => {
nextSocket = socket;
},
};
});
vi.mock("node:net", () => ({
connect: connectSpy,
}));
describe("openHttpConnectTunnel", () => {
it("opens an HTTP CONNECT tunnel through the configured proxy", async () => {
const socket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
nextSocket(socket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
const result = await openHttpConnectTunnel({
proxyUrl: "http://proxy.example:8080",
targetHost: "api.push.apple.com",
targetPort: 443,
});
expect(result).toBe(socket);
expect(connectSpy).toHaveBeenCalledWith({ host: "proxy.example", port: 8080 });
expect(socket.writes[0]).toBe(
[
"CONNECT api.push.apple.com:443 HTTP/1.1",
"Host: api.push.apple.com:443",
"Proxy-Connection: Keep-Alive",
"",
"",
].join("\r\n"),
);
});
it("sends basic proxy authorization for proxy URLs with credentials", async () => {
const socket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
nextSocket(socket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await openHttpConnectTunnel({
proxyUrl: "http://user:pass@proxy.example:8080",
targetHost: "api.push.apple.com",
targetPort: 443,
});
expect(socket.writes[0]).toContain(
`Proxy-Authorization: Basic ${Buffer.from("user:pass").toString("base64")}`,
);
});
it("destroys the socket and redacts credentials when CONNECT fails", async () => {
const socket = new FakeSocket("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
nextSocket(socket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await expect(
openHttpConnectTunnel({
proxyUrl: "http://user:secret@proxy.example:8080",
targetHost: "api.push.apple.com",
targetPort: 443,
}),
).rejects.toThrow("Proxy CONNECT failed via http://proxy.example:8080: HTTP/1.1 407");
expect(socket.destroyed).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
import { once } from "node:events";
import * as net from "node:net";
export type HttpConnectTunnelParams = {
proxyUrl: string;
targetHost: string;
targetPort: number;
timeoutMs?: number;
};
function redactProxyUrl(proxyUrl: string): string {
try {
const parsed = new URL(proxyUrl);
parsed.username = "";
parsed.password = "";
return parsed.toString().replace(/\/$/, "");
} catch {
return "<invalid proxy URL>";
}
}
function resolveProxyPort(proxy: URL): number {
if (proxy.port) {
return Number(proxy.port);
}
return proxy.protocol === "https:" ? 443 : 80;
}
function resolveProxyAuthorization(proxy: URL): string | undefined {
if (!proxy.username && !proxy.password) {
return undefined;
}
const username = decodeURIComponent(proxy.username);
const password = decodeURIComponent(proxy.password);
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
function readConnectResponse(socket: net.Socket): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0);
const cleanup = () => {
socket.off("data", onData);
socket.off("end", onEnd);
socket.off("error", onError);
socket.off("close", onClose);
};
const fail = (err: Error) => {
cleanup();
reject(err);
};
const onData = (chunk: Buffer | string) => {
const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "latin1");
buffer = Buffer.concat([buffer, nextChunk]);
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) {
return;
}
cleanup();
const bodyOffset = headerEnd + 4;
if (buffer.length > bodyOffset) {
socket.unshift(buffer.subarray(bodyOffset));
}
resolve(buffer.subarray(0, bodyOffset).toString("latin1"));
};
const onEnd = () => fail(new Error("Proxy closed before CONNECT response"));
const onError = (err: Error) => fail(err);
const onClose = () => fail(new Error("Proxy closed before CONNECT response"));
socket.on("data", onData);
socket.once("end", onEnd);
socket.once("error", onError);
socket.once("close", onClose);
});
}
export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Promise<net.Socket> {
const proxy = new URL(params.proxyUrl);
if (proxy.protocol !== "http:") {
throw new Error(`Unsupported proxy protocol for APNs HTTP/2 CONNECT tunnel: ${proxy.protocol}`);
}
const socket = net.connect({ host: proxy.hostname, port: resolveProxyPort(proxy) });
let timeout: NodeJS.Timeout | undefined;
const clear = () => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
};
try {
if (params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0) {
timeout = setTimeout(() => {
socket.destroy(
new Error(`Proxy CONNECT timed out after ${Math.trunc(params.timeoutMs ?? 0)}ms`),
);
}, Math.trunc(params.timeoutMs));
timeout.unref?.();
}
await once(socket, "connect");
const target = `${params.targetHost}:${params.targetPort}`;
const headers = [
`CONNECT ${target} HTTP/1.1`,
`Host: ${target}`,
"Proxy-Connection: Keep-Alive",
];
const authorization = resolveProxyAuthorization(proxy);
if (authorization) {
headers.push(`Proxy-Authorization: ${authorization}`);
}
socket.write([...headers, "", ""].join("\r\n"));
const response = await readConnectResponse(socket);
const statusLine = response.split("\r\n", 1)[0] ?? "";
if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
socket.destroy();
throw new Error(`Proxy CONNECT failed via ${redactProxyUrl(params.proxyUrl)}: ${statusLine}`);
}
clear();
return socket;
} catch (err) {
clear();
if (!socket.destroyed) {
socket.destroy();
}
throw err;
}
}

View File

@@ -0,0 +1,87 @@
import type http2 from "node:http2";
import { describe, expect, it, vi } from "vitest";
const { connectSpy, tlsConnectSpy, tunnelSpy, fakeSession, fakeTlsSocket } = vi.hoisted(() => {
const fakeSession = { close: vi.fn(), destroy: vi.fn() };
const fakeTlsSocket = { encrypted: true };
return {
fakeSession,
fakeTlsSocket,
connectSpy: vi.fn(() => fakeSession),
tlsConnectSpy: vi.fn(() => fakeTlsSocket),
tunnelSpy: vi.fn(async () => ({ tunneled: true })),
};
});
vi.mock("node:http2", () => ({
default: { connect: connectSpy },
connect: connectSpy,
}));
vi.mock("node:tls", () => ({
default: { connect: tlsConnectSpy },
connect: tlsConnectSpy,
}));
vi.mock("./net/http-connect-tunnel.js", () => ({
openHttpConnectTunnel: tunnelSpy,
}));
describe("connectApnsHttp2Session", () => {
it("uses direct http2.connect when no HTTPS proxy is configured", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
const session = await connectApnsHttp2Session({
authority: "https://api.sandbox.push.apple.com",
timeoutMs: 10_000,
env: {},
});
expect(session).toBe(fakeSession);
expect(tunnelSpy).not.toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalledWith("https://api.sandbox.push.apple.com");
});
it("uses an HTTP CONNECT tunnel and disables direct fallback when HTTPS proxy is configured", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
const session = await connectApnsHttp2Session({
authority: "https://api.push.apple.com",
timeoutMs: 10_000,
env: { HTTPS_PROXY: "http://proxy.example:8080" },
});
expect(session).toBe(fakeSession);
expect(tunnelSpy).toHaveBeenCalledWith({
proxyUrl: "http://proxy.example:8080",
targetHost: "api.push.apple.com",
targetPort: 443,
timeoutMs: 10_000,
});
expect(tlsConnectSpy).toHaveBeenCalledWith({
socket: { tunneled: true },
servername: "api.push.apple.com",
ALPNProtocols: ["h2"],
});
expect(connectSpy).toHaveBeenCalledWith("https://api.push.apple.com", {
createConnection: expect.any(Function),
});
const connectCall = connectSpy.mock.calls.at(-1) as
| [string, http2.ClientSessionOptions]
| undefined;
const createConnection = connectCall?.[1].createConnection;
expect(createConnection?.(new URL("https://api.push.apple.com"), {})).toBe(fakeTlsSocket);
});
it("rejects non-APNs authorities", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
await expect(
connectApnsHttp2Session({
authority: "https://example.com",
timeoutMs: 10_000,
env: { HTTPS_PROXY: "http://proxy.example:8080" },
}),
).rejects.toThrow("Unsupported APNs authority");
});
});

View File

@@ -0,0 +1,58 @@
import http2 from "node:http2";
import tls from "node:tls";
import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js";
import { resolveEnvHttpProxyUrl } from "./net/proxy-env.js";
const APNS_AUTHORITIES = new Set([
"https://api.push.apple.com",
"https://api.sandbox.push.apple.com",
]);
type ApnsAuthority = "https://api.push.apple.com" | "https://api.sandbox.push.apple.com";
export type ConnectApnsHttp2SessionParams = {
authority: string;
timeoutMs: number;
env?: NodeJS.ProcessEnv;
};
function assertApnsAuthority(authority: string): ApnsAuthority {
let parsed: URL;
try {
parsed = new URL(authority);
} catch {
throw new Error(`Unsupported APNs authority: ${authority}`);
}
const normalized = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
if (!APNS_AUTHORITIES.has(normalized)) {
throw new Error(`Unsupported APNs authority: ${authority}`);
}
return normalized as ApnsAuthority;
}
export async function connectApnsHttp2Session(
params: ConnectApnsHttp2SessionParams,
): Promise<http2.ClientHttp2Session> {
const authority = assertApnsAuthority(params.authority);
const proxyUrl = resolveEnvHttpProxyUrl("https", params.env);
if (!proxyUrl) {
return http2.connect(authority);
}
const apnsHost = new URL(authority).hostname;
const tunnel = await openHttpConnectTunnel({
proxyUrl,
targetHost: apnsHost,
targetPort: 443,
timeoutMs: params.timeoutMs,
});
const tlsSocket = tls.connect({
socket: tunnel,
servername: apnsHost,
ALPNProtocols: ["h2"],
});
return http2.connect(authority, {
createConnection: () => tlsSocket,
});
}

View File

@@ -10,6 +10,7 @@ import {
import type { DeviceIdentity } from "./device-identity.js";
import { formatErrorMessage } from "./errors.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
import { connectApnsHttp2Session } from "./push-apns-http2.js";
import {
type ApnsRelayConfig,
type ApnsRelayPushResponse,
@@ -658,8 +659,12 @@ async function sendApnsRequest(params: {
const body = JSON.stringify(params.payload);
const requestPath = `/3/device/${params.token}`;
const client = await connectApnsHttp2Session({
authority,
timeoutMs: params.timeoutMs,
});
return await new Promise((resolve, reject) => {
const client = http2.connect(authority);
let settled = false;
const fail = (err: unknown) => {
if (settled) {