mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
refactor: use https-proxy-agent for APNs tunnels
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user