mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
fix: harden APNs proxy tunnel timeout
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user