Files
openclaw/src/infra/net/proxy/proxy-lifecycle.ts
Jesse Merhi 2633b14914 feat(security): support operator-managed network proxy routing (#70044)
* feat: support operator-managed proxy routing

* docs: add network proxy changelog entry

* fix(proxy): restrict gateway bypass to loopback IPs

* fix(cli): harden container proxy URL checks

* docs(proxy): clarify gateway bypass scope

* docs: remove proxy changelog entry

* fix(proxy): clear startup CI guard failures

* fix(proxy): harden gateway proxy policy parsing

* fix(proxy): honor update shorthand proxy policy

* fix(cli): redact proxy URL suffixes

* test(proxy): keep gateway help off proxy startup

* fix(proxy): keep overlapping lifecycle active

* docs: add proxy changelog entry

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-04-28 00:20:47 -05:00

422 lines
13 KiB
TypeScript

/**
* High-level lifecycle management for OpenClaw's operator-managed network
* proxy routing.
*
* OpenClaw does not spawn or configure the filtering proxy. When enabled, it
* routes process-wide HTTP clients through the configured forward proxy URL and
* restores the previous process state on shutdown.
*/
import http from "node:http";
import https from "node:https";
import { bootstrap as bootstrapGlobalAgent } from "global-agent";
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";
export type ProxyHandle = {
/** The operator-managed proxy URL injected into process.env. */
proxyUrl: string;
/** Alias kept for CLI cleanup tests and logs. */
injectedProxyUrl: string;
/** Original proxy-related environment values, restored on stop/crash. */
envSnapshot: ProxyEnvSnapshot;
/** Restore process-wide proxy state. */
stop: () => Promise<void>;
/** Synchronously restore process-wide proxy state during hard process exit. */
kill: (signal?: NodeJS.Signals) => void;
};
const PROXY_ENV_KEYS = ["http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"] as const;
const GLOBAL_AGENT_PROXY_KEYS = ["GLOBAL_AGENT_HTTP_PROXY", "GLOBAL_AGENT_HTTPS_PROXY"] as const;
const GLOBAL_AGENT_FORCE_KEYS = ["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] as const;
const NO_PROXY_ENV_KEYS = ["no_proxy", "NO_PROXY", "GLOBAL_AGENT_NO_PROXY"] as const;
const PROXY_ACTIVE_KEYS = ["OPENCLAW_PROXY_ACTIVE"] as const;
const ALL_PROXY_ENV_KEYS = [
...PROXY_ENV_KEYS,
...GLOBAL_AGENT_PROXY_KEYS,
...GLOBAL_AGENT_FORCE_KEYS,
...NO_PROXY_ENV_KEYS,
...PROXY_ACTIVE_KEYS,
] as const;
type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number];
type ProxyEnvSnapshot = Record<ProxyEnvKey, string | undefined>;
type NodeHttpStackSnapshot = {
httpRequest: typeof http.request;
httpGet: typeof http.get;
httpGlobalAgent: typeof http.globalAgent;
httpsRequest: typeof https.request;
httpsGet: typeof https.get;
httpsGlobalAgent: typeof https.globalAgent;
hadGlobalAgent: boolean;
globalAgent: unknown;
};
type ActiveProxyRegistration = {
proxyUrl: string;
stopped: boolean;
};
let globalAgentBootstrapped = false;
let nodeHttpStackSnapshot: NodeHttpStackSnapshot | null = null;
let activeProxyRegistrations: ActiveProxyRegistration[] = [];
let baseProxyEnvSnapshot: ProxyEnvSnapshot | null = null;
export function _resetGlobalAgentBootstrapForTests(): void {
globalAgentBootstrapped = false;
nodeHttpStackSnapshot = null;
activeProxyRegistrations = [];
baseProxyEnvSnapshot = null;
}
function captureProxyEnv(): ProxyEnvSnapshot {
return {
http_proxy: process.env["http_proxy"],
https_proxy: process.env["https_proxy"],
HTTP_PROXY: process.env["HTTP_PROXY"],
HTTPS_PROXY: process.env["HTTPS_PROXY"],
GLOBAL_AGENT_HTTP_PROXY: process.env["GLOBAL_AGENT_HTTP_PROXY"],
GLOBAL_AGENT_HTTPS_PROXY: process.env["GLOBAL_AGENT_HTTPS_PROXY"],
GLOBAL_AGENT_FORCE_GLOBAL_AGENT: process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"],
no_proxy: process.env["no_proxy"],
NO_PROXY: process.env["NO_PROXY"],
GLOBAL_AGENT_NO_PROXY: process.env["GLOBAL_AGENT_NO_PROXY"],
OPENCLAW_PROXY_ACTIVE: process.env["OPENCLAW_PROXY_ACTIVE"],
};
}
function injectProxyEnv(proxyUrl: string): ProxyEnvSnapshot {
const snapshot = captureProxyEnv();
applyProxyEnv(proxyUrl);
return snapshot;
}
function applyProxyEnv(proxyUrl: string): void {
for (const key of PROXY_ENV_KEYS) {
process.env[key] = proxyUrl;
}
for (const key of GLOBAL_AGENT_PROXY_KEYS) {
process.env[key] = proxyUrl;
}
process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] = "true";
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
for (const key of NO_PROXY_ENV_KEYS) {
process.env[key] = "";
}
}
function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void {
for (const key of ALL_PROXY_ENV_KEYS) {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function restoreGlobalAgentRuntime(snapshot: ProxyEnvSnapshot): void {
if (
typeof global === "undefined" ||
(global as Record<string, unknown>)["GLOBAL_AGENT"] == null
) {
return;
}
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
agent["HTTP_PROXY"] = snapshot["GLOBAL_AGENT_HTTP_PROXY"] ?? "";
agent["HTTPS_PROXY"] = snapshot["GLOBAL_AGENT_HTTPS_PROXY"] ?? "";
agent["NO_PROXY"] = snapshot["GLOBAL_AGENT_NO_PROXY"] ?? null;
}
function captureNodeHttpStack(): NodeHttpStackSnapshot {
const globalRecord = global as Record<string, unknown>;
return {
httpRequest: http.request,
httpGet: http.get,
httpGlobalAgent: http.globalAgent,
httpsRequest: https.request,
httpsGet: https.get,
httpsGlobalAgent: https.globalAgent,
hadGlobalAgent: Object.hasOwn(globalRecord, "GLOBAL_AGENT"),
globalAgent: globalRecord["GLOBAL_AGENT"],
};
}
function restoreNodeHttpStack(): void {
const snapshot = nodeHttpStackSnapshot;
if (!snapshot) {
return;
}
http.request = snapshot.httpRequest;
http.get = snapshot.httpGet;
http.globalAgent = snapshot.httpGlobalAgent;
https.request = snapshot.httpsRequest;
https.get = snapshot.httpsGet;
https.globalAgent = snapshot.httpsGlobalAgent;
const globalRecord = global as Record<string, unknown>;
if (snapshot.hadGlobalAgent) {
globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent;
} else {
delete globalRecord["GLOBAL_AGENT"];
}
nodeHttpStackSnapshot = null;
globalAgentBootstrapped = false;
}
function bootstrapNodeHttpStack(proxyUrl: string): void {
if (!globalAgentBootstrapped) {
nodeHttpStackSnapshot = captureNodeHttpStack();
bootstrapGlobalAgent();
globalAgentBootstrapped = true;
}
if (
typeof global !== "undefined" &&
(global as Record<string, unknown>)["GLOBAL_AGENT"] != null
) {
const agent = (global as Record<string, unknown>)["GLOBAL_AGENT"] as Record<string, unknown>;
agent["HTTP_PROXY"] = proxyUrl;
agent["HTTPS_PROXY"] = proxyUrl;
agent["NO_PROXY"] = process.env["GLOBAL_AGENT_NO_PROXY"];
}
}
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();
} catch (err) {
logWarn(`proxy: failed to reset undici dispatcher: ${String(err)}`);
}
}
function restoreGlobalAgentRuntimeForProxyLifecycle(snapshot: ProxyEnvSnapshot): void {
try {
restoreGlobalAgentRuntime(snapshot);
} catch (err) {
logWarn(`proxy: failed to reset global-agent: ${String(err)}`);
}
}
function restoreNodeHttpStackForProxyLifecycle(): void {
try {
restoreNodeHttpStack();
} catch (err) {
logWarn(`proxy: failed to restore node HTTP stack: ${String(err)}`);
}
}
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();
restoreGlobalAgentRuntimeForProxyLifecycle(snapshot);
restoreNodeHttpStackForProxyLifecycle();
}
function restoreAfterFailedProxyActivation(
previousActiveRegistration: ActiveProxyRegistration | null,
restoreSnapshot: ProxyEnvSnapshot,
): void {
if (previousActiveRegistration) {
reapplyActiveProxyRuntime(previousActiveRegistration.proxyUrl);
return;
}
restoreInactiveProxyRuntime(restoreSnapshot);
baseProxyEnvSnapshot = null;
}
function stopActiveProxyRegistration(registration: ActiveProxyRegistration): void {
if (registration.stopped) {
return;
}
registration.stopped = true;
activeProxyRegistrations = activeProxyRegistrations.filter((entry) => !entry.stopped);
const nextActiveRegistration = findTopActiveProxyRegistration();
if (nextActiveRegistration) {
reapplyActiveProxyRuntime(nextActiveRegistration.proxyUrl);
return;
}
const restoreSnapshot = baseProxyEnvSnapshot ?? captureProxyEnv();
baseProxyEnvSnapshot = null;
restoreInactiveProxyRuntime(restoreSnapshot);
}
function isSupportedProxyUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:";
} catch {
return false;
}
}
function resolveProxyUrl(config: ProxyConfig | undefined): string {
const candidate = config?.proxyUrl?.trim() || process.env["OPENCLAW_PROXY_URL"]?.trim();
if (!candidate) {
throw new Error(
"proxy: enabled but no HTTP proxy URL is configured; set proxy.proxyUrl " +
"or OPENCLAW_PROXY_URL to an http:// forward proxy.",
);
}
if (!isSupportedProxyUrl(candidate)) {
throw new Error(
"proxy: enabled but proxy URL is invalid; set proxy.proxyUrl " +
"or OPENCLAW_PROXY_URL to an http:// forward proxy.",
);
}
return candidate;
}
function redactProxyUrlForLog(value: string): string {
try {
const url = new URL(value);
return url.origin;
} catch {
return "<invalid proxy URL>";
}
}
export async function startProxy(config: ProxyConfig | undefined): Promise<ProxyHandle | null> {
if (config?.enabled !== true) {
return null;
}
const proxyUrl = resolveProxyUrl(config);
const previousActiveRegistration = findTopActiveProxyRegistration();
baseProxyEnvSnapshot ??= captureProxyEnv();
const lifecycleBaseEnvSnapshot = baseProxyEnvSnapshot;
let injectedEnvSnapshot = captureProxyEnv();
let registration: ActiveProxyRegistration | null = null;
try {
injectedEnvSnapshot = injectProxyEnv(proxyUrl);
forceResetGlobalDispatcher();
bootstrapNodeHttpStack(proxyUrl);
registration = {
proxyUrl,
stopped: false,
};
activeProxyRegistrations.push(registration);
} catch (err) {
restoreAfterFailedProxyActivation(previousActiveRegistration, lifecycleBaseEnvSnapshot);
throw new Error(`proxy: failed to activate external proxy routing: ${String(err)}`, {
cause: err,
});
}
logInfo(
`proxy: routing process HTTP traffic through external proxy ${redactProxyUrlForLog(proxyUrl)}`,
);
const handle: ProxyHandle = {
proxyUrl,
injectedProxyUrl: proxyUrl,
envSnapshot: injectedEnvSnapshot,
stop: async () => {
if (registration) {
stopActiveProxyRegistration(registration);
}
},
kill: () => {
if (registration) {
stopActiveProxyRegistration(registration);
}
},
};
return handle;
}
export async function stopProxy(handle: ProxyHandle | null): Promise<void> {
if (!handle) {
return;
}
await handle.stop();
}
function isGatewayLoopbackControlPlaneUrl(value: string): boolean {
let url: URL;
try {
url = new URL(value);
} catch {
return false;
}
if (
url.protocol !== "ws:" &&
url.protocol !== "wss:" &&
url.protocol !== "http:" &&
url.protocol !== "https:"
) {
return false;
}
return isLoopbackIpAddress(url.hostname);
}
export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
url: string,
run: () => T,
): T {
if (!isGatewayLoopbackControlPlaneUrl(url)) {
throw new Error("proxy: dangerous Gateway control-plane bypass is loopback-only");
}
const snapshot = nodeHttpStackSnapshot;
if (!snapshot) {
return run();
}
// Security-sensitive: this temporarily removes managed proxy hooks for the
// synchronous Gateway loopback WebSocket constructor only. Do not reuse this
// helper for provider, plugin, user WebUI, model server, or arbitrary egress.
const activeStack = captureNodeHttpStack();
const globalRecord = global as Record<string, unknown>;
try {
http.request = snapshot.httpRequest;
http.get = snapshot.httpGet;
http.globalAgent = snapshot.httpGlobalAgent;
https.request = snapshot.httpsRequest;
https.get = snapshot.httpsGet;
https.globalAgent = snapshot.httpsGlobalAgent;
if (snapshot.hadGlobalAgent) {
globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent;
} else {
delete globalRecord["GLOBAL_AGENT"];
}
return run();
} finally {
http.request = activeStack.httpRequest;
http.get = activeStack.httpGet;
http.globalAgent = activeStack.httpGlobalAgent;
https.request = activeStack.httpsRequest;
https.get = activeStack.httpsGet;
https.globalAgent = activeStack.httpsGlobalAgent;
if (activeStack.hadGlobalAgent) {
globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent;
} else {
delete globalRecord["GLOBAL_AGENT"];
}
}
}