fix: proxy direct APNs HTTP2 sessions (#74905)

Summary:
- This PR routes direct APNs HTTP/2 sends through an APNs allowlisted managed-proxy CONNECT wrapper, adds APNs proxy validation/docs/guardrails, and expands regression and live-test coverage.
- Reproducibility: yes. source-reproducible: current main `sendApnsRequest()` still uses raw `http2.connect(au ... nly covers HTTP/global-agent/Undici hooks. I did not run a live APNs reproduction in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test: guard raw HTTP2 APNs connections
- PR branch already contained follow-up commit before automerge: test: guard raw HTTP2 with OpenGrep
- PR branch already contained follow-up commit before automerge: lint: ban raw HTTP2 imports
- PR branch already contained follow-up commit before automerge: fix: use managed proxy state for APNs
- PR branch already contained follow-up commit before automerge: test: exercise APNs active proxy state
- PR branch already contained follow-up commit before automerge: fix: reject conflicting managed proxy activation

Validation:
- ClawSweeper review passed for head dab7c86a75.
- Required merge gates passed before the squash merge.

Prepared head SHA: dab7c86a75
Review: https://github.com/openclaw/openclaw/pull/74905#issuecomment-4350181159

Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Jesse Merhi
2026-05-04 21:04:17 +10:00
committed by GitHub
parent 5efbb3078a
commit d5b0083300
30 changed files with 2159 additions and 89 deletions

View File

@@ -43,6 +43,8 @@ describe("proxy cli runtime", () => {
"OPENCLAW_DEBUG_PROXY_CERT_DIR",
"OPENCLAW_DEBUG_PROXY_SESSION_ID",
"OPENCLAW_DEBUG_PROXY_ENABLED",
"FORCE_COLOR",
"NO_COLOR",
] as const;
const savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
let tempDir = "";
@@ -54,6 +56,8 @@ describe("proxy cli runtime", () => {
process.env.OPENCLAW_DEBUG_PROXY_CERT_DIR = path.join(tempDir, "certs");
delete process.env.OPENCLAW_DEBUG_PROXY_ENABLED;
delete process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID;
delete process.env.FORCE_COLOR;
process.env.NO_COLOR = "1";
getRuntimeConfigMock.mockReset();
getRuntimeConfigMock.mockReturnValue({
proxy: {
@@ -109,6 +113,8 @@ describe("proxy cli runtime", () => {
proxyUrl: "http://override.example:3128",
allowedUrls: ["https://allowed.example/"],
deniedUrls: ["http://127.0.0.1/"],
apnsReachability: true,
apnsAuthority: "https://api.sandbox.push.apple.com",
timeoutMs: 1234,
});
@@ -122,6 +128,8 @@ describe("proxy cli runtime", () => {
proxyUrlOverride: "http://override.example:3128",
allowedUrls: ["https://allowed.example/"],
deniedUrls: ["http://127.0.0.1/"],
apnsReachability: true,
apnsAuthority: "https://api.sandbox.push.apple.com",
timeoutMs: 1234,
});
expect(process.stdout.write).toHaveBeenCalledWith(
@@ -307,6 +315,66 @@ describe("proxy cli runtime", () => {
);
});
it("prints check errors on the same line", async () => {
runProxyValidationMock.mockResolvedValueOnce({
ok: true,
config: {
enabled: true,
proxyUrl: "http://proxy.example:3128",
source: "config",
errors: [],
},
checks: [
{
kind: "denied",
url: "http://127.0.0.1:12345/",
ok: true,
error: "fetch failed",
},
],
});
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
await runProxyValidateCommand({});
expect(process.stdout.write).toHaveBeenCalledWith(
"Proxy validation passed\n\n" +
"Proxy\n" +
" Source: config\n" +
" URL: http://proxy.example:3128/\n\n" +
"Checks\n" +
" ✓ denied http://127.0.0.1:12345/ — fetch failed\n",
);
});
it("applies the terminal color theme when rich output is enabled", async () => {
vi.resetModules();
vi.doMock("../terminal/theme.js", () => ({
colorize: (rich: boolean, color: (value: string) => string, value: string) =>
rich ? color(value) : value,
isRich: () => true,
theme: {
heading: (value: string) => `<heading>${value}</heading>`,
success: (value: string) => `<success>${value}</success>`,
error: (value: string) => `<error>${value}</error>`,
muted: (value: string) => `<muted>${value}</muted>`,
warn: (value: string) => `<warn>${value}</warn>`,
},
}));
try {
const { runProxyValidateCommand } = await import("./proxy-cli.runtime.js");
await runProxyValidateCommand({});
const output = String(vi.mocked(process.stdout.write).mock.calls[0]?.[0] ?? "");
expect(output).toContain("<success>Proxy validation passed</success>");
expect(output).toContain("<heading>Checks</heading>");
expect(output).toContain("<success>✓</success>");
} finally {
vi.doUnmock("../terminal/theme.js");
}
});
it("prints actionable check failure output", async () => {
runProxyValidationMock.mockResolvedValueOnce({
ok: false,
@@ -343,8 +411,7 @@ describe("proxy cli runtime", () => {
" URL: http://proxy.example:3128/\n\n" +
"Checks\n" +
" ✓ allowed http://target.example/allowed HTTP 200\n" +
" ✗ denied http://target.example/allowed HTTP 200\n" +
" Denied destination was reachable through the proxy\n\n" +
" ✗ denied http://target.example/allowed HTTP 200 — Denied destination was reachable through the proxy\n\n" +
"Next steps\n" +
" Update the proxy ACL so denied destinations are blocked, or pass the expected --denied-url values.\n",
);

View File

@@ -19,6 +19,7 @@ import {
getDebugProxyCaptureStore,
} from "../proxy-capture/store.sqlite.js";
import type { CaptureQueryPreset } from "../proxy-capture/types.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
export async function runDebugProxyStartCommand(opts: { host?: string; port?: number }) {
const settings = resolveDebugProxySettings();
@@ -148,11 +149,41 @@ function redactProxyValidationResult(result: ProxyValidationResult): ProxyValida
};
}
function formatProxyCheckLine(check: ProxyValidationResult["checks"][number]): string {
const icon = check.ok ? "✓" : "✗";
const paddedKind = check.kind.padEnd(7, " ");
const status = check.status === undefined ? "" : ` HTTP ${check.status}`;
return ` ${icon} ${paddedKind} ${check.url}${status}`;
type ProxyValidationTextColors = {
heading: (value: string) => string;
success: (value: string) => string;
error: (value: string) => string;
muted: (value: string) => string;
warn: (value: string) => string;
};
function getProxyValidationTextColors(): ProxyValidationTextColors {
const rich = isRich();
const apply = (color: (value: string) => string) => (value: string) =>
colorize(rich, color, value);
return {
heading: apply(theme.heading),
success: apply(theme.success),
error: apply(theme.error),
muted: apply(theme.muted),
warn: apply(theme.warn),
};
}
function formatProxyCheckLine(
check: ProxyValidationResult["checks"][number],
colors: ProxyValidationTextColors,
): string {
const icon = check.ok ? colors.success("✓") : colors.error("✗");
const paddedKind = colors.muted(check.kind.padEnd(7, " "));
const status =
check.status === undefined
? ""
: ` ${check.ok ? colors.success(`HTTP ${check.status}`) : colors.error(`HTTP ${check.status}`)}`;
const detail = check.error
? `${check.ok ? colors.muted(check.error) : colors.error(check.error)}`
: "";
return ` ${icon} ${paddedKind} ${check.url}${status}${detail}`;
}
function formatProxyValidationNextSteps(result: ProxyValidationResult): string[] {
@@ -185,37 +216,35 @@ function formatProxyValidationNextSteps(result: ProxyValidationResult): string[]
}
function formatProxyValidationText(result: ProxyValidationResult): string {
const colors = getProxyValidationTextColors();
const redactedProxyUrl = redactProxyUrl(result.config.proxyUrl);
const lines = [
`Proxy validation ${result.ok ? "passed" : "failed"}`,
result.ok ? colors.success("Proxy validation passed") : colors.error("Proxy validation failed"),
"",
"Proxy",
` Source: ${result.config.source}`,
` URL: ${redactedProxyUrl ?? "not configured"}`,
colors.heading("Proxy"),
` Source: ${colors.muted(result.config.source)}`,
` URL: ${redactedProxyUrl ?? colors.muted("not configured")}`,
];
if (result.config.errors.length > 0) {
lines.push("", "Problems");
lines.push("", colors.heading("Problems"));
for (const error of result.config.errors) {
lines.push(` - ${error}`);
lines.push(` - ${colors.error(error)}`);
}
}
if (result.checks.length > 0) {
lines.push("", "Checks");
lines.push("", colors.heading("Checks"));
for (const check of result.checks) {
lines.push(formatProxyCheckLine(check));
if (check.error) {
lines.push(` ${check.error}`);
}
lines.push(formatProxyCheckLine(check, colors));
}
}
const nextSteps = formatProxyValidationNextSteps(result);
if (nextSteps.length > 0) {
lines.push("", "Next steps");
lines.push("", colors.heading("Next steps"));
for (const nextStep of nextSteps) {
lines.push(` ${nextStep}`);
lines.push(` ${colors.warn(nextStep)}`);
}
}
@@ -227,6 +256,8 @@ export async function runProxyValidateCommand(opts: {
proxyUrl?: string;
allowedUrls?: string[];
deniedUrls?: string[];
apnsReachability?: boolean;
apnsAuthority?: string;
timeoutMs?: number;
}) {
const config = getRuntimeConfig();
@@ -236,6 +267,8 @@ export async function runProxyValidateCommand(opts: {
proxyUrlOverride: opts.proxyUrl,
allowedUrls: opts.allowedUrls,
deniedUrls: opts.deniedUrls,
apnsReachability: opts.apnsReachability,
apnsAuthority: opts.apnsAuthority,
timeoutMs: opts.timeoutMs,
});
const outputResult = redactProxyValidationResult(result);

View File

@@ -26,6 +26,8 @@ describe("proxy cli", () => {
"--proxy-url",
"--allowed-url",
"--denied-url",
"--apns-reachable",
"--apns-authority",
"--timeout-ms",
]);
});

View File

@@ -67,6 +67,8 @@ export function registerProxyCli(program: Command) {
collectOption,
)
.option("--denied-url <url>", "Destination expected to be blocked by the proxy", collectOption)
.option("--apns-reachable", "Also verify sandbox APNs HTTP/2 is reachable through the proxy")
.option("--apns-authority <url>", "APNs authority to probe with --apns-reachable")
.option("--timeout-ms <ms>", "Per-request timeout in milliseconds", parseOptionalNumber)
.action(
async (opts: {
@@ -74,6 +76,8 @@ export function registerProxyCli(program: Command) {
proxyUrl?: string;
allowedUrl?: string[];
deniedUrl?: string[];
apnsReachable?: boolean;
apnsAuthority?: string;
timeoutMs?: number;
}) => {
const runtime = await loadProxyCliRuntime();
@@ -82,6 +86,8 @@ export function registerProxyCli(program: Command) {
proxyUrl: opts.proxyUrl,
allowedUrls: opts.allowedUrl,
deniedUrls: opts.deniedUrl,
apnsReachability: opts.apnsReachable,
apnsAuthority: opts.apnsAuthority,
timeoutMs: opts.timeoutMs,
});
},

View File

@@ -0,0 +1,308 @@
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;
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,
}));
vi.mock("node:tls", () => ({
connect: tlsConnectSpy,
}));
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"),
targetHost: "api.sandbox.push.apple.com",
targetPort: 443,
});
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("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 () => {
const proxySocket = new FakeSocket();
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,
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

@@ -0,0 +1,315 @@
import * as net from "node:net";
import * as tls from "node:tls";
export type HttpConnectTunnelParams = {
proxyUrl: URL;
targetHost: string;
targetPort: number;
timeoutMs?: number;
};
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
type ProxySocket = net.Socket | tls.TLSSocket;
type ConnectResponseBuffer = Buffer;
type ProxyConnectReadResult =
| {
kind: "incomplete";
responseBuffer: ConnectResponseBuffer;
}
| {
kind: "complete";
responseBuffer: ConnectResponseBuffer;
statusLine: string;
tunneledBytes: ConnectResponseBuffer | undefined;
};
function redactProxyUrl(proxyUrl: URL): string {
try {
return proxyUrl.origin;
} catch {
return "<invalid proxy URL>";
}
}
function resolveProxyHost(proxy: URL): string {
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
}
function resolveProxyPort(proxy: URL): number {
if (proxy.port) {
return Number(proxy.port);
}
return proxy.protocol === "https:" ? 443 : 80;
}
function resolveProxyAuthorization(proxy: URL): string | undefined {
if (!proxy.username && !proxy.password) {
return undefined;
}
const username = decodeURIComponent(proxy.username);
const password = decodeURIComponent(proxy.password);
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
function formatTunnelFailure(proxyUrl: URL, err: unknown): Error {
return new Error(
`Proxy CONNECT failed via ${redactProxyUrl(proxyUrl)}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
function writeConnectRequest(socket: net.Socket, proxy: URL, target: string): void {
const headers = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`, "Proxy-Connection: Keep-Alive"];
const authorization = resolveProxyAuthorization(proxy);
if (authorization) {
headers.push(`Proxy-Authorization: ${authorization}`);
}
socket.write([...headers, "", ""].join("\r\n"));
}
function assertConnectHeaderBytesWithinLimit(size: number): void {
if (size > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
throw new Error(
`Proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`,
);
}
}
function readProxyConnectResponse(
responseBuffer: ConnectResponseBuffer,
chunk: ConnectResponseBuffer,
): ProxyConnectReadResult {
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] ?? "";
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): ProxySocket {
const proxyHost = resolveProxyHost(proxy);
const connectOptions = {
host: proxyHost,
port: resolveProxyPort(proxy),
};
if (proxy.protocol === "https:") {
return tls.connect({
...connectOptions,
servername: proxyHost,
ALPNProtocols: ["http/1.1"],
});
}
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.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;
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
this.timeout = setTimeout(() => {
this.fail(new Error(`Proxy CONNECT timed out after ${Math.trunc(timeoutMs)}ms`));
}, Math.trunc(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();
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"));
};
}
export async function openHttpConnectTunnel(
params: HttpConnectTunnelParams,
): Promise<tls.TLSSocket> {
const proxy = new URL(params.proxyUrl.href);
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
throw new Error(`Unsupported proxy protocol for APNs HTTP/2 CONNECT tunnel: ${proxy.protocol}`);
}
return await new Promise<tls.TLSSocket>((resolve, reject) => {
new HttpConnectTunnelAttempt(params, proxy, resolve, reject).start();
});
}

View File

@@ -0,0 +1,52 @@
export type ActiveManagedProxyUrl = Readonly<URL>;
export type ActiveManagedProxyRegistration = {
proxyUrl: ActiveManagedProxyUrl;
stopped: boolean;
};
let activeProxyUrl: ActiveManagedProxyUrl | undefined;
let activeProxyRegistrationCount = 0;
export function registerActiveManagedProxyUrl(proxyUrl: URL): ActiveManagedProxyRegistration {
const normalizedProxyUrl = new URL(proxyUrl.href);
if (activeProxyUrl !== undefined) {
if (activeProxyUrl.href !== normalizedProxyUrl.href) {
throw new Error(
"proxy: cannot activate a managed proxy while another proxy is active; " +
"stop the current proxy before changing proxy.proxyUrl.",
);
}
activeProxyRegistrationCount += 1;
return { proxyUrl: activeProxyUrl, stopped: false };
}
activeProxyUrl = normalizedProxyUrl;
activeProxyRegistrationCount = 1;
return { proxyUrl: activeProxyUrl, stopped: false };
}
export function stopActiveManagedProxyRegistration(
registration: ActiveManagedProxyRegistration,
): void {
if (registration.stopped) {
return;
}
registration.stopped = true;
if (activeProxyUrl?.href !== registration.proxyUrl.href) {
return;
}
activeProxyRegistrationCount = Math.max(0, activeProxyRegistrationCount - 1);
if (activeProxyRegistrationCount === 0) {
activeProxyUrl = undefined;
}
}
export function getActiveManagedProxyUrl(): ActiveManagedProxyUrl | undefined {
return activeProxyUrl;
}
export function _resetActiveManagedProxyStateForTests(): void {
activeProxyUrl = undefined;
activeProxyRegistrationCount = 0;
}

View File

@@ -19,6 +19,7 @@ vi.mock("../../../logger.js", () => ({
import { bootstrap as bootstrapGlobalAgent } from "global-agent";
import { logInfo, logWarn } from "../../../logger.js";
import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js";
import { _resetActiveManagedProxyStateForTests } from "./active-proxy-state.js";
import {
_resetGlobalAgentBootstrapForTests,
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane,
@@ -66,6 +67,7 @@ describe("startProxy", () => {
mockLogInfo.mockReset();
mockLogWarn.mockReset();
_resetGlobalAgentBootstrapForTests();
_resetActiveManagedProxyStateForTests();
(global as Record<string, unknown>)["GLOBAL_AGENT"] = undefined;
http.request = originalHttpRequest;
http.get = originalHttpGet;
@@ -113,6 +115,23 @@ describe("startProxy", () => {
expect(mockLogWarn).not.toHaveBeenCalled();
});
it("exposes the active managed proxy URL", async () => {
const { getActiveManagedProxyUrl } = await import("./active-proxy-state.js");
expect(getActiveManagedProxyUrl()).toBeUndefined();
const handle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
expect(getActiveManagedProxyUrl()?.href).toBe("http://127.0.0.1:3128/");
await stopProxy(handle);
expect(getActiveManagedProxyUrl()).toBeUndefined();
});
it("uses OPENCLAW_PROXY_URL when config proxyUrl is omitted", async () => {
process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128";
@@ -272,7 +291,7 @@ describe("startProxy", () => {
expect((global as Record<string, unknown>)["GLOBAL_AGENT"]).toBeUndefined();
});
it("keeps process-wide proxy hooks active until the last overlapping handle stops", async () => {
it("keeps same-url overlapping handles active until the final stop", async () => {
const patchedHttpRequest = vi.fn() as unknown as typeof http.request;
const patchedHttpGet = vi.fn() as unknown as typeof http.get;
const patchedHttpsRequest = vi.fn() as unknown as typeof https.request;
@@ -294,22 +313,25 @@ describe("startProxy", () => {
});
const secondHandle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3129",
proxyUrl: "http://127.0.0.1:3128",
});
expect(mockForceResetGlobalDispatcher).toHaveBeenCalledOnce();
expect(mockBootstrapGlobalAgent).toHaveBeenCalledOnce();
expect(http.request).toBe(patchedHttpRequest);
expect(https.request).toBe(patchedHttpsRequest);
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129");
await stopProxy(firstHandle);
expect(http.request).toBe(patchedHttpRequest);
expect(https.request).toBe(patchedHttpsRequest);
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3129");
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
await stopProxy(secondHandle);
expect(http.request).toBe(patchedHttpRequest);
expect(https.request).toBe(patchedHttpsRequest);
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
await stopProxy(firstHandle);
expect(http.request).toBe(originalHttpRequest);
expect(http.get).toBe(originalHttpGet);
expect(https.request).toBe(originalHttpsRequest);
@@ -318,6 +340,25 @@ describe("startProxy", () => {
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBeUndefined();
});
it("rejects overlapping handles with different managed proxy URLs", async () => {
const firstHandle = await startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
});
await expect(
startProxy({
enabled: true,
proxyUrl: "http://127.0.0.1:3129",
}),
).rejects.toThrow("cannot activate a managed proxy");
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
await stopProxy(firstHandle);
});
it("restores env and throws when undici activation fails", async () => {
mockForceResetGlobalDispatcher.mockImplementationOnce(() => {
throw new Error("dispatcher failed");

View File

@@ -15,6 +15,12 @@ import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
import { logInfo, logWarn } from "../../../logger.js";
import { isLoopbackIpAddress } from "../../../shared/net/ip.js";
import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js";
import {
getActiveManagedProxyUrl,
registerActiveManagedProxyUrl,
stopActiveManagedProxyRegistration,
type ActiveManagedProxyRegistration,
} from "./active-proxy-state.js";
export type ProxyHandle = {
/** The operator-managed proxy URL injected into process.env. */
@@ -64,10 +70,6 @@ type NodeHttpStackSnapshot = {
hadGlobalAgent: boolean;
globalAgent: unknown;
};
type ActiveProxyRegistration = {
proxyUrl: string;
stopped: boolean;
};
type GlobalAgentConnectConfiguration = Record<string, unknown> & {
host: string;
tls: Record<string, unknown>;
@@ -82,14 +84,12 @@ type GlobalAgentHttpsAgent = {
let globalAgentBootstrapped = false;
let nodeHttpStackSnapshot: NodeHttpStackSnapshot | null = null;
let activeProxyRegistrations: ActiveProxyRegistration[] = [];
let baseProxyEnvSnapshot: ProxyEnvSnapshot | null = null;
let patchedGlobalAgentHttpsAgents = new WeakSet<object>();
export function _resetGlobalAgentBootstrapForTests(): void {
globalAgentBootstrapped = false;
nodeHttpStackSnapshot = null;
activeProxyRegistrations = [];
baseProxyEnvSnapshot = null;
patchedGlobalAgentHttpsAgents = new WeakSet<object>();
}
@@ -302,16 +302,6 @@ function patchGlobalAgentHttpsConnectTlsTargetHost(): void {
patchedGlobalAgentHttpsAgents.add(agent);
}
function findTopActiveProxyRegistration(): ActiveProxyRegistration | null {
for (let index = activeProxyRegistrations.length - 1; index >= 0; index -= 1) {
const registration = activeProxyRegistrations[index];
if (!registration.stopped) {
return registration;
}
}
return null;
}
function resetUndiciDispatcherForProxyLifecycle(): void {
try {
forceResetGlobalDispatcher();
@@ -336,16 +326,6 @@ function restoreNodeHttpStackForProxyLifecycle(): void {
}
}
function reapplyActiveProxyRuntime(proxyUrl: string): void {
applyProxyEnv(proxyUrl);
resetUndiciDispatcherForProxyLifecycle();
try {
bootstrapNodeHttpStack(proxyUrl);
} catch (err) {
logWarn(`proxy: failed to refresh node HTTP proxy hooks: ${String(err)}`);
}
}
function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void {
restoreProxyEnv(snapshot);
resetUndiciDispatcherForProxyLifecycle();
@@ -353,28 +333,17 @@ function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void {
restoreNodeHttpStackForProxyLifecycle();
}
function restoreAfterFailedProxyActivation(
previousActiveRegistration: ActiveProxyRegistration | null,
restoreSnapshot: ProxyEnvSnapshot,
): void {
if (previousActiveRegistration) {
reapplyActiveProxyRuntime(previousActiveRegistration.proxyUrl);
return;
}
function restoreAfterFailedProxyActivation(restoreSnapshot: ProxyEnvSnapshot): void {
restoreInactiveProxyRuntime(restoreSnapshot);
baseProxyEnvSnapshot = null;
}
function stopActiveProxyRegistration(registration: ActiveProxyRegistration): void {
function stopActiveProxyRegistration(registration: ActiveManagedProxyRegistration): void {
if (registration.stopped) {
return;
}
registration.stopped = true;
activeProxyRegistrations = activeProxyRegistrations.filter((entry) => !entry.stopped);
const nextActiveRegistration = findTopActiveProxyRegistration();
if (nextActiveRegistration) {
reapplyActiveProxyRuntime(nextActiveRegistration.proxyUrl);
stopActiveManagedProxyRegistration(registration);
if (getActiveManagedProxyUrl()) {
return;
}
@@ -424,23 +393,34 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
}
const proxyUrl = resolveProxyUrl(config);
const previousActiveRegistration = findTopActiveProxyRegistration();
const activeProxyUrl = getActiveManagedProxyUrl();
if (activeProxyUrl) {
const registration = registerActiveManagedProxyUrl(new URL(proxyUrl));
const handle: ProxyHandle = {
proxyUrl,
injectedProxyUrl: proxyUrl,
envSnapshot: baseProxyEnvSnapshot ?? captureProxyEnv(),
stop: async () => {
stopActiveProxyRegistration(registration);
},
kill: () => {
stopActiveProxyRegistration(registration);
},
};
return handle;
}
baseProxyEnvSnapshot ??= captureProxyEnv();
const lifecycleBaseEnvSnapshot = baseProxyEnvSnapshot;
let injectedEnvSnapshot = captureProxyEnv();
let registration: ActiveProxyRegistration | null = null;
let registration: ActiveManagedProxyRegistration | null = null;
try {
injectedEnvSnapshot = injectProxyEnv(proxyUrl);
forceResetGlobalDispatcher();
bootstrapNodeHttpStack(proxyUrl);
registration = {
proxyUrl,
stopped: false,
};
activeProxyRegistrations.push(registration);
registration = registerActiveManagedProxyUrl(new URL(proxyUrl));
} catch (err) {
restoreAfterFailedProxyActivation(previousActiveRegistration, lifecycleBaseEnvSnapshot);
restoreAfterFailedProxyActivation(lifecycleBaseEnvSnapshot);
throw new Error(`proxy: failed to activate external proxy routing: ${String(err)}`, {
cause: err,
});

View File

@@ -420,4 +420,146 @@ describe("proxy validation", () => {
},
]);
});
it("adds an APNs reachability check when requested", async () => {
const fetchCheck = vi.fn().mockResolvedValue({ ok: true, status: 200 });
const apnsCheck = vi
.fn()
.mockResolvedValue({ status: 403, apnsId: "00000000-0000-0000-0000-000000000000" });
const result = await runProxyValidation({
config: {
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
},
env: {},
allowedUrls: [],
deniedUrls: [],
apnsReachability: true,
apnsAuthority: "https://api.sandbox.push.apple.com",
timeoutMs: 1234,
fetchCheck,
apnsCheck,
});
expect(fetchCheck).not.toHaveBeenCalled();
expect(apnsCheck).toHaveBeenCalledWith({
proxyUrl: "http://127.0.0.1:3128",
authority: "https://api.sandbox.push.apple.com",
timeoutMs: 1234,
});
expect(result).toEqual({
ok: true,
config: {
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
source: "config",
errors: [],
},
checks: [
{
kind: "apns",
url: "https://api.sandbox.push.apple.com",
ok: true,
status: 403,
},
],
});
});
it("accepts APNs 403 reachability with InvalidProviderToken when apns-id is unavailable", async () => {
const result = await runProxyValidation({
config: {
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
},
env: {},
allowedUrls: [],
deniedUrls: [],
apnsReachability: true,
apnsCheck: vi.fn().mockResolvedValue({ status: 403, apnsReason: "InvalidProviderToken" }),
});
expect(result.ok).toBe(true);
expect(result.checks).toEqual([
{
kind: "apns",
url: "https://api.sandbox.push.apple.com",
ok: true,
status: 403,
},
]);
});
it("fails APNs reachability when bare 403 has no APNs proof", async () => {
const result = await runProxyValidation({
config: {
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
},
env: {},
allowedUrls: [],
deniedUrls: [],
apnsReachability: true,
apnsCheck: vi.fn().mockResolvedValue({ status: 403 }),
});
expect(result.ok).toBe(false);
expect(result.checks).toEqual([
{
kind: "apns",
url: "https://api.sandbox.push.apple.com",
ok: false,
error: expect.stringContaining("InvalidProviderToken"),
},
]);
});
it("fails APNs reachability when non-403 response has no apns-id (proxy intercept)", async () => {
const result = await runProxyValidation({
config: {
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
},
env: {},
allowedUrls: [],
deniedUrls: [],
apnsReachability: true,
apnsCheck: vi.fn().mockResolvedValue({ status: 200 }),
});
expect(result.ok).toBe(false);
expect(result.checks).toEqual([
{
kind: "apns",
url: "https://api.sandbox.push.apple.com",
ok: false,
error: expect.stringContaining("apns-id"),
},
]);
});
it("fails APNs reachability when the proxy blocks CONNECT", async () => {
const result = await runProxyValidation({
config: {
enabled: true,
proxyUrl: "http://127.0.0.1:3128",
},
env: {},
allowedUrls: [],
deniedUrls: [],
apnsReachability: true,
apnsCheck: vi.fn().mockRejectedValue(new Error("HTTP/1.1 403 Forbidden")),
});
expect(result.ok).toBe(false);
expect(result.checks).toEqual([
{
kind: "apns",
url: "https://api.sandbox.push.apple.com",
ok: false,
error: "HTTP/1.1 403 Forbidden",
},
]);
});
});

View File

@@ -1,13 +1,16 @@
import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:http";
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
import { probeApnsHttp2ReachabilityViaProxy } from "../../push-apns-http2.js";
import { fetchWithRuntimeDispatcher } from "../runtime-fetch.js";
import { createHttp1ProxyAgent } from "../undici-runtime.js";
export const DEFAULT_PROXY_VALIDATION_ALLOWED_URLS = ["https://example.com/"] as const;
export const DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY = "https://api.sandbox.push.apple.com";
const DEFAULT_PROXY_VALIDATION_TIMEOUT_MS = 5000;
const DENIED_CANARY_HEADER = "x-openclaw-proxy-validation-canary";
const APNS_REACHABILITY_REASON = "InvalidProviderToken";
export type ProxyValidationConfigSource = "override" | "config" | "env" | "missing" | "disabled";
@@ -18,7 +21,7 @@ export type ProxyValidationResolvedConfig = {
errors: string[];
};
export type ProxyValidationCheckKind = "allowed" | "denied";
export type ProxyValidationCheckKind = "allowed" | "denied" | "apns";
export type ProxyValidationCheck = {
kind: ProxyValidationCheckKind;
@@ -50,6 +53,24 @@ export type ProxyValidationFetchCheck = (
params: ProxyValidationFetchCheckParams,
) => Promise<ProxyValidationFetchCheckResult>;
export type ProxyValidationApnsCheckParams = {
proxyUrl: string;
authority: string;
timeoutMs: number;
};
export type ProxyValidationApnsCheckResult = {
status: number;
/** Present when the response originated from a real APNs server (Apple always returns this UUID). */
apnsId?: string;
/** APNs JSON error reason. InvalidProviderToken proves the invalid-token probe reached APNs. */
apnsReason?: string;
};
export type ProxyValidationApnsCheck = (
params: ProxyValidationApnsCheckParams,
) => Promise<ProxyValidationApnsCheckResult>;
export type ResolveProxyValidationConfigOptions = {
config?: ProxyConfig;
env?: NodeJS.ProcessEnv | Partial<Record<"OPENCLAW_PROXY_URL", string | undefined>>;
@@ -61,6 +82,9 @@ export type RunProxyValidationOptions = ResolveProxyValidationConfigOptions & {
deniedUrls?: readonly string[];
timeoutMs?: number;
fetchCheck?: ProxyValidationFetchCheck;
apnsReachability?: boolean;
apnsAuthority?: string;
apnsCheck?: ProxyValidationApnsCheck;
};
function normalizeProxyUrl(value: string | undefined): string | undefined {
@@ -176,6 +200,39 @@ async function defaultProxyValidationFetchCheck({
}
}
async function defaultProxyValidationApnsCheck({
proxyUrl,
authority,
timeoutMs,
}: ProxyValidationApnsCheckParams): Promise<ProxyValidationApnsCheckResult> {
const result = await probeApnsHttp2ReachabilityViaProxy({ proxyUrl, authority, timeoutMs });
return {
status: result.status,
apnsId: result.responseHeaders?.["apns-id"],
apnsReason: parseApnsErrorReason(result.body),
};
}
function parseApnsErrorReason(body: string): string | undefined {
try {
const parsed: unknown = JSON.parse(body);
if (!parsed || typeof parsed !== "object") {
return undefined;
}
const reason = (parsed as { reason?: unknown }).reason;
return typeof reason === "string" && reason.trim() ? reason : undefined;
} catch {
return undefined;
}
}
function hasApnsReachabilityProof(result: ProxyValidationApnsCheckResult): boolean {
if (result.apnsId) {
return true;
}
return result.status === 403 && result.apnsReason === APNS_REACHABILITY_REASON;
}
function normalizeTimeoutMs(value: number | undefined): number {
if (value === undefined || !Number.isFinite(value) || value <= 0) {
return DEFAULT_PROXY_VALIDATION_TIMEOUT_MS;
@@ -380,6 +437,44 @@ async function runDeniedCheck(params: {
}
}
async function runApnsReachabilityCheck(params: {
authority: string;
proxyUrl: string;
timeoutMs: number;
apnsCheck: ProxyValidationApnsCheck;
}): Promise<ProxyValidationCheck> {
try {
const result = await params.apnsCheck({
proxyUrl: params.proxyUrl,
authority: params.authority,
timeoutMs: params.timeoutMs,
});
if (!hasApnsReachabilityProof(result)) {
return {
kind: "apns",
url: params.authority,
ok: false,
error:
"APNs reachability check failed: response did not include an apns-id header or APNs InvalidProviderToken body. " +
"The proxy may be intercepting the connection instead of tunneling it.",
};
}
return {
kind: "apns",
url: params.authority,
ok: true,
status: result.status,
};
} catch (err) {
return {
kind: "apns",
url: params.authority,
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
export async function runProxyValidation(
options: RunProxyValidationOptions,
): Promise<ProxyValidationResult> {
@@ -405,6 +500,8 @@ export async function runProxyValidation(
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
const fetchCheck = options.fetchCheck ?? defaultProxyValidationFetchCheck;
const apnsCheck = options.apnsCheck ?? defaultProxyValidationApnsCheck;
const apnsAuthority = options.apnsAuthority ?? DEFAULT_PROXY_VALIDATION_APNS_AUTHORITY;
const allowedUrls = options.allowedUrls ?? DEFAULT_PROXY_VALIDATION_ALLOWED_URLS;
const deniedTargets = await resolveDeniedTargets(options.deniedUrls);
const checks: ProxyValidationCheck[] = [];
@@ -418,6 +515,16 @@ export async function runProxyValidation(
await runDeniedCheck({ target, proxyUrl: config.proxyUrl, timeoutMs, fetchCheck }),
);
}
if (options.apnsReachability === true) {
checks.push(
await runApnsReachabilityCheck({
authority: apnsAuthority,
proxyUrl: config.proxyUrl,
timeoutMs,
apnsCheck,
}),
);
}
} finally {
await deniedTargets.close();
}

View File

@@ -0,0 +1,129 @@
import { createServer, type Server } from "node:http";
import { connect } from "node:net";
import { afterAll, describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "./env.js";
import { probeApnsHttp2ReachabilityViaProxy } from "./push-apns-http2.js";
const APNS_SANDBOX_AUTHORITY = "https://api.sandbox.push.apple.com";
const APNS_SANDBOX_HOST = "api.sandbox.push.apple.com";
const APNS_CONNECT_PORT = 443;
const DEFAULT_TIMEOUT_MS = 15_000;
const LIVE =
(isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST)) &&
isTruthyEnvValue(process.env.OPENCLAW_LIVE_APNS_REACHABILITY);
const describeLive = LIVE ? describe : describe.skip;
function getLiveTimeoutMs(): number {
const raw = process.env.OPENCLAW_LIVE_APNS_TIMEOUT_MS;
if (!raw) {
return DEFAULT_TIMEOUT_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`OPENCLAW_LIVE_APNS_TIMEOUT_MS must be a positive number, got ${raw}`);
}
return Math.trunc(parsed);
}
function parseConnectTarget(target: string): { hostname: string; port: number } | undefined {
try {
const parsed = new URL(`http://${target}`);
const port = parsed.port ? Number(parsed.port) : APNS_CONNECT_PORT;
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
return undefined;
}
return { hostname: parsed.hostname, port };
} catch {
return undefined;
}
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function startApnsConnectProxy(): Promise<{ proxyUrl: string; server: Server }> {
const server = createServer((_request, response) => {
response.writeHead(405);
response.end();
});
server.on("connect", (request, clientSocket, head) => {
const target = request.url ? parseConnectTarget(request.url) : undefined;
if (!target || target.hostname !== APNS_SANDBOX_HOST || target.port !== APNS_CONNECT_PORT) {
clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
clientSocket.destroy();
return;
}
const upstreamSocket = connect(target.port, target.hostname);
upstreamSocket.once("connect", () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length > 0) {
upstreamSocket.write(head);
}
upstreamSocket.pipe(clientSocket);
clientSocket.pipe(upstreamSocket);
});
upstreamSocket.once("error", () => {
clientSocket.destroy();
});
clientSocket.once("error", () => {
upstreamSocket.destroy();
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
await closeServer(server);
throw new Error("APNs live CONNECT proxy did not bind to a TCP port");
}
return {
proxyUrl: `http://127.0.0.1:${address.port}`,
server,
};
}
describeLive("APNs HTTP/2 live reachability via CONNECT proxy", () => {
const servers: Server[] = [];
afterAll(async () => {
await Promise.all(servers.map((server) => closeServer(server)));
});
it(
"receives Apple's 403 response through the HTTP/2 CONNECT tunnel",
async () => {
const { proxyUrl, server } = await startApnsConnectProxy();
servers.push(server);
const result = await probeApnsHttp2ReachabilityViaProxy({
authority: APNS_SANDBOX_AUTHORITY,
proxyUrl,
timeoutMs: getLiveTimeoutMs(),
});
expect(result.status).toBe(403);
expect(result.body).toContain("InvalidProviderToken");
},
getLiveTimeoutMs() + 5_000,
);
});

View File

@@ -0,0 +1,251 @@
import type http2 from "node:http2";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HttpConnectTunnelParams } from "./net/http-connect-tunnel.js";
import {
_resetActiveManagedProxyStateForTests,
registerActiveManagedProxyUrl,
stopActiveManagedProxyRegistration,
} from "./net/proxy/active-proxy-state.js";
const { connectSpy, tunnelSpy, fakeRequest, fakeSession, fakeTlsSocket } = vi.hoisted(() => {
class FakeEmitter {
private readonly handlers = new Map<string, Array<(...args: unknown[]) => void>>();
on(event: string, handler: (...args: unknown[]) => void): this {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]);
return this;
}
once(event: string, handler: (...args: unknown[]) => void): this {
const wrapped = (...args: unknown[]) => {
this.off(event, wrapped);
handler(...args);
};
return this.on(event, wrapped);
}
off(event: string, handler: (...args: unknown[]) => void): this {
this.handlers.set(
event,
(this.handlers.get(event) ?? []).filter((candidate) => candidate !== handler),
);
return this;
}
emit(event: string, ...args: unknown[]): void {
for (const handler of this.handlers.get(event) ?? []) {
handler(...args);
}
}
reset(): void {
this.handlers.clear();
}
}
const fakeRequest = Object.assign(new FakeEmitter(), {
setEncoding: vi.fn(),
end: vi.fn(() => {
queueMicrotask(() => {
fakeRequest.emit("response", { ":status": 403 });
fakeRequest.emit("data", '{"reason":"InvalidProviderToken"}');
fakeRequest.emit("end");
});
}),
});
const fakeSession = Object.assign(new FakeEmitter(), {
closed: false,
destroyed: false,
close: vi.fn(() => {
fakeSession.closed = true;
}),
destroy: vi.fn(() => {
fakeSession.destroyed = true;
}),
request: vi.fn(() => fakeRequest),
});
const fakeTlsSocket = { encrypted: true };
return {
fakeRequest,
fakeSession,
fakeTlsSocket,
connectSpy: vi.fn(() => fakeSession),
tunnelSpy: vi.fn(async (_params: HttpConnectTunnelParams) => fakeTlsSocket),
};
});
vi.mock("node:http2", () => ({
default: { connect: connectSpy, constants: { NGHTTP2_CANCEL: 8 } },
connect: connectSpy,
constants: { NGHTTP2_CANCEL: 8 },
}));
vi.mock("./net/http-connect-tunnel.js", () => ({
openHttpConnectTunnel: tunnelSpy,
}));
describe("connectApnsHttp2Session", () => {
beforeEach(() => {
connectSpy.mockClear();
tunnelSpy.mockClear();
fakeRequest.reset();
fakeRequest.setEncoding.mockClear();
fakeRequest.end.mockClear();
fakeSession.reset();
fakeSession.closed = false;
fakeSession.destroyed = false;
fakeSession.close.mockClear();
fakeSession.destroy.mockClear();
fakeSession.request.mockClear();
_resetActiveManagedProxyStateForTests();
});
it("uses direct http2.connect when managed proxy is inactive", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
const session = await connectApnsHttp2Session({
authority: "https://api.sandbox.push.apple.com",
timeoutMs: 10_000,
});
expect(session).toBe(fakeSession);
expect(tunnelSpy).not.toHaveBeenCalled();
expect(connectSpy).toHaveBeenCalledWith("https://api.sandbox.push.apple.com");
});
it("normalizes the default APNs HTTPS port", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
await connectApnsHttp2Session({
authority: "https://api.push.apple.com:443",
timeoutMs: 10_000,
});
expect(connectSpy).toHaveBeenCalledWith("https://api.push.apple.com");
});
it("rejects APNs authorities with non-origin URL components", async () => {
const { connectApnsHttp2Session, probeApnsHttp2ReachabilityViaProxy } =
await import("./push-apns-http2.js");
await expect(
connectApnsHttp2Session({
authority: "https://token@api.push.apple.com",
timeoutMs: 10_000,
}),
).rejects.toThrow("Unsupported APNs authority");
await expect(
probeApnsHttp2ReachabilityViaProxy({
authority: "https://api.sandbox.push.apple.com/3/device/abc",
proxyUrl: "http://proxy.example:8080",
timeoutMs: 10_000,
}),
).rejects.toThrow("Unsupported APNs authority");
});
it("uses an HTTP CONNECT tunnel when managed proxy is active", async () => {
const registration = registerActiveManagedProxyUrl(new URL("http://proxy.example:8080"));
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
const session = await connectApnsHttp2Session({
authority: "https://api.push.apple.com",
timeoutMs: 10_000,
});
stopActiveManagedProxyRegistration(registration);
expect(session).toBe(fakeSession);
const tunnelCall = tunnelSpy.mock.calls.at(-1)?.[0];
const proxyUrl = tunnelCall?.proxyUrl;
expect(proxyUrl).toBeInstanceOf(URL);
if (!(proxyUrl instanceof URL)) {
throw new Error("expected active managed proxy URL");
}
expect(proxyUrl.href).toBe("http://proxy.example:8080/");
expect(tunnelCall?.targetHost).toBe("api.push.apple.com");
expect(tunnelCall?.targetPort).toBe(443);
expect(tunnelCall?.timeoutMs).toBe(10_000);
expect(connectSpy).toHaveBeenCalledWith("https://api.push.apple.com", {
createConnection: expect.any(Function),
});
const connectCall = connectSpy.mock.calls.at(-1) as
| [string, http2.ClientSessionOptions]
| undefined;
const createConnection = connectCall?.[1].createConnection;
expect(createConnection?.(new URL("https://api.push.apple.com"), {})).toBe(fakeTlsSocket);
});
it("ignores ambient proxy env when managed proxy is inactive", async () => {
const originalHttpsProxy = process.env["HTTPS_PROXY"];
process.env["HTTPS_PROXY"] = "http://ambient.example:8080";
try {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
const session = await connectApnsHttp2Session({
authority: "https://api.push.apple.com",
timeoutMs: 10_000,
});
expect(session).toBe(fakeSession);
expect(tunnelSpy).not.toHaveBeenCalled();
} finally {
if (originalHttpsProxy === undefined) {
delete process.env["HTTPS_PROXY"];
} else {
process.env["HTTPS_PROXY"] = originalHttpsProxy;
}
}
});
it("probes APNs reachability through an explicit proxy", async () => {
const { probeApnsHttp2ReachabilityViaProxy } = await import("./push-apns-http2.js");
const result = await probeApnsHttp2ReachabilityViaProxy({
authority: "https://api.sandbox.push.apple.com",
proxyUrl: "http://proxy.example:8080",
timeoutMs: 10_000,
});
expect(result).toEqual({
status: 403,
body: '{"reason":"InvalidProviderToken"}',
responseHeaders: {},
});
const tunnelCall = tunnelSpy.mock.calls.at(-1)?.[0];
const proxyUrl = tunnelCall?.proxyUrl;
expect(proxyUrl).toBeInstanceOf(URL);
if (!(proxyUrl instanceof URL)) {
throw new Error("expected explicit proxy URL");
}
expect(proxyUrl.href).toBe("http://proxy.example:8080/");
expect(tunnelCall?.targetHost).toBe("api.sandbox.push.apple.com");
expect(tunnelCall?.targetPort).toBe(443);
expect(tunnelCall?.timeoutMs).toBe(10_000);
expect(fakeSession.request).toHaveBeenCalledWith({
":method": "POST",
":path": `/3/device/${"0".repeat(64)}`,
authorization: "bearer intentionally.invalid.openclaw.proxy.validation",
"apns-topic": "ai.openclaw.ios",
"apns-push-type": "alert",
"apns-priority": "10",
});
expect(fakeSession.close).toHaveBeenCalledOnce();
});
it("rejects non-APNs authorities", async () => {
const { connectApnsHttp2Session, probeApnsHttp2ReachabilityViaProxy } =
await import("./push-apns-http2.js");
await expect(
connectApnsHttp2Session({
authority: "https://example.com",
timeoutMs: 10_000,
}),
).rejects.toThrow("Unsupported APNs authority");
await expect(
probeApnsHttp2ReachabilityViaProxy({
authority: "https://example.com",
proxyUrl: "http://proxy.example:8080",
timeoutMs: 10_000,
}),
).rejects.toThrow("Unsupported APNs authority");
});
});

