diff --git a/src/infra/net/http-connect-tunnel.test.ts b/src/infra/net/http-connect-tunnel.test.ts deleted file mode 100644 index 97a740c33d69..000000000000 --- a/src/infra/net/http-connect-tunnel.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts deleted file mode 100644 index add0c85660b0..000000000000 --- a/src/infra/net/http-connect-tunnel.ts +++ /dev/null @@ -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 ""; - } -} - -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 { - 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((resolve, reject) => { - new HttpConnectTunnelAttempt(params, proxy, resolve, reject).start(); - }); -} diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts index dacb099c49f8..c2765ae34652 100644 --- a/src/infra/push-apns-http2.test.ts +++ b/src/infra/push-apns-http2.test.ts @@ -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 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", diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index d9bb69850c2c..68bbbd5a430d 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -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 { + // 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 { 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,