mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:40:43 +00:00
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 headdab7c86a75. - Required merge gates passed before the squash merge. Prepared head SHA:dab7c86a75Review: 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:
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,6 +26,8 @@ describe("proxy cli", () => {
|
||||
"--proxy-url",
|
||||
"--allowed-url",
|
||||
"--denied-url",
|
||||
"--apns-reachable",
|
||||
"--apns-authority",
|
||||
"--timeout-ms",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
308
src/infra/net/http-connect-tunnel.test.ts
Normal file
308
src/infra/net/http-connect-tunnel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
315
src/infra/net/http-connect-tunnel.ts
Normal file
315
src/infra/net/http-connect-tunnel.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
52
src/infra/net/proxy/active-proxy-state.ts
Normal file
52
src/infra/net/proxy/active-proxy-state.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
129
src/infra/push-apns-http2.live.test.ts
Normal file
129
src/infra/push-apns-http2.live.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
251
src/infra/push-apns-http2.test.ts
Normal file
251
src/infra/push-apns-http2.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
176
src/infra/push-apns-http2.ts
Normal file
176
src/infra/push-apns-http2.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user