View File

@@ -0,0 +1,176 @@
import http2 from "node:http2";
import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js";
import {
getActiveManagedProxyUrl,
type ActiveManagedProxyUrl,
} from "./net/proxy/active-proxy-state.js";
const APNS_DEFAULT_PORT = "443";
const APNS_AUTHORITIES = new Set([
"https://api.push.apple.com",
"https://api.sandbox.push.apple.com",
]);
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;
};
export type ProbeApnsHttp2ReachabilityViaProxyParams = {
authority: string;
proxyUrl: string;
timeoutMs: number;
};
export type ProbeApnsHttp2ReachabilityViaProxyResult = {
status: number;
body: string;
/** Raw response headers from APNs. Includes apns-id when the connection was truly tunneled to Apple. */
responseHeaders: Record<string, string>;
};
function assertApnsAuthority(authority: string): ApnsAuthority {
let parsed: URL;
try {
parsed = new URL(authority);
} catch {
throw new Error(`Unsupported APNs authority: ${authority}`);
}
if (
parsed.username ||
parsed.password ||
parsed.pathname !== "/" ||
parsed.search ||
parsed.hash
) {
throw new Error(`Unsupported APNs authority: ${authority}`);
}
const port = parsed.port && parsed.port !== APNS_DEFAULT_PORT ? `:${parsed.port}` : "";
const normalized = `${parsed.protocol}//${parsed.hostname}${port}`;
if (!APNS_AUTHORITIES.has(normalized)) {
throw new Error(`Unsupported APNs authority: ${authority}`);
}
return normalized as ApnsAuthority;
}
async function openProxiedApnsHttp2Session(params: {
authority: ApnsAuthority;
proxyUrl: ActiveManagedProxyUrl;
timeoutMs: number;
}): Promise<http2.ClientHttp2Session> {
const apnsHost = new URL(params.authority).hostname;
const tlsSocket = await openHttpConnectTunnel({
proxyUrl: params.proxyUrl,
targetHost: apnsHost,
targetPort: 443,
timeoutMs: params.timeoutMs,
});
return http2.connect(params.authority, {
createConnection: () => tlsSocket,
});
}
export async function connectApnsHttp2Session(
params: ConnectApnsHttp2SessionParams,
): Promise<http2.ClientHttp2Session> {
const authority = assertApnsAuthority(params.authority);
const proxyUrl = getActiveManagedProxyUrl();
if (!proxyUrl) {
return http2.connect(authority);
}
return await openProxiedApnsHttp2Session({
authority,
proxyUrl,
timeoutMs: params.timeoutMs,
});
}
export async function probeApnsHttp2ReachabilityViaProxy(
params: ProbeApnsHttp2ReachabilityViaProxyParams,
): Promise<ProbeApnsHttp2ReachabilityViaProxyResult> {
const authority = assertApnsAuthority(params.authority);
const session = await openProxiedApnsHttp2Session({
authority,
proxyUrl: new URL(params.proxyUrl),
timeoutMs: params.timeoutMs,
});
try {
return await new Promise<ProbeApnsHttp2ReachabilityViaProxyResult>((resolve, reject) => {
let settled = false;
let body = "";
let status: number | undefined;
let responseHeaders: Record<string, string> = {};
const timeout = setTimeout(() => {
fail(
new Error(`APNs reachability probe timed out after ${Math.trunc(params.timeoutMs)}ms`),
);
}, Math.trunc(params.timeoutMs));
timeout.unref?.();
const cleanup = () => {
clearTimeout(timeout);
session.off("error", fail);
};
const fail = (err: unknown) => {
if (settled) {
return;
}
settled = true;
cleanup();
session.destroy(err instanceof Error ? err : new Error(String(err)));
reject(err);
};
const request = session.request({
":method": "POST",
":path": `/3/device/${"0".repeat(64)}`,
authorization: "bearer intentionally.invalid.openclaw.proxy.validation",
"apns-topic": "ai.openclaw.ios",
"apns-push-type": "alert",
"apns-priority": "10",
});
session.once("error", fail);
request.setEncoding("utf8");
request.on("response", (headers) => {
const rawStatus = headers[":status"];
status = typeof rawStatus === "number" ? rawStatus : Number(rawStatus);
responseHeaders = Object.fromEntries(
Object.entries(headers)
.filter(([k]) => !k.startsWith(":"))
.map(([k, v]) => [k, String(v)]),
);
});
request.on("data", (chunk) => {
body += String(chunk);
});
request.once("error", fail);
request.once("end", () => {
if (settled) {
return;
}
settled = true;
cleanup();
if (status === undefined || !Number.isFinite(status)) {
reject(new Error("APNs reachability probe ended without an HTTP/2 status"));
return;
}
resolve({ status, body, responseHeaders });
});
request.end(JSON.stringify({ aps: { alert: "OpenClaw APNs proxy validation" } }));
});
} finally {
if (!session.closed && !session.destroyed) {
session.close();
}
}
}

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,67 @@ const testAuthPrivateKey = generateKeyPairSync("ec", {
namedCurve: "prime256v1",
}).privateKey.export({ format: "pem", type: "pkcs8" });
const testApnsServerKey = `-----BEGIN PRIVATE KEY-----`; // pragma: allowlist secret
const testApnsServerKeyPem = `${testApnsServerKey}
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 +137,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: testApnsServerKeyPem,
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 +284,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

@@ -1,6 +1,5 @@
import { createHash, createPrivateKey, sign as signJwt } from "node:crypto";
import fs from "node:fs/promises";
import http2 from "node:http2";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import {
@@ -10,6 +9,7 @@ import {
import type { DeviceIdentity } from "./device-identity.js";
import { formatErrorMessage } from "./errors.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
import { APNS_HTTP2_CANCEL_CODE, connectApnsHttp2Session } from "./push-apns-http2.js";
import {
type ApnsRelayConfig,
type ApnsRelayPushResponse,
@@ -658,8 +658,12 @@ async function sendApnsRequest(params: {
const body = JSON.stringify(params.payload);
const requestPath = `/3/device/${params.token}`;
const client = await connectApnsHttp2Session({
authority,
timeoutMs: params.timeoutMs,
});
return await new Promise((resolve, reject) => {
const client = http2.connect(authority);
let settled = false;
const fail = (err: unknown) => {
if (settled) {
@@ -698,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) => {