From 281f82d29d68d55a872becd6c5236f49d529ff8a Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 4 May 2026 02:30:39 +1000 Subject: [PATCH] refactor: use https-proxy-agent for APNs tunnels --- src/infra/net/http-connect-tunnel.test.ts | 101 +++++++------------- src/infra/net/http-connect-tunnel.ts | 110 ++++++---------------- src/infra/push-apns-http2.test.ts | 16 +--- src/infra/push-apns-http2.ts | 8 +- 4 files changed, 64 insertions(+), 171 deletions(-) diff --git a/src/infra/net/http-connect-tunnel.test.ts b/src/infra/net/http-connect-tunnel.test.ts index 372d01ded8b..09b849a32eb 100644 --- a/src/infra/net/http-connect-tunnel.test.ts +++ b/src/infra/net/http-connect-tunnel.test.ts @@ -1,97 +1,61 @@ -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; +const { connectSpy, agentConstructorSpy, fakeSocket } = vi.hoisted(() => { + const fakeSocket = { destroyed: false, writable: true }; + const connectSpy = vi.fn(async () => fakeSocket); return { - connectSpy: vi.fn(() => { - if (!nextSocket) { - throw new Error("nextSocket not set"); - } - const socket = nextSocket; - queueMicrotask(() => socket.emit("connect")); - return socket; + fakeSocket, + connectSpy, + agentConstructorSpy: vi.fn(function HttpsProxyAgent(this: { connect: typeof connectSpy }) { + this.connect = connectSpy; }), - nextSocket: (socket: FakeSocket) => { - nextSocket = socket; - }, }; }); -vi.mock("node:net", () => ({ - connect: connectSpy, +vi.mock("https-proxy-agent", () => ({ + HttpsProxyAgent: agentConstructorSpy, })); 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); - + it("delegates CONNECT tunneling to https-proxy-agent with APNs TLS options", async () => { const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); const result = await openHttpConnectTunnel({ proxyUrl: "http://proxy.example:8080", targetHost: "api.push.apple.com", targetPort: 443, + timeoutMs: 10_000, }); - 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"), - ); + expect(result).toBe(fakeSocket); + expect(agentConstructorSpy).toHaveBeenCalledWith("http://proxy.example:8080", { + keepAlive: true, + }); + expect(connectSpy).toHaveBeenCalledWith(expect.any(Object), { + host: "api.push.apple.com", + port: 443, + secureEndpoint: true, + servername: "api.push.apple.com", + ALPNProtocols: ["h2"], + }); }); - 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); - + it("supports https proxy URLs through https-proxy-agent", async () => { const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); await openHttpConnectTunnel({ - proxyUrl: "http://user:pass@proxy.example:8080", - targetHost: "api.push.apple.com", + proxyUrl: "https://proxy.example:8443", + targetHost: "api.sandbox.push.apple.com", targetPort: 443, }); - expect(socket.writes[0]).toContain( - `Proxy-Authorization: Basic ${Buffer.from("user:pass").toString("base64")}`, - ); + expect(agentConstructorSpy).toHaveBeenCalledWith("https://proxy.example:8443", { + keepAlive: true, + }); }); - 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); - + it("redacts proxy credentials in dependency failures", async () => { + connectSpy.mockRejectedValueOnce(new Error("407 Proxy Authentication Required")); const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); await expect( @@ -100,7 +64,8 @@ describe("openHttpConnectTunnel", () => { 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); + ).rejects.toThrow( + "Proxy CONNECT failed via http://proxy.example:8080: 407 Proxy Authentication Required", + ); }); }); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts index a9134512bf6..810b8b24ff4 100644 --- a/src/infra/net/http-connect-tunnel.ts +++ b/src/infra/net/http-connect-tunnel.ts @@ -1,5 +1,7 @@ -import { once } from "node:events"; -import * as net from "node:net"; +import { EventEmitter } from "node:events"; +import type http from "node:http"; +import type net from "node:net"; +import { HttpsProxyAgent } from "https-proxy-agent"; export type HttpConnectTunnelParams = { proxyUrl: string; @@ -19,67 +21,16 @@ function redactProxyUrl(proxyUrl: string): string { } } -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 { - 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); - }); +function isSuccessfulProxySocket(socket: net.Socket): boolean { + return !socket.destroyed && socket.writable; } export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Promise { - 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) }); + const req = new EventEmitter() as http.ClientRequest; + req.once = req.once.bind(req) as typeof req.once; + req.emit = req.emit.bind(req) as typeof req.emit; + + const agent = new HttpsProxyAgent(params.proxyUrl, { keepAlive: true }); let timeout: NodeJS.Timeout | undefined; const clear = () => { if (timeout) { @@ -87,42 +38,37 @@ export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Pr timeout = undefined; } }; + try { if (params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0) { timeout = setTimeout(() => { - socket.destroy( + req.emit( + "error", 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 socket = await agent.connect(req, { + host: params.targetHost, + port: params.targetPort, + secureEndpoint: true, + servername: params.targetHost, + ALPNProtocols: ["h2"], + }); - 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}`); + if (!isSuccessfulProxySocket(socket)) { + throw new Error("proxy returned an unusable CONNECT socket"); } + clear(); return socket; } catch (err) { clear(); - if (!socket.destroyed) { - socket.destroy(); - } - throw err; + throw new Error( + `Proxy CONNECT failed via ${redactProxyUrl(params.proxyUrl)}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); } } diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts index 6bfd36a626d..901c5d571ab 100644 --- a/src/infra/push-apns-http2.test.ts +++ b/src/infra/push-apns-http2.test.ts @@ -6,15 +6,14 @@ import { stopActiveManagedProxyRegistration, } from "./net/proxy/active-proxy-state.js"; -const { connectSpy, tlsConnectSpy, tunnelSpy, fakeSession, fakeTlsSocket } = vi.hoisted(() => { +const { connectSpy, 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 })), + tunnelSpy: vi.fn(async () => fakeTlsSocket), }; }); @@ -23,11 +22,6 @@ vi.mock("node:http2", () => ({ connect: connectSpy, })); -vi.mock("node:tls", () => ({ - default: { connect: tlsConnectSpy }, - connect: tlsConnectSpy, -})); - vi.mock("./net/http-connect-tunnel.js", () => ({ openHttpConnectTunnel: tunnelSpy, })); @@ -35,7 +29,6 @@ vi.mock("./net/http-connect-tunnel.js", () => ({ describe("connectApnsHttp2Session", () => { beforeEach(() => { connectSpy.mockClear(); - tlsConnectSpy.mockClear(); tunnelSpy.mockClear(); _resetActiveManagedProxyStateForTests(); }); @@ -69,11 +62,6 @@ describe("connectApnsHttp2Session", () => { 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), }); diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index dffea88d049..04cde58ea90 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -1,5 +1,4 @@ import http2 from "node:http2"; -import tls from "node:tls"; import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js"; import { getActiveManagedProxyUrl } from "./net/proxy/active-proxy-state.js"; @@ -39,17 +38,12 @@ export async function connectApnsHttp2Session( } const apnsHost = new URL(authority).hostname; - const tunnel = await openHttpConnectTunnel({ + const tlsSocket = 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,