fix: use managed proxy state for APNs

This commit is contained in:
jesse-merhi
2026-05-04 02:30:39 +10:00
committed by clawsweeper
parent bfa0ca09e3
commit 715893631e
6 changed files with 119 additions and 48 deletions

View File

@@ -1420,13 +1420,8 @@
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts",
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
"lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs",
<<<<<<< HEAD
"lint:scripts": "pnpm lint:docker-e2e && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts",
"lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.scripts.json scripts",
"lint:swift": "swiftlint lint --config config/swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
=======
"lint:scripts": "pnpm lint:docker-e2e && pnpm lint:tmp:no-raw-http2-imports && node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.scripts.json scripts",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
>>>>>>> 59b4c14509 (lint: ban raw HTTP2 imports)
"lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs",
"lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs",
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",

View File

@@ -0,0 +1,44 @@
export type ActiveManagedProxyRegistration = {
proxyUrl: string;
stopped: boolean;
};
let activeProxyUrl: string | undefined;
let activeProxyHandleCount = 0;
export function registerActiveManagedProxyUrl(proxyUrl: string): ActiveManagedProxyRegistration {
if (activeProxyUrl !== undefined && activeProxyUrl !== proxyUrl) {
throw new Error(
"proxy: cannot activate a different managed proxy while another proxy is active; " +
"stop the current proxy before changing proxy.proxyUrl.",
);
}
activeProxyUrl = proxyUrl;
activeProxyHandleCount += 1;
return { proxyUrl, stopped: false };
}
export function stopActiveManagedProxyRegistration(
registration: ActiveManagedProxyRegistration,
): void {
if (registration.stopped) {
return;
}
registration.stopped = true;
if (activeProxyHandleCount > 0) {
activeProxyHandleCount -= 1;
}
if (activeProxyHandleCount === 0) {
activeProxyUrl = undefined;
}
}
export function getActiveManagedProxyUrl(): string | undefined {
return activeProxyUrl;
}
export function _resetActiveManagedProxyStateForTests(): void {
activeProxyUrl = undefined;
activeProxyHandleCount = 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()).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";

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();
@@ -354,27 +344,26 @@ function restoreInactiveProxyRuntime(snapshot: ProxyEnvSnapshot): void {
}
function restoreAfterFailedProxyActivation(
previousActiveRegistration: ActiveProxyRegistration | null,
previousActiveProxyUrl: string | undefined,
restoreSnapshot: ProxyEnvSnapshot,
): void {
if (previousActiveRegistration) {
reapplyActiveProxyRuntime(previousActiveRegistration.proxyUrl);
if (previousActiveProxyUrl) {
reapplyActiveProxyRuntime(previousActiveProxyUrl);
return;
}
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);
stopActiveManagedProxyRegistration(registration);
const nextActiveRegistration = findTopActiveProxyRegistration();
if (nextActiveRegistration) {
reapplyActiveProxyRuntime(nextActiveRegistration.proxyUrl);
const nextActiveProxyUrl = getActiveManagedProxyUrl();
if (nextActiveProxyUrl) {
reapplyActiveProxyRuntime(nextActiveProxyUrl);
return;
}
@@ -424,23 +413,19 @@ export async function startProxy(config: ProxyConfig | undefined): Promise<Proxy
}
const proxyUrl = resolveProxyUrl(config);
const previousActiveRegistration = findTopActiveProxyRegistration();
const previousActiveProxyUrl = getActiveManagedProxyUrl();
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(proxyUrl);
} catch (err) {
restoreAfterFailedProxyActivation(previousActiveRegistration, lifecycleBaseEnvSnapshot);
restoreAfterFailedProxyActivation(previousActiveProxyUrl, lifecycleBaseEnvSnapshot);
throw new Error(`proxy: failed to activate external proxy routing: ${String(err)}`, {
cause: err,
});

View File

@@ -1,5 +1,5 @@
import type http2 from "node:http2";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { connectSpy, tlsConnectSpy, tunnelSpy, fakeSession, fakeTlsSocket } = vi.hoisted(() => {
const fakeSession = { close: vi.fn(), destroy: vi.fn() };
@@ -28,13 +28,18 @@ vi.mock("./net/http-connect-tunnel.js", () => ({
}));
describe("connectApnsHttp2Session", () => {
it("uses direct http2.connect when no HTTPS proxy is configured", async () => {
beforeEach(() => {
connectSpy.mockClear();
tlsConnectSpy.mockClear();
tunnelSpy.mockClear();
});
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,
env: {},
getManagedProxyUrl: () => undefined,
});
expect(session).toBe(fakeSession);
@@ -42,13 +47,13 @@ describe("connectApnsHttp2Session", () => {
expect(connectSpy).toHaveBeenCalledWith("https://api.sandbox.push.apple.com");
});
it("uses an HTTP CONNECT tunnel and disables direct fallback when HTTPS proxy is configured", async () => {
it("uses an HTTP CONNECT tunnel when managed proxy is active", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
const session = await connectApnsHttp2Session({
authority: "https://api.push.apple.com",
timeoutMs: 10_000,
env: { HTTPS_PROXY: "http://proxy.example:8080" },
getManagedProxyUrl: () => "http://proxy.example:8080",
});
expect(session).toBe(fakeSession);
@@ -73,6 +78,29 @@ describe("connectApnsHttp2Session", () => {
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,
getManagedProxyUrl: () => undefined,
});
expect(session).toBe(fakeSession);
expect(tunnelSpy).not.toHaveBeenCalled();
} finally {
if (originalHttpsProxy === undefined) {
delete process.env["HTTPS_PROXY"];
} else {
process.env["HTTPS_PROXY"] = originalHttpsProxy;
}
}
});
it("rejects non-APNs authorities", async () => {
const { connectApnsHttp2Session } = await import("./push-apns-http2.js");
@@ -80,7 +108,7 @@ describe("connectApnsHttp2Session", () => {
connectApnsHttp2Session({
authority: "https://example.com",
timeoutMs: 10_000,
env: { HTTPS_PROXY: "http://proxy.example:8080" },
getManagedProxyUrl: () => "http://proxy.example:8080",
}),
).rejects.toThrow("Unsupported APNs authority");
});

View File

@@ -1,7 +1,7 @@
import http2 from "node:http2";
import tls from "node:tls";
import { openHttpConnectTunnel } from "./net/http-connect-tunnel.js";
import { resolveEnvHttpProxyUrl } from "./net/proxy-env.js";
import { getActiveManagedProxyUrl } from "./net/proxy/active-proxy-state.js";
const APNS_AUTHORITIES = new Set([
"https://api.push.apple.com",
@@ -13,7 +13,7 @@ type ApnsAuthority = "https://api.push.apple.com" | "https://api.sandbox.push.ap
export type ConnectApnsHttp2SessionParams = {
authority: string;
timeoutMs: number;
env?: NodeJS.ProcessEnv;
getManagedProxyUrl?: () => string | undefined;
};
function assertApnsAuthority(authority: string): ApnsAuthority {
@@ -34,7 +34,7 @@ export async function connectApnsHttp2Session(
params: ConnectApnsHttp2SessionParams,
): Promise<http2.ClientHttp2Session> {
const authority = assertApnsAuthority(params.authority);
const proxyUrl = resolveEnvHttpProxyUrl("https", params.env);
const proxyUrl = (params.getManagedProxyUrl ?? getActiveManagedProxyUrl)();
if (!proxyUrl) {
return http2.connect(authority);
}