fix: harden APNs proxy tunnel timeout

This commit is contained in:
jesse-merhi
2026-05-04 02:30:40 +10:00
committed by clawsweeper
parent 281f82d29d
commit 9e33ab4ebf
7 changed files with 541 additions and 78 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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<net.Socket> {
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<net.Socket>((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);
}
});
}

View File

@@ -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", () => ({

View File

@@ -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;

View File

@@ -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<number> {
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<void> {
await new Promise<void>((resolve, reject) => {
server.close((error?: Error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function startFakeApnsServer(): Promise<{
port: number;
requests: CapturedApnsRequest[];
stop: () => Promise<void>;
}> {
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<void>;
}> {
const connectTargets: string[] = [];
const sockets = new Set<DestroyableConnection>();
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",

View File

@@ -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) => {