From 9e33ab4ebf9816476e529ac0ce39dc6d0b3ce025 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 4 May 2026 02:30:40 +1000 Subject: [PATCH] fix: harden APNs proxy tunnel timeout --- CHANGELOG.md | 1 + src/infra/net/http-connect-tunnel.test.ts | 172 ++++++++++++++--- src/infra/net/http-connect-tunnel.ts | 216 ++++++++++++++++----- src/infra/push-apns-http2.test.ts | 3 +- src/infra/push-apns-http2.ts | 2 + src/infra/push-apns.test.ts | 221 ++++++++++++++++++++++ src/infra/push-apns.ts | 4 +- 7 files changed, 541 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82612f874de..8e21be706ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -278,6 +278,7 @@ Docs: https://docs.openclaw.ai - Slack: collapse routine Socket Mode pong-timeout reconnects into one OpenClaw reconnect line and suppress the duplicate Slack SDK pong warning. - Gateway/diagnostics: abort-drain embedded runs after an extended no-progress stall so a single dead session no longer leaves queued Discord/channel turns blocked behind repeated `recovery=none` liveness warnings. - Plugins/ClawHub: accept the live artifact resolver `kind`/`sha256` field names alongside the typed `artifactKind`/`artifactSha256` form so `clawhub:` installs of npm-pack and legacy ZIP packages no longer miss downloadable artifacts. Thanks @romneyda. +- Direct APNs: route direct HTTP/2 delivery through the active managed proxy so push requests honor configured egress controls, and let `openclaw proxy validate --apns-reachable` prove APNs is reachable through the proxy before deployment. (#74905) Thanks @jesse-merhi. - Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc. - Gateway/watch: run `doctor --fix --non-interactive` once and retry when the dev Gateway child exits during startup, so stale local plugin install/config state does not leave the tmux watch session disappearing without a repair attempt. - Doctor/Telegram: warn when selected Telegram quote replies can suppress `streaming.preview.toolProgress`, and document the `replyToMode` trade-off without changing runtime delivery. Fixes #73487. Thanks @GodsBoy. diff --git a/src/infra/net/http-connect-tunnel.test.ts b/src/infra/net/http-connect-tunnel.test.ts index 09b849a32eb..9e2dfc4189a 100644 --- a/src/infra/net/http-connect-tunnel.test.ts +++ b/src/infra/net/http-connect-tunnel.test.ts @@ -1,23 +1,105 @@ -import { describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +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; + + constructor(private readonly response?: string) { + super(); + } + + 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; -const { connectSpy, agentConstructorSpy, fakeSocket } = vi.hoisted(() => { - const fakeSocket = { destroyed: false, writable: true }; - const connectSpy = vi.fn(async () => fakeSocket); return { - fakeSocket, - connectSpy, - agentConstructorSpy: vi.fn(function HttpsProxyAgent(this: { connect: typeof connectSpy }) { - this.connect = connectSpy; + 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"); + } + return nextTargetTlsSocket; + } + if (!nextProxyTlsSocket) { + throw new Error("nextProxyTlsSocket not set"); + } + const socket = nextProxyTlsSocket; + queueMicrotask(() => socket.emit("secureConnect")); + return socket; }), }; }); -vi.mock("https-proxy-agent", () => ({ - HttpsProxyAgent: agentConstructorSpy, +vi.mock("node:net", () => ({ + connect: netConnectSpy, +})); + +vi.mock("node:tls", () => ({ + connect: tlsConnectSpy, })); describe("openHttpConnectTunnel", () => { - it("delegates CONNECT tunneling to https-proxy-agent with APNs TLS options", async () => { + 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({ @@ -27,20 +109,29 @@ describe("openHttpConnectTunnel", () => { timeoutMs: 10_000, }); - 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, + 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 through https-proxy-agent", async () => { + 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({ @@ -49,13 +140,22 @@ describe("openHttpConnectTunnel", () => { targetPort: 443, }); - expect(agentConstructorSpy).toHaveBeenCalledWith("https://proxy.example:8443", { - keepAlive: true, + expect(tlsConnectSpy.mock.calls[0]?.[0]).toEqual({ + host: "proxy.example", + port: 8443, + servername: "proxy.example", + ALPNProtocols: ["http/1.1"], + }); + expect(tlsConnectSpy).toHaveBeenLastCalledWith({ + socket: proxySocket, + servername: "api.sandbox.push.apple.com", + ALPNProtocols: ["h2"], }); }); - it("redacts proxy credentials in dependency failures", async () => { - connectSpy.mockRejectedValueOnce(new Error("407 Proxy Authentication Required")); + 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( @@ -65,7 +165,29 @@ describe("openHttpConnectTunnel", () => { targetPort: 443, }), ).rejects.toThrow( - "Proxy CONNECT failed via http://proxy.example:8080: 407 Proxy Authentication Required", + "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("rejects and destroys the proxy socket when CONNECT times out", async () => { + const proxySocket = new FakeSocket(); + setNextNetSocket(proxySocket); + const { openHttpConnectTunnel } = await import("./http-connect-tunnel.js"); + + await expect( + openHttpConnectTunnel({ + proxyUrl: "http://proxy.example:8080", + targetHost: "api.push.apple.com", + targetPort: 443, + timeoutMs: 1, + }), + ).rejects.toThrow( + "Proxy CONNECT failed via http://proxy.example:8080: Proxy CONNECT timed out after 1ms", + ); + expect(proxySocket.destroyed).toBe(true); }); }); diff --git a/src/infra/net/http-connect-tunnel.ts b/src/infra/net/http-connect-tunnel.ts index 810b8b24ff4..04ad11302ae 100644 --- a/src/infra/net/http-connect-tunnel.ts +++ b/src/infra/net/http-connect-tunnel.ts @@ -1,7 +1,5 @@ -import { EventEmitter } from "node:events"; -import type http from "node:http"; -import type net from "node:net"; -import { HttpsProxyAgent } from "https-proxy-agent"; +import * as net from "node:net"; +import * as tls from "node:tls"; export type HttpConnectTunnelParams = { proxyUrl: string; @@ -21,54 +19,172 @@ function redactProxyUrl(proxyUrl: string): string { } } -function isSuccessfulProxySocket(socket: net.Socket): boolean { - return !socket.destroyed && socket.writable; +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: string, 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")); } export async function openHttpConnectTunnel(params: HttpConnectTunnelParams): Promise { - 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) { - clearTimeout(timeout); - timeout = undefined; - } - }; - - try { - if (params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0) { - timeout = setTimeout(() => { - req.emit( - "error", - new Error(`Proxy CONNECT timed out after ${Math.trunc(params.timeoutMs ?? 0)}ms`), - ); - }, Math.trunc(params.timeoutMs)); - timeout.unref?.(); - } - - const socket = await agent.connect(req, { - host: params.targetHost, - port: params.targetPort, - secureEndpoint: true, - servername: params.targetHost, - ALPNProtocols: ["h2"], - }); - - if (!isSuccessfulProxySocket(socket)) { - throw new Error("proxy returned an unusable CONNECT socket"); - } - - clear(); - return socket; - } catch (err) { - clear(); - throw new Error( - `Proxy CONNECT failed via ${redactProxyUrl(params.proxyUrl)}: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); + const proxy = new URL(params.proxyUrl); + 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) => { + let proxySocket: net.Socket | tls.TLSSocket | undefined; + let targetTlsSocket: tls.TLSSocket | undefined; + let timeout: NodeJS.Timeout | undefined; + let settled = false; + let responseBuffer = Buffer.alloc(0); + + const clearTimer = () => { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + }; + + const cleanupProxyListeners = () => { + proxySocket?.off("data", onData); + proxySocket?.off("end", onEnd); + proxySocket?.off("error", onError); + proxySocket?.off("close", onClose); + proxySocket?.off("connect", onConnected); + proxySocket?.off("secureConnect", onConnected); + }; + + const fail = (err: unknown) => { + if (settled) { + return; + } + settled = true; + clearTimer(); + cleanupProxyListeners(); + targetTlsSocket?.destroy(); + proxySocket?.destroy(); + reject(formatTunnelFailure(params.proxyUrl, err)); + }; + + const succeed = (socket: tls.TLSSocket) => { + if (settled) { + socket.destroy(); + return; + } + settled = true; + clearTimer(); + cleanupProxyListeners(); + resolve(socket); + }; + + function onConnected(): void { + if (!proxySocket) { + fail(new Error("Proxy socket missing after connect")); + return; + } + const target = `${params.targetHost}:${params.targetPort}`; + writeConnectRequest(proxySocket, proxy, target); + } + + function onData(chunk: Buffer | string): void { + const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "latin1"); + responseBuffer = Buffer.concat([responseBuffer, nextChunk]); + const headerEnd = responseBuffer.indexOf("\r\n\r\n"); + if (headerEnd === -1 || !proxySocket) { + return; + } + + const bodyOffset = headerEnd + 4; + if (responseBuffer.length > bodyOffset) { + proxySocket.unshift(responseBuffer.subarray(bodyOffset)); + } + const responseHeader = responseBuffer.subarray(0, bodyOffset).toString("latin1"); + const statusLine = responseHeader.split("\r\n", 1)[0] ?? ""; + if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) { + fail(new Error(statusLine || "Proxy returned an invalid CONNECT response")); + return; + } + + cleanupProxyListeners(); + targetTlsSocket = tls.connect({ + socket: proxySocket, + servername: params.targetHost, + ALPNProtocols: ["h2"], + }); + succeed(targetTlsSocket); + } + + function onEnd(): void { + fail(new Error("Proxy closed before CONNECT response")); + } + + function onClose(): void { + fail(new Error("Proxy closed before CONNECT response")); + } + + function onError(err: Error): void { + fail(err); + } + + try { + if (params.timeoutMs && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0) { + timeout = setTimeout(() => { + fail(new Error(`Proxy CONNECT timed out after ${Math.trunc(params.timeoutMs ?? 0)}ms`)); + }, Math.trunc(params.timeoutMs)); + timeout.unref?.(); + } + + const proxyHost = resolveProxyHost(proxy); + const connectOptions = { + host: proxyHost, + port: resolveProxyPort(proxy), + }; + proxySocket = + proxy.protocol === "https:" + ? tls.connect({ + ...connectOptions, + servername: proxyHost, + ALPNProtocols: ["http/1.1"], + }) + : net.connect(connectOptions); + + proxySocket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected); + proxySocket.on("data", onData); + proxySocket.once("end", onEnd); + proxySocket.once("error", onError); + proxySocket.once("close", onClose); + } catch (err) { + fail(err); + } + }); } diff --git a/src/infra/push-apns-http2.test.ts b/src/infra/push-apns-http2.test.ts index 901c5d571ab..79a69a6d54b 100644 --- a/src/infra/push-apns-http2.test.ts +++ b/src/infra/push-apns-http2.test.ts @@ -18,8 +18,9 @@ const { connectSpy, tunnelSpy, fakeSession, fakeTlsSocket } = vi.hoisted(() => { }); vi.mock("node:http2", () => ({ - default: { connect: connectSpy }, + default: { connect: connectSpy, constants: { NGHTTP2_CANCEL: 8 } }, connect: connectSpy, + constants: { NGHTTP2_CANCEL: 8 }, })); vi.mock("./net/http-connect-tunnel.js", () => ({ diff --git a/src/infra/push-apns-http2.ts b/src/infra/push-apns-http2.ts index 04cde58ea90..68eea7bcc61 100644 --- a/src/infra/push-apns-http2.ts +++ b/src/infra/push-apns-http2.ts @@ -9,6 +9,8 @@ const APNS_AUTHORITIES = new Set([ type ApnsAuthority = "https://api.push.apple.com" | "https://api.sandbox.push.apple.com"; +export const APNS_HTTP2_CANCEL_CODE = http2.constants.NGHTTP2_CANCEL; + export type ConnectApnsHttp2SessionParams = { authority: string; timeoutMs: number; diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index 5c60388db89..b8e6c0137e3 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -1,5 +1,9 @@ import { generateKeyPairSync } from "node:crypto"; +import { createServer, type Server as HttpServer } from "node:http"; +import http2 from "node:http2"; +import net from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { startProxy, stopProxy, type ProxyHandle } from "./net/proxy/proxy-lifecycle.js"; import { sendApnsAlert, sendApnsBackgroundWake, @@ -11,6 +15,66 @@ const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1", }).privateKey.export({ format: "pem", type: "pkcs8" }); +const testApnsServerKey = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1l/DDGxT//Ma2 +1EC7ON4lb+9IOrHHd437rv5DBhMt7ZXpzmfZuXyJWd/RI3ljiCcJeXwTYdzLsyaR +aMRUnbzOoaI5/9LRdwmo007Y/US1ZxSjXW3L+vl3+QtiAUt6GDBZo49jB/LSCgu3 +lXYcN96OjpkF2j8rBR8Sn7eTUMIkiCFKn8V68hMRhDuHVJHWSGsMcfq8P7jZZ8S0 +31sUvQw8JaAvEhju3GbxbhQH8RnicR4VxI+bZ3v1JTnWNXCSClRmfDAM0AFrWv8k +qJXrhat4RsppeRSRDjENdUFS+VvW2s/oyaU9hXl3/G+9Srx5ANOCdLy+pTQdkq3b +Clg7a917AgMBAAECggEACpyyZolJ7PtiyeMI7pTQSp2XFrOZzKw8bgJk4oBtSE66 +AMIqruSx/Fbch3Zl81gzRWosXMRoNYRzkwwHBfwUp612pqJzUzSV9tNBqHJryWWy +PsL74rx44R1604N7qGSkfE1ci+JP7h1fLOw9M3Rb+1AmOigHomYRhRjNwhXcmp5u +spnubpOpJhYANFvQbard7yFmz2n1PcmtKOZussMN9F2w3CJ0pucDDEY+kpHVXiRa +j65STQi9rxoZVKjzCo4UGIrsURZCfrtZFQ5ga8JhzytY4rsgyF6Wl2gOiZ3E+nMs +34QDdL8ZMBU6in9lb/iVEvBuUdRFqRVtH+zoQRf1RQKBgQDnZps2u40/55XpeoYW +6fR5tmgGKN4bpcd7r5zRM+831n5v4MqBfJZEq/TeGSw2ddhQbzeezQg+CRzxuVy/ +MGNOKskGSZ5quamwqD3DDw8hIA6KvVpfBIEKfz4O3lbzP/3UsP3CM+c8FS2b7tzm +Mfggt1caVAj2dBd8cKyXS3bZRQKBgQDI5d4N2tAopvaRyzFXT4rhZPL1drOKCO0L +QMN8CRK1seke0W4j+pMqnT6uJd+mTGQH7aAUMFcbHvX1Pn8M5SudyljcleH8taxt +F8gw1tyH3+tnJqXiQOGFlEL6fX2V3ETThVPyVXQ2sIm17Q961tL+gSQPjYXPKTfU +IG37/9FnvwKBgBWzV6cAW7S8gSCOLvkDI7wuUP8S4hFxsI124Jv15N81rFHNoPAX +wPfbsHELp0vMLWcNpwerbrRyolZA7eO4I/f2pzeBu+uCUdmRTYl3ZhHTMcntDAaR +I5DacfVvAHR7cdB6cLG/sFXAHrDa67hiw0Q+LVr4uoZySKmQ336owxKJAoGBAMdZ +kicdYkF0rGevwZ5qB93xVkXNLAtlIBNyiIikWDSD/lfeafS5yR8YOgKFApD6bKiR +W6+s6EK5Tke1ZE1fexBwog0BjeY+QINgff44t0z9HZKV/zWsPB1ZKb12mRAEKyfZ +vZtSwKckNwKX4ix6z5RMgYQNYyJWPFf6dikBiMHxAoGBALEOli/ZehBqx5Bd7bHm +HKgZBuBmEDn0wdqB9bGXDdY84bjfNJ8crhiO+zFGzHRvwa+eO2dp0iffIFqXVG15 +/DjMPsMlaX2rmmHE0iYpTo3jbDm4TrGf8uhNFJBW2f7UMAvEK30NXi4aajzIadhD +LxmTaLeSxjQDE6BXgPlf2dr4 +-----END PRIVATE KEY-----`; + +const testApnsServerCert = `-----BEGIN CERTIFICATE----- +MIIDaDCCAlCgAwIBAgIUafG6emKuR1YWUNOTWjvy32lTx7YwDQYJKoZIhvcNAQEL +BQAwJTEjMCEGA1UEAwwaYXBpLnNhbmRib3gucHVzaC5hcHBsZS5jb20wHhcNMjYw +NTAxMDIzMjM2WhcNMzYwNDI4MDIzMjM2WjAlMSMwIQYDVQQDDBphcGkuc2FuZGJv +eC5wdXNoLmFwcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +ALWX8MMbFP/8xrbUQLs43iVv70g6scd3jfuu/kMGEy3tlenOZ9m5fIlZ39EjeWOI +Jwl5fBNh3MuzJpFoxFSdvM6hojn/0tF3CajTTtj9RLVnFKNdbcv6+Xf5C2IBS3oY +MFmjj2MH8tIKC7eVdhw33o6OmQXaPysFHxKft5NQwiSIIUqfxXryExGEO4dUkdZI +awxx+rw/uNlnxLTfWxS9DDwloC8SGO7cZvFuFAfxGeJxHhXEj5tne/UlOdY1cJIK +VGZ8MAzQAWta/ySoleuFq3hGyml5FJEOMQ11QVL5W9baz+jJpT2FeXf8b71KvHkA +04J0vL6lNB2SrdsKWDtr3XsCAwEAAaOBjzCBjDAdBgNVHQ4EFgQUcS8iUpQu0qs4 +MHxfmbd6WjvplH4wHwYDVR0jBBgwFoAUcS8iUpQu0qs4MHxfmbd6WjvplH4wDwYD +VR0TAQH/BAUwAwEB/zA5BgNVHREEMjAwghphcGkuc2FuZGJveC5wdXNoLmFwcGxl +LmNvbYISYXBpLnB1c2guYXBwbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQAVP+Qg +lAjpy9jINCeVkt4x/tdZvenag7tCD03ATQ/jrbndAkoHnJt7if1PXmH4+R/iW59X +yEv7o+2cTJa1g1QQgHMdiEBhGSGzNCQl8VhvZ6eZ6eeZuVLHZUPoZhV9+eax1sB/ +346JgSF6z2IIjr7H26jumZKuAqQsZwvQBOS20zZk+gewpHd4Xy3KxhLMz5Qtl7Df +ILty9ZCz2RlAy1H3bzxFEAVQt/SQ4cjmdI1U0svR3iHhpX9qT6DTZYvisjjpUBgN +0nu1jQgAYFHA2hQmgChmPJUYhkxjXtgemTYyiurXsi3VK/dQ9yrOBkk1MOwuOYZs +W8tBzWn/ZhBpWD88 +-----END CERTIFICATE-----`; + +type CapturedApnsRequest = { + headers: http2.IncomingHttpHeaders; + body: string; +}; + +type DestroyableConnection = { + destroy: () => void; +}; + function createDirectApnsSendFixture(params: { nodeId: string; environment: "sandbox" | "production"; @@ -72,6 +136,109 @@ function createRelayApnsSendFixture(params: { }; } +function listen(server: HttpServer | http2.Http2SecureServer): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("server address unavailable")); + return; + } + resolve(address.port); + }); + }); +} + +async function closeServer(server: HttpServer | http2.Http2SecureServer): Promise { + await new Promise((resolve, reject) => { + server.close((error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function startFakeApnsServer(): Promise<{ + port: number; + requests: CapturedApnsRequest[]; + stop: () => Promise; +}> { + const requests: CapturedApnsRequest[] = []; + const server = http2.createSecureServer({ + key: testApnsServerKey, + cert: testApnsServerCert, + allowHTTP1: false, + }); + server.on("stream", (stream: http2.ServerHttp2Stream, headers) => { + let body = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + body += typeof chunk === "string" ? chunk : String(chunk); + }); + stream.on("end", () => { + requests.push({ headers, body }); + stream.respond({ ":status": 200, "apns-id": "proxied-apns-id" }); + stream.end(); + }); + }); + const port = await listen(server); + return { + port, + requests, + stop: async () => await closeServer(server), + }; +} + +async function startConnectProxy(upstreamPort: number): Promise<{ + proxyUrl: string; + connectTargets: string[]; + stop: () => Promise; +}> { + const connectTargets: string[] = []; + const sockets = new Set(); + const server = createServer((_req, res) => { + res.writeHead(502); + res.end("CONNECT required"); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + server.on("connect", (req, clientSocket, head) => { + connectTargets.push(req.url ?? ""); + const upstreamSocket = net.connect(upstreamPort, "127.0.0.1", () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) { + upstreamSocket.write(head); + } + clientSocket.pipe(upstreamSocket); + upstreamSocket.pipe(clientSocket); + }); + sockets.add(clientSocket); + sockets.add(upstreamSocket); + clientSocket.on("close", () => sockets.delete(clientSocket)); + upstreamSocket.on("close", () => sockets.delete(upstreamSocket)); + clientSocket.on("error", () => upstreamSocket.destroy()); + upstreamSocket.on("error", () => clientSocket.destroy()); + }); + const port = await listen(server); + return { + proxyUrl: `http://127.0.0.1:${port}`, + connectTargets, + stop: async () => { + for (const socket of sockets) { + socket.destroy(); + } + await closeServer(server); + }, + }; +} + afterEach(async () => { vi.unstubAllGlobals(); }); @@ -116,6 +283,60 @@ describe("push APNs send semantics", () => { expect(result.transport).toBe("direct"); }); + it("routes direct APNs HTTP/2 requests through the active managed proxy", async () => { + const apnsServer = await startFakeApnsServer(); + const proxy = await startConnectProxy(apnsServer.port); + let proxyHandle: ProxyHandle | null = null; + const previousTlsRejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + try { + proxyHandle = await startProxy({ enabled: true, proxyUrl: proxy.proxyUrl }); + const { registration, auth } = createDirectApnsSendFixture({ + nodeId: "ios-node-proxied-alert", + environment: "sandbox", + sendResult: { + status: 200, + apnsId: "unused", + body: "", + }, + }); + + const result = await sendApnsAlert({ + registration, + nodeId: "ios-node-proxied-alert", + title: "Wake", + body: "Ping", + auth, + timeoutMs: 2_500, + }); + + expect(result).toMatchObject({ + ok: true, + status: 200, + apnsId: "proxied-apns-id", + transport: "direct", + }); + expect(proxy.connectTargets).toEqual(["api.sandbox.push.apple.com:443"]); + expect(apnsServer.requests).toHaveLength(1); + const request = apnsServer.requests[0]; + expect(request?.headers[":method"]).toBe("POST"); + expect(request?.headers[":path"]).toBe("/3/device/abcd1234abcd1234abcd1234abcd1234"); + expect(request?.headers["apns-topic"]).toBe("ai.openclaw.ios"); + expect(request?.headers["apns-push-type"]).toBe("alert"); + expect(request?.body).toContain('"nodeId":"ios-node-proxied-alert"'); + } finally { + if (previousTlsRejectUnauthorized === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = previousTlsRejectUnauthorized; + } + await stopProxy(proxyHandle); + await proxy.stop(); + await apnsServer.stop(); + } + }); + it("sends background wake pushes with silent payload semantics", async () => { const { send, registration, auth } = createDirectApnsSendFixture({ nodeId: "ios-node-wake", diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index 695276167d5..63adadf0656 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -9,7 +9,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 { APNS_HTTP2_CANCEL_CODE, connectApnsHttp2Session } from "./push-apns-http2.js"; import { type ApnsRelayConfig, type ApnsRelayPushResponse, @@ -702,7 +702,7 @@ async function sendApnsRequest(params: { req.setEncoding("utf8"); req.setTimeout(params.timeoutMs, () => { - req.close(http2.constants.NGHTTP2_CANCEL); + req.close(APNS_HTTP2_CANCEL_CODE); fail(new Error(`APNs request timed out after ${params.timeoutMs}ms`)); }); req.on("response", (headers) => {