mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
fix: use managed proxy state for APNs
This commit is contained in:
@@ -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",
|
||||
|
||||
44
src/infra/net/proxy/active-proxy-state.ts
Normal file
44
src/infra/net/proxy/active-proxy-state.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user