refactor: use https-proxy-agent for APNs tunnels

This commit is contained in:
jesse-merhi
2026-05-04 02:30:39 +10:00
committed by clawsweeper
parent bfca5feed7
commit 281f82d29d
4 changed files with 64 additions and 171 deletions

View File

@@ -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",
);
});
});

View File

@@ -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<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);
});
function isSuccessfulProxySocket(socket: net.Socket): boolean {
return !socket.destroyed && socket.writable;
}
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) });
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 },
);
}
}

View File

@@ -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),
});

View File

@@ -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,