mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:10:45 +00:00
fix: proxy direct APNs HTTP2 sessions
This commit is contained in:
106
src/infra/net/http-connect-tunnel.test.ts
Normal file
106
src/infra/net/http-connect-tunnel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
128
src/infra/net/http-connect-tunnel.ts
Normal file
128
src/infra/net/http-connect-tunnel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
87
src/infra/push-apns-http2.test.ts
Normal file
87
src/infra/push-apns-http2.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
58
src/infra/push-apns-http2.ts
Normal file
58
src/infra/push-apns-http2.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user