refactor(proxy): use native CONNECT tunnel

This commit is contained in:
Peter Steinberger
2026-07-14 03:51:28 +01:00
parent 877af700bf
commit c490cf1913
4 changed files with 221 additions and 731 deletions
-385
View File
@@ -1,385 +0,0 @@
// HTTP CONNECT tunnel tests cover HTTP/HTTPS proxy handshakes, proxy auth,
// timeout/error cleanup, and tunneled-byte preservation.
import { EventEmitter } from "node:events";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
class FakeSocket extends EventEmitter {
public readonly writes: string[] = [];
public readonly unshifted: Buffer[] = [];
public destroyed = false;
public writable = true;
public readonly alpnProtocol: string | false;
public readonly emitSecureConnectOnConnect: boolean;
constructor(
private readonly response?: string,
options: { alpnProtocol?: string | false; emitSecureConnectOnConnect?: boolean } = {},
) {
super();
this.alpnProtocol = options.alpnProtocol ?? "h2";
this.emitSecureConnectOnConnect = options.emitSecureConnectOnConnect ?? true;
}
write(data: string): void {
this.writes.push(data);
const response = this.response;
if (response !== undefined) {
queueMicrotask(() => this.emit("data", Buffer.from(response, "latin1")));
}
}
destroy(): void {
this.destroyed = true;
this.writable = false;
this.emit("close");
}
unshift(data: Buffer): void {
this.unshifted.push(data);
}
}
const {
netConnectSpy,
tlsConnectSpy,
setNextNetSocket,
setNextProxyTlsSocket,
setNextTargetTlsSocket,
} = vi.hoisted(() => {
let nextNetSocket: FakeSocket | undefined;
let nextProxyTlsSocket: FakeSocket | undefined;
let nextTargetTlsSocket: FakeSocket | undefined;
return {
setNextNetSocket: (socket: FakeSocket) => {
nextNetSocket = socket;
},
setNextProxyTlsSocket: (socket: FakeSocket) => {
nextProxyTlsSocket = socket;
},
setNextTargetTlsSocket: (socket: FakeSocket) => {
nextTargetTlsSocket = socket;
},
netConnectSpy: vi.fn(() => {
if (!nextNetSocket) {
throw new Error("nextNetSocket not set");
}
const socket = nextNetSocket;
queueMicrotask(() => socket.emit("connect"));
return socket;
}),
tlsConnectSpy: vi.fn((options: { socket?: FakeSocket }) => {
if (options.socket) {
if (!nextTargetTlsSocket) {
throw new Error("nextTargetTlsSocket not set");
}
const socket = nextTargetTlsSocket;
if (socket.emitSecureConnectOnConnect) {
queueMicrotask(() => socket.emit("secureConnect"));
}
return socket;
}
if (!nextProxyTlsSocket) {
throw new Error("nextProxyTlsSocket not set");
}
const socket = nextProxyTlsSocket;
queueMicrotask(() => socket.emit("secureConnect"));
return socket;
}),
};
});
vi.mock("node:net", () => ({
connect: netConnectSpy,
isIP: (host: string) => {
if (host === "127.0.0.1") {
return 4;
}
if (host === "::1") {
return 6;
}
return 0;
},
}));
vi.mock("node:tls", () => ({
connect: tlsConnectSpy,
}));
function requireFirstTlsConnectOptions(): unknown {
const [call] = tlsConnectSpy.mock.calls;
if (!call) {
throw new Error("expected TLS connect call");
}
return call[0];
}
describe("openHttpConnectTunnel", () => {
beforeEach(() => {
vi.useRealTimers();
netConnectSpy.mockClear();
tlsConnectSpy.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("opens an HTTP CONNECT tunnel through the configured proxy", async () => {
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
const targetTlsSocket = new FakeSocket();
setNextNetSocket(proxySocket);
setNextTargetTlsSocket(targetTlsSocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
const result = await openHttpConnectTunnel({
proxyUrl: new URL("http://proxy.example:8080"),
targetHost: "api.push.apple.com",
targetPort: 443,
timeoutMs: 10_000,
});
expect(result).toBe(targetTlsSocket);
expect(netConnectSpy).toHaveBeenCalledWith({ host: "proxy.example", port: 8080 });
expect(proxySocket.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(tlsConnectSpy).toHaveBeenLastCalledWith({
socket: proxySocket,
servername: "api.push.apple.com",
ALPNProtocols: ["h2"],
});
});
it("supports HTTPS proxy URLs", async () => {
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
const targetTlsSocket = new FakeSocket();
setNextProxyTlsSocket(proxySocket);
setNextTargetTlsSocket(targetTlsSocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await openHttpConnectTunnel({
proxyUrl: new URL("https://proxy.example:8443"),
proxyTls: { ca: "proxy-ca" },
targetHost: "api.sandbox.push.apple.com",
targetPort: 443,
});
expect(requireFirstTlsConnectOptions()).toEqual({
host: "proxy.example",
port: 8443,
servername: "proxy.example",
ALPNProtocols: ["http/1.1"],
ca: "proxy-ca",
});
expect(tlsConnectSpy).toHaveBeenLastCalledWith({
socket: proxySocket,
servername: "api.sandbox.push.apple.com",
ALPNProtocols: ["h2"],
});
});
it("omits SNI for HTTPS proxy IP literals", async () => {
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
const targetTlsSocket = new FakeSocket();
setNextProxyTlsSocket(proxySocket);
setNextTargetTlsSocket(targetTlsSocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await openHttpConnectTunnel({
proxyUrl: new URL("https://127.0.0.1:8443"),
proxyTls: { ca: "proxy-ca" },
targetHost: "api.sandbox.push.apple.com",
targetPort: 443,
});
expect(requireFirstTlsConnectOptions()).toEqual({
host: "127.0.0.1",
port: 8443,
ALPNProtocols: ["http/1.1"],
ca: "proxy-ca",
});
});
it("sends basic proxy authorization and redacts credentials when CONNECT fails", async () => {
const proxySocket = new FakeSocket("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
setNextNetSocket(proxySocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await expect(
openHttpConnectTunnel({
proxyUrl: new URL("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 Proxy Authentication Required",
);
expect(proxySocket.writes[0]).toContain(
`Proxy-Authorization: Basic ${Buffer.from("user:secret").toString("base64")}`,
);
expect(proxySocket.destroyed).toBe(true);
});
it("redacts proxy URL query and fragment values when CONNECT fails", async () => {
const proxySocket = new FakeSocket("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
setNextNetSocket(proxySocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
let caught: unknown;
try {
await openHttpConnectTunnel({
proxyUrl: new URL("http://user:secret@proxy.example:8080/?token=hidden#fragment"),
targetHost: "api.push.apple.com",
targetPort: 443,
});
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
if (!(caught instanceof Error)) {
throw new Error("expected CONNECT failure");
}
expect(caught.message).toBe(
"Proxy CONNECT failed via http://proxy.example:8080: HTTP/1.1 407 Proxy Authentication Required",
);
expect(caught.message).not.toContain("secret");
expect(caught.message).not.toContain("hidden");
expect(caught.message).not.toContain("fragment");
});
it("rejects malformed proxy credentials through the normal cleanup path", async () => {
const proxySocket = new FakeSocket();
setNextNetSocket(proxySocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await expect(
openHttpConnectTunnel({
proxyUrl: new URL("http://%E0%A4%A@proxy.example:8080"),
targetHost: "api.push.apple.com",
targetPort: 443,
}),
).rejects.toThrow("Proxy CONNECT failed via http://proxy.example:8080: URI malformed");
expect(proxySocket.destroyed).toBe(true);
});
it("caps unterminated CONNECT response headers", async () => {
const proxySocket = new FakeSocket(`HTTP/1.1 200 ${"a".repeat(17 * 1024)}`);
setNextNetSocket(proxySocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await expect(
openHttpConnectTunnel({
proxyUrl: new URL("http://proxy.example:8080"),
targetHost: "api.push.apple.com",
targetPort: 443,
}),
).rejects.toThrow(
"Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT response headers exceeded 16384 bytes",
);
expect(proxySocket.destroyed).toBe(true);
});
it("waits for APNs TLS secureConnect before resolving", async () => {
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
const targetTlsSocket = new FakeSocket(undefined, { emitSecureConnectOnConnect: false });
setNextNetSocket(proxySocket);
setNextTargetTlsSocket(targetTlsSocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
let resolved = false;
const tunnel = openHttpConnectTunnel({
proxyUrl: new URL("http://proxy.example:8080"),
targetHost: "api.push.apple.com",
targetPort: 443,
}).then((socket) => {
resolved = true;
return socket;
});
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(resolved).toBe(false);
targetTlsSocket.emit("secureConnect");
await expect(tunnel).resolves.toBe(targetTlsSocket);
});
it("rejects APNs TLS tunnels that do not negotiate h2", async () => {
const proxySocket = new FakeSocket("HTTP/1.1 200 Connection Established\r\n\r\n");
const targetTlsSocket = new FakeSocket(undefined, { alpnProtocol: "http/1.1" });
setNextNetSocket(proxySocket);
setNextTargetTlsSocket(targetTlsSocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
await expect(
openHttpConnectTunnel({
proxyUrl: new URL("http://proxy.example:8080"),
targetHost: "api.push.apple.com",
targetPort: 443,
}),
).rejects.toThrow(
"Proxy CONNECT failed via http://proxy.example:8080: APNs TLS tunnel negotiated http/1.1 instead of h2",
);
expect(targetTlsSocket.destroyed).toBe(true);
});
it("rejects and destroys the proxy socket when CONNECT times out", async () => {
vi.useFakeTimers();
const proxySocket = new FakeSocket();
setNextNetSocket(proxySocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
const tunnel = openHttpConnectTunnel({
proxyUrl: new URL("http://proxy.example:8080"),
targetHost: "api.push.apple.com",
targetPort: 443,
timeoutMs: 1,
});
void tunnel.catch(() => undefined);
const rejected = expect(tunnel).rejects.toThrow(
"Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after 1ms",
);
await vi.advanceTimersByTimeAsync(1);
await rejected;
expect(proxySocket.destroyed).toBe(true);
});
it("caps oversized CONNECT timeouts before arming the watchdog", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const proxySocket = new FakeSocket();
setNextNetSocket(proxySocket);
const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js");
const tunnel = openHttpConnectTunnel({
proxyUrl: new URL("http://proxy.example:8080"),
targetHost: "api.push.apple.com",
targetPort: 443,
timeoutMs: Number.MAX_SAFE_INTEGER,
});
void tunnel.catch(() => undefined);
const rejected = expect(tunnel).rejects.toThrow(
`Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after ${MAX_TIMER_TIMEOUT_MS}ms`,
);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
await vi.advanceTimersByTimeAsync(1);
expect(proxySocket.destroyed).toBe(false);
await vi.advanceTimersToNextTimerAsync();
await rejected;
expect(proxySocket.destroyed).toBe(true);
});
});
-335
View File
@@ -1,335 +0,0 @@
// HTTP CONNECT tunnel support opens TLS target sockets through HTTP(S) forward
// proxies for APNs and similar clients.
import * as net from "node:net";
import * as tls from "node:tls";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import type { ManagedProxyTlsOptions } from "./proxy/proxy-tls.js";
/** Parameters for opening an APNs HTTP/2 tunnel through an HTTP(S) forward proxy. */
type HttpConnectTunnelParams = {
proxyUrl: URL;
proxyTls?: ManagedProxyTlsOptions;
targetHost: string;
targetPort: number;
timeoutMs?: number;
};
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
const MIN_CONNECT_TIMEOUT_MS = 1;
type ProxySocket = net.Socket | tls.TLSSocket;
type ConnectResponseBuffer = Buffer;
type ProxyConnectReadResult =
| {
kind: "incomplete";
responseBuffer: ConnectResponseBuffer;
}
| {
kind: "complete";
responseBuffer: ConnectResponseBuffer;
statusLine: string;
tunneledBytes: ConnectResponseBuffer | undefined;
};
function redactProxyUrl(proxyUrl: URL): string {
try {
return proxyUrl.origin;
} catch {
return "<invalid proxy URL>";
}
}
function resolveProxyHost(proxy: URL): string {
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
}
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 formatTunnelFailure(proxyUrl: URL, err: unknown): Error {
return new Error(
`Proxy CONNECT failed via ${redactProxyUrl(proxyUrl)}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
function writeConnectRequest(socket: net.Socket, proxy: URL, target: string): void {
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"));
}
function assertConnectHeaderBytesWithinLimit(size: number): void {
if (size > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
throw new Error(
`Proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`,
);
}
}
function readProxyConnectResponse(
responseBuffer: ConnectResponseBuffer,
chunk: ConnectResponseBuffer,
): ProxyConnectReadResult {
// CONNECT response data can include the first bytes of the target TLS stream;
// preserve them with unshift once the tunnel is established.
const nextBuffer = Buffer.concat([responseBuffer, chunk]);
const headerEnd = nextBuffer.indexOf("\r\n\r\n");
if (headerEnd === -1) {
assertConnectHeaderBytesWithinLimit(nextBuffer.length);
return { kind: "incomplete", responseBuffer: nextBuffer };
}
const bodyOffset = headerEnd + 4;
assertConnectHeaderBytesWithinLimit(bodyOffset);
const responseHeader = nextBuffer.subarray(0, bodyOffset).toString("latin1");
const statusLine = responseHeader.split("\r\n", 1)[0] ?? "";
// CONNECT can coalesce response headers and first tunneled bytes. Preserve
// those bytes so the target TLS handshake sees the stream from byte zero.
const tunneledBytes =
nextBuffer.length > bodyOffset ? nextBuffer.subarray(bodyOffset) : undefined;
return {
kind: "complete",
responseBuffer: nextBuffer,
statusLine,
tunneledBytes,
};
}
function isSuccessfulConnectStatusLine(statusLine: string): boolean {
return /^HTTP\/1\.[01] 2\d\d\b/.test(statusLine);
}
function connectToProxy(proxy: URL, proxyTls: ManagedProxyTlsOptions | undefined): ProxySocket {
const proxyHost = resolveProxyHost(proxy);
// TLS SNI cannot be an IP literal; omit it for IP-addressed HTTPS proxies.
const proxyServername = net.isIP(proxyHost) === 0 ? proxyHost : undefined;
const connectOptions = {
host: proxyHost,
port: resolveProxyPort(proxy),
};
if (proxy.protocol === "https:") {
return tls.connect({
...connectOptions,
...(proxyServername ? { servername: proxyServername } : {}),
ALPNProtocols: ["http/1.1"],
...(proxyTls?.ca ? { ca: proxyTls.ca } : {}),
});
}
return net.connect(connectOptions);
}
class HttpConnectTunnelAttempt {
private proxySocket: ProxySocket | undefined;
private targetTlsSocket: tls.TLSSocket | undefined;
private timeout: NodeJS.Timeout | undefined;
private settled = false;
private responseBuffer: ConnectResponseBuffer = Buffer.alloc(0);
constructor(
private readonly params: HttpConnectTunnelParams,
private readonly proxy: URL,
private readonly resolve: (socket: tls.TLSSocket) => void,
private readonly reject: (reason?: unknown) => void,
) {}
public start(): void {
try {
this.startTimeout();
this.proxySocket = connectToProxy(this.proxy, this.params.proxyTls);
this.proxySocket.once(
this.proxy.protocol === "https:" ? "secureConnect" : "connect",
this.onProxyConnected,
);
this.proxySocket.on("data", this.onProxyData);
this.proxySocket.once("end", this.onProxyClosedBeforeConnect);
this.proxySocket.once("error", this.fail);
this.proxySocket.once("close", this.onProxyClosedBeforeConnect);
} catch (err) {
this.fail(err);
}
}
private startTimeout(): void {
const timeoutMs =
this.params.timeoutMs === undefined || this.params.timeoutMs <= 0
? undefined
: resolveTimerTimeoutMs(this.params.timeoutMs, MIN_CONNECT_TIMEOUT_MS);
if (timeoutMs !== undefined) {
this.timeout = setTimeout(() => {
this.fail(new Error(`Proxy CONNECT timed out after ${timeoutMs}ms`));
}, timeoutMs);
}
}
private clearTimer(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
}
}
private cleanupProxyListeners(): void {
const socket = this.proxySocket;
if (!socket) {
return;
}
socket.off("data", this.onProxyData);
socket.off("end", this.onProxyClosedBeforeConnect);
socket.off("error", this.fail);
socket.off("close", this.onProxyClosedBeforeConnect);
socket.off("connect", this.onProxyConnected);
socket.off("secureConnect", this.onProxyConnected);
}
private cleanupTargetTlsListeners(): void {
const socket = this.targetTlsSocket;
if (!socket) {
return;
}
socket.off("secureConnect", this.onTargetSecureConnect);
socket.off("error", this.fail);
socket.off("close", this.onTargetTlsClosedBeforeSecureConnect);
}
private readonly fail = (err: unknown): void => {
if (this.settled) {
return;
}
this.settled = true;
this.clearTimer();
this.cleanupProxyListeners();
this.cleanupTargetTlsListeners();
// Failure may happen during either CONNECT or target TLS setup. Destroy both
// sockets so half-open proxy tunnels do not leak into the process.
this.targetTlsSocket?.destroy();
this.proxySocket?.destroy();
this.reject(formatTunnelFailure(this.params.proxyUrl, err));
};
private succeed(socket: tls.TLSSocket): void {
if (this.settled) {
socket.destroy();
return;
}
this.settled = true;
this.clearTimer();
this.cleanupProxyListeners();
this.cleanupTargetTlsListeners();
this.resolve(socket);
}
private readonly onProxyConnected = (): void => {
const socket = this.proxySocket;
if (!socket) {
this.fail(new Error("Proxy socket missing after connect"));
return;
}
const target = `${this.params.targetHost}:${this.params.targetPort}`;
try {
writeConnectRequest(socket, this.proxy, target);
} catch (err) {
this.fail(err);
}
};
private readonly onProxyData = (chunk: Buffer): void => {
let result: ProxyConnectReadResult;
try {
result = readProxyConnectResponse(this.responseBuffer, chunk);
} catch (err) {
this.fail(err);
return;
}
this.responseBuffer = result.responseBuffer;
if (result.kind === "incomplete") {
return;
}
const socket = this.proxySocket;
if (!socket) {
this.fail(new Error("Proxy socket missing after CONNECT response"));
return;
}
if (result.tunneledBytes) {
socket.unshift(result.tunneledBytes);
}
if (!isSuccessfulConnectStatusLine(result.statusLine)) {
this.fail(new Error(result.statusLine || "Proxy returned an invalid CONNECT response"));
return;
}
this.cleanupProxyListeners();
this.startTargetTls(socket);
};
private startTargetTls(socket: ProxySocket): void {
try {
this.targetTlsSocket = tls.connect({
socket,
servername: this.params.targetHost,
ALPNProtocols: ["h2"],
});
this.targetTlsSocket.once("secureConnect", this.onTargetSecureConnect);
this.targetTlsSocket.once("error", this.fail);
this.targetTlsSocket.once("close", this.onTargetTlsClosedBeforeSecureConnect);
} catch (err) {
this.fail(err);
}
}
private readonly onTargetSecureConnect = (): void => {
const socket = this.targetTlsSocket;
if (!socket) {
this.fail(new Error("APNs TLS socket missing after secureConnect"));
return;
}
if (socket.alpnProtocol !== "h2") {
const negotiated = socket.alpnProtocol || "no ALPN protocol";
this.fail(new Error(`APNs TLS tunnel negotiated ${negotiated} instead of h2`));
return;
}
this.succeed(socket);
};
private readonly onTargetTlsClosedBeforeSecureConnect = (): void => {
this.fail(new Error("APNs TLS tunnel closed before secureConnect"));
};
private readonly onProxyClosedBeforeConnect = (): void => {
this.fail(new Error("Proxy closed before CONNECT response"));
};
}
/** Opens a TLS-over-CONNECT tunnel and verifies the target negotiated HTTP/2. */
export async function openHttpConnectTunnel(
params: HttpConnectTunnelParams,
): Promise<tls.TLSSocket> {
const proxy = new URL(params.proxyUrl.href);
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
throw new Error(`Unsupported proxy protocol for APNs HTTP/2 CONNECT tunnel: ${proxy.protocol}`);
}
return await new Promise<tls.TLSSocket>((resolve, reject) => {
new HttpConnectTunnelAttempt(params, proxy, resolve, reject).start();
});
}
+137 -9
View File
@@ -1,17 +1,26 @@
// Covers APNs HTTP/2 session and proxy behavior.
import type http2 from "node:http2";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
registerActiveManagedProxyUrl,
stopActiveManagedProxyRegistration,
} from "./net/proxy/active-proxy-state.js";
type HttpConnectTunnelParams = Parameters<
typeof import("./net/http-connect-tunnel.js").openHttpConnectTunnel
type ProxyConnectTunnelParams = Parameters<
typeof import("@openclaw/proxyline").openProxyConnectTunnel
>[0];
const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.hoisted(() => {
const {
connectSpy,
tunnelSpy,
tlsConnectSpy,
setTargetTlsEvent,
fakeProxySocket,
fakeRequest,
fakeSession,
fakeTlsSocket,
} = vi.hoisted(() => {
class FakeEmitter {
private readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>();
@@ -36,6 +45,10 @@ const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.ho
return this;
}
removeListener(event: string, handler: (...args: unknown[]) => void): this {
return this.off(event, handler);
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
@@ -68,13 +81,40 @@ const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.ho
}),
request: vi.fn(() => fakeRequestLocal),
});
const fakeTlsSocketLocal = { encrypted: true };
const fakeProxySocketLocal = { destroy: vi.fn() };
const fakeTlsSocketLocal = Object.assign(new FakeEmitter(), {
encrypted: true,
alpnProtocol: "h2" as string | false,
destroyed: false,
destroy: vi.fn(),
});
fakeTlsSocketLocal.destroy.mockImplementation(() => {
fakeTlsSocketLocal.destroyed = true;
});
let targetTlsEvent: "secureConnect" | "close" | "error" | undefined = "secureConnect";
return {
fakeProxySocket: fakeProxySocketLocal,
fakeRequest: fakeRequestLocal,
fakeSession: fakeSessionLocal,
fakeTlsSocket: fakeTlsSocketLocal,
connectSpy: vi.fn(() => fakeSessionLocal),
tunnelSpy: vi.fn(async (_params: HttpConnectTunnelParams) => fakeTlsSocketLocal),
tunnelSpy: vi.fn(async (_params: ProxyConnectTunnelParams) => fakeProxySocketLocal),
tlsConnectSpy: vi.fn(() => {
const event = targetTlsEvent;
if (event) {
queueMicrotask(() => {
if (event === "error") {
fakeTlsSocketLocal.emit("error", new Error("target TLS failed"));
} else {
fakeTlsSocketLocal.emit(event);
}
});
}
return fakeTlsSocketLocal;
}),
setTargetTlsEvent: (event: typeof targetTlsEvent) => {
targetTlsEvent = event;
},
};
});
@@ -84,11 +124,16 @@ vi.mock("node:http2", () => ({
constants: { NGHTTP2_CANCEL: 8 },
}));
vi.mock("./net/http-connect-tunnel.js", () => ({
openHttpConnectTunnel: tunnelSpy,
vi.mock("node:tls", () => ({
default: { connect: tlsConnectSpy },
connect: tlsConnectSpy,
}));
function lastTunnelCall(): HttpConnectTunnelParams {
vi.mock("@openclaw/proxyline", () => ({
openProxyConnectTunnel: tunnelSpy,
}));
function lastTunnelCall(): ProxyConnectTunnelParams {
const calls = tunnelSpy.mock.calls;
const call = calls[calls.length - 1];
if (!call) {
@@ -108,8 +153,12 @@ function lastConnectCall(): [string, http2.ClientSessionOptions] {
describe("connectApnsHttp2Session", () => {
beforeEach(() => {
vi.useRealTimers();
connectSpy.mockClear();
tunnelSpy.mockClear();
tlsConnectSpy.mockClear();
setTargetTlsEvent("secureConnect");
fakeProxySocket.destroy.mockClear();
fakeRequest.reset();
fakeRequest.setEncoding.mockClear();
fakeRequest.end.mockClear();
@@ -119,7 +168,16 @@ describe("connectApnsHttp2Session", () => {
fakeSession.close.mockClear();
fakeSession.destroy.mockClear();
fakeSession.request.mockClear();
fakeTlsSocket.reset();
fakeTlsSocket.alpnProtocol = "h2";
fakeTlsSocket.destroyed = false;
fakeTlsSocket.destroy.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("uses direct http2.connect when managed proxy is inactive", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
@@ -188,6 +246,11 @@ describe("connectApnsHttp2Session", () => {
expect(tunnelCall.targetHost).toBe("api.push.apple.com");
expect(tunnelCall.targetPort).toBe(443);
expect(tunnelCall.timeoutMs).toBe(10_000);
expect(tlsConnectSpy).toHaveBeenCalledWith({
socket: fakeProxySocket,
servername: "api.push.apple.com",
ALPNProtocols: ["h2"],
});
expect(connectSpy).toHaveBeenCalledTimes(1);
const connectCall = lastConnectCall();
expect(connectCall[0]).toBe("https://api.push.apple.com");
@@ -196,6 +259,71 @@ describe("connectApnsHttp2Session", () => {
expect(createConnection?.(new URL("https://api.push.apple.com"), {})).toBe(fakeTlsSocket);
});
it("rejects a non-h2 target tunnel without exposing proxy URL details", async () => {
fakeTlsSocket.alpnProtocol = "http/1.1";
const { probeApnsHttp2ReachabilityViaProxy } = await import("./push-apns-http2.js");
const result = probeApnsHttp2ReachabilityViaProxy({
authority: "https://api.sandbox.push.apple.com",
proxyUrl: "http://proxy.example:8080/private?detail=opaque#fragment",
timeoutMs: 10_000,
});
await expect(result).rejects.toThrow(
"Proxy CONNECT failed via http://proxy.example:8080: APNs TLS tunnel negotiated http/1.1 instead of h2",
);
const proxyUrl = lastTunnelCall().proxyUrl;
expect(proxyUrl).toBeInstanceOf(URL);
if (!(proxyUrl instanceof URL)) {
throw new Error("expected normalized proxy URL");
}
expect(proxyUrl.pathname).toBe("/");
expect(proxyUrl.search).toBe("");
expect(proxyUrl.hash).toBe("");
expect(String(await result.catch((error: unknown) => error))).not.toMatch(
/private|opaque|fragment/,
);
expect(fakeTlsSocket.destroy).toHaveBeenCalledOnce();
expect(fakeProxySocket.destroy).toHaveBeenCalledOnce();
});
it("times out the target TLS handshake within the CONNECT deadline", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
const registration = registerActiveManagedProxyUrl(new URL("http://proxy.example:8080"), {
loopbackMode: "gateway-only",
});
vi.useFakeTimers();
setTargetTlsEvent(undefined);
const result = connectApnsHttp2Session({
authority: "https://api.push.apple.com",
timeoutMs: 1000,
});
const rejection = expect(result).rejects.toThrow(
"Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after 1000ms",
);
await vi.advanceTimersByTimeAsync(0);
expect(tlsConnectSpy).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1000);
await rejection;
stopActiveManagedProxyRegistration(registration);
expect(fakeTlsSocket.destroy).toHaveBeenCalledOnce();
expect(fakeProxySocket.destroy).toHaveBeenCalledOnce();
});
it("rejects malformed proxy auth before opening the native tunnel", async () => {
const { probeApnsHttp2ReachabilityViaProxy } = await import("./push-apns-http2.js");
await expect(
probeApnsHttp2ReachabilityViaProxy({
authority: "https://api.sandbox.push.apple.com",
proxyUrl: "http://%E0%A4%A@proxy.example:8080",
timeoutMs: 10_000,
}),
).rejects.toThrow("Proxy CONNECT failed via http://proxy.example:8080: URI malformed");
expect(tunnelSpy).not.toHaveBeenCalled();
});
it("caps oversized managed proxy timeouts before opening the APNs tunnel", async () => {
const registration = registerActiveManagedProxyUrl(new URL("https://proxy.example:8443"), {
loopbackMode: "gateway-only",
+84 -2
View File
@@ -1,8 +1,10 @@
// Opens APNs HTTP/2 sessions with optional managed proxy tunneling.
import { once } from "node:events";
import http2 from "node:http2";
import tls from "node:tls";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { openProxyConnectTunnel } from "@openclaw/proxyline";
import { toErrorObject } from "./errors.js";
import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js";
import {
getActiveManagedProxyUrl,
getActiveManagedProxyTlsOptions,
@@ -77,6 +79,86 @@ function assertApnsAuthority(authority: string): ApnsAuthority {
return normalized as ApnsAuthority;
}
function normalizeConnectProxyUrl(proxyUrl: URL): URL {
const normalized = new URL(proxyUrl);
normalized.pathname = "/";
normalized.search = "";
normalized.hash = "";
try {
// Proxyline decodes auth from its socket callback. Validate first so bad
// config rejects normally instead of escaping the EventEmitter boundary.
decodeURIComponent(normalized.username);
decodeURIComponent(normalized.password);
} catch (err) {
throw new Error(
`Proxy CONNECT failed via ${normalized.origin}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
return normalized;
}
async function openApnsTlsTunnel(params: {
proxyUrl: URL;
proxyTls?: ManagedProxyTlsOptions;
targetHost: string;
targetPort: number;
timeoutMs: number;
}): Promise<tls.TLSSocket> {
// CONNECT ignores URL paths. Strip path metadata before Proxyline sees it so
// tokens embedded in a configured proxy URL cannot surface in errors.
const proxyUrl = normalizeConnectProxyUrl(params.proxyUrl);
const deadline = Date.now() + params.timeoutMs;
const proxySocket = await openProxyConnectTunnel({
proxyUrl,
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
targetHost: params.targetHost,
targetPort: params.targetPort,
timeoutMs: params.timeoutMs,
});
const abortController = new AbortController();
let targetTlsSocket: tls.TLSSocket | undefined;
let timeout: NodeJS.Timeout | undefined;
try {
targetTlsSocket = tls.connect({
socket: proxySocket,
servername: params.targetHost,
ALPNProtocols: ["h2"],
});
timeout = setTimeout(
() => abortController.abort(new Error(`Proxy CONNECT timed out after ${params.timeoutMs}ms`)),
Math.max(1, deadline - Date.now()),
);
timeout.unref?.();
await Promise.race([
once(targetTlsSocket, "secureConnect", { signal: abortController.signal }),
once(targetTlsSocket, "close", { signal: abortController.signal }).then(() => {
throw new Error("APNs TLS tunnel closed before secureConnect");
}),
]);
if (targetTlsSocket.alpnProtocol !== "h2") {
throw new Error(
`APNs TLS tunnel negotiated ${targetTlsSocket.alpnProtocol || "no ALPN protocol"} instead of h2`,
);
}
return targetTlsSocket;
} catch (err) {
targetTlsSocket?.destroy();
proxySocket.destroy();
const failure = abortController.signal.aborted ? abortController.signal.reason : err;
throw new Error(
`Proxy CONNECT failed via ${proxyUrl.origin}: ${failure instanceof Error ? failure.message : String(failure)}`,
{ cause: failure },
);
} finally {
if (timeout) {
clearTimeout(timeout);
}
abortController.abort();
}
}
async function openProxiedApnsHttp2Session(params: {
authority: ApnsAuthority;
proxyUrl: ActiveManagedProxyUrl;
@@ -84,7 +166,7 @@ async function openProxiedApnsHttp2Session(params: {
timeoutMs: number;
}): Promise<http2.ClientHttp2Session> {
const apnsHost = new URL(params.authority).hostname;
const tlsSocket = await openHttpConnectTunnel({
const tlsSocket = await openApnsTlsTunnel({
proxyUrl: params.proxyUrl,
...(params.proxyTls ? { proxyTls: params.proxyTls } : {}),
targetHost: apnsHost,