mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
fix(gateway): bypass proxies for localhost control plane
This commit is contained in:
@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
|
||||
- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07.
|
||||
- Gateway/startup: return retryable `UNAVAILABLE` during the sidecar startup window and keep CLI/TUI/status clients retrying inside their existing timeout budget, so early connects no longer surface as terminal handshake failures. Fixes #73652. Thanks @spenceryang1996-dot.
|
||||
- Gateway/proxy: bypass inherited proxy environment for local Gateway control-plane WebSockets to `localhost` as well as loopback IPs, so Windows/WSL proxy settings cannot intercept local CLI/TUI Gateway connections. Supersedes #73474; refs #73602. Thanks @DhtIsCoding.
|
||||
- Doctor/Gateway: use a lightweight `status` RPC without channel summary work for doctor Gateway liveness, so slow health snapshots do not falsely drive service restart repair. Fixes #64400; supersedes #64511. Thanks @CHE10X and @EronFan.
|
||||
- Agents/auth: scope external CLI credential discovery to configured providers during model auth status and startup prewarm, so opencode-only and other single-provider gateways do not block on unrelated Claude CLI Keychain probes. Fixes #73908. Thanks @Ailuras.
|
||||
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.
|
||||
|
||||
@@ -36,7 +36,7 @@ OpenClaw process
|
||||
WebSocket clients -> operator-managed filtering proxy -> public internet
|
||||
```
|
||||
|
||||
The public contract is the routing behavior, not the internal Node hooks used to implement it. OpenClaw Gateway control-plane WebSocket clients use a narrow direct path for local loopback Gateway RPC traffic when the Gateway URL uses a literal loopback IP such as `127.0.0.1` or `[::1]`. That control-plane path must be able to reach loopback Gateways even when the operator proxy blocks loopback destinations. Normal runtime HTTP and WebSocket requests still use the configured proxy.
|
||||
The public contract is the routing behavior, not the internal Node hooks used to implement it. OpenClaw Gateway control-plane WebSocket clients use a narrow direct path for local loopback Gateway RPC traffic when the Gateway URL uses `localhost` or a literal loopback IP such as `127.0.0.1` or `[::1]`. That control-plane path must be able to reach loopback Gateways even when the operator proxy blocks loopback destinations. Normal runtime HTTP and WebSocket requests still use the configured proxy.
|
||||
|
||||
The proxy URL itself must use `http://`. HTTPS destinations are still supported through the proxy with HTTP `CONNECT`; this only means OpenClaw expects a plain HTTP forward-proxy listener such as `http://127.0.0.1:3128`.
|
||||
|
||||
@@ -150,6 +150,6 @@ proxy:
|
||||
- The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it does not replace application-level `fetchWithSsrFGuard`.
|
||||
- Raw `net`, `tls`, and `http2` sockets, native addons, and child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables.
|
||||
- User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them.
|
||||
- Gateway control-plane proxy bypass is intentionally limited to literal loopback IP URLs. Use `ws://127.0.0.1:18789` or `ws://[::1]:18789` for local direct Gateway control-plane connections; `localhost` hostnames route like ordinary hostname-based traffic.
|
||||
- Gateway control-plane proxy bypass is intentionally limited to `localhost` and literal loopback IP URLs. Use `ws://127.0.0.1:18789`, `ws://[::1]:18789`, or `ws://localhost:18789` for local direct Gateway control-plane connections; other hostnames route like ordinary hostname-based traffic.
|
||||
- OpenClaw does not inspect, test, or certify your proxy policy.
|
||||
- Treat proxy policy changes as security-sensitive operational changes.
|
||||
|
||||
@@ -17,7 +17,6 @@ import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "../
|
||||
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import { isLoopbackIpAddress } from "../shared/net/ip.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -101,7 +100,7 @@ function createDirectGatewayAgent(url: string): http.Agent | https.Agent | undef
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isLoopbackIpAddress(hostname)) {
|
||||
if (!isLoopbackHost(hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
return url.startsWith("wss://") ? new https.Agent() : new http.Agent();
|
||||
|
||||
@@ -105,12 +105,12 @@ describe("GatewayClient", () => {
|
||||
expect(last?.opts.agent).toBeDefined();
|
||||
});
|
||||
|
||||
test("does not use the direct control-plane bypass for localhost hostnames", () => {
|
||||
test("uses the direct control-plane bypass for localhost hostnames", () => {
|
||||
const client = new GatewayClient({ url: "ws://localhost:1" });
|
||||
client.start();
|
||||
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
|
||||
|
||||
expect(last?.opts.agent).toBeUndefined();
|
||||
expect(last?.opts.agent).toBeDefined();
|
||||
});
|
||||
|
||||
test("does not force a direct agent for remote Gateway WebSocket connections", () => {
|
||||
|
||||
@@ -38,6 +38,8 @@ describe("startProxy", () => {
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"all_proxy",
|
||||
"ALL_PROXY",
|
||||
"no_proxy",
|
||||
"NO_PROXY",
|
||||
"GLOBAL_AGENT_HTTP_PROXY",
|
||||
@@ -378,7 +380,7 @@ describe("startProxy", () => {
|
||||
await stopProxy(handle);
|
||||
});
|
||||
|
||||
it("allows the Gateway control-plane bypass for literal loopback IPs only", () => {
|
||||
it("allows the Gateway control-plane bypass for literal loopback IPs and localhost", () => {
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://127.0.0.1:18789",
|
||||
@@ -388,12 +390,18 @@ describe("startProxy", () => {
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane("ws://[::1]:18789", () => "ok"),
|
||||
).toBe("ok");
|
||||
expect(() =>
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://localhost:18789",
|
||||
() => undefined,
|
||||
() => "ok",
|
||||
),
|
||||
).toThrow("loopback-only");
|
||||
).toBe("ok");
|
||||
expect(
|
||||
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://localhost.:18789",
|
||||
() => "ok",
|
||||
),
|
||||
).toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects dangerous Gateway control-plane bypass for non-loopback URLs", () => {
|
||||
@@ -405,6 +413,92 @@ describe("startProxy", () => {
|
||||
).toThrow("loopback-only");
|
||||
});
|
||||
|
||||
it("temporarily clears inherited proxy env for Gateway control-plane setup", () => {
|
||||
process.env["http_proxy"] = "http://lower-http.example.com:8080";
|
||||
process.env["https_proxy"] = "http://lower-https.example.com:8080";
|
||||
process.env["HTTP_PROXY"] = "http://upper-http.example.com:8080";
|
||||
process.env["HTTPS_PROXY"] = "http://upper-https.example.com:8080";
|
||||
process.env["all_proxy"] = "http://lower-all.example.com:8080";
|
||||
process.env["ALL_PROXY"] = "http://upper-all.example.com:8080";
|
||||
process.env["NO_PROXY"] = "localhost";
|
||||
process.env["no_proxy"] = "127.0.0.1";
|
||||
process.env["GLOBAL_AGENT_HTTP_PROXY"] = "http://global-http.example.com:8080";
|
||||
process.env["GLOBAL_AGENT_HTTPS_PROXY"] = "http://global-https.example.com:8080";
|
||||
process.env["GLOBAL_AGENT_NO_PROXY"] = "localhost";
|
||||
process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] = "true";
|
||||
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
|
||||
|
||||
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://localhost:18789",
|
||||
() => ({
|
||||
httpProxy: process.env["HTTP_PROXY"],
|
||||
httpsProxy: process.env["HTTPS_PROXY"],
|
||||
allProxy: process.env["ALL_PROXY"],
|
||||
lowerAllProxy: process.env["all_proxy"],
|
||||
noProxy: process.env["NO_PROXY"],
|
||||
globalProxy: process.env["GLOBAL_AGENT_HTTP_PROXY"],
|
||||
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(during).toEqual({
|
||||
httpProxy: undefined,
|
||||
httpsProxy: undefined,
|
||||
allProxy: undefined,
|
||||
lowerAllProxy: undefined,
|
||||
noProxy: undefined,
|
||||
globalProxy: undefined,
|
||||
proxyActive: undefined,
|
||||
});
|
||||
expect(process.env["HTTP_PROXY"]).toBe("http://upper-http.example.com:8080");
|
||||
expect(process.env["HTTPS_PROXY"]).toBe("http://upper-https.example.com:8080");
|
||||
expect(process.env["ALL_PROXY"]).toBe("http://upper-all.example.com:8080");
|
||||
expect(process.env["all_proxy"]).toBe("http://lower-all.example.com:8080");
|
||||
expect(process.env["NO_PROXY"]).toBe("localhost");
|
||||
expect(process.env["GLOBAL_AGENT_HTTP_PROXY"]).toBe("http://global-http.example.com:8080");
|
||||
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
|
||||
});
|
||||
|
||||
it("temporarily clears managed proxy env while restoring the original HTTP stack", async () => {
|
||||
const patchedHttpRequest = vi.fn() as unknown as typeof http.request;
|
||||
mockBootstrapGlobalAgent.mockImplementationOnce(() => {
|
||||
http.request = patchedHttpRequest;
|
||||
(global as Record<string, unknown>)["GLOBAL_AGENT"] = {
|
||||
HTTP_PROXY: "",
|
||||
HTTPS_PROXY: "",
|
||||
};
|
||||
});
|
||||
|
||||
const handle = await startProxy({
|
||||
enabled: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
});
|
||||
process.env["ALL_PROXY"] = "http://inherited-all.example.com:8080";
|
||||
|
||||
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
|
||||
"ws://127.0.0.1:18789",
|
||||
() => ({
|
||||
httpRequest: http.request,
|
||||
httpProxy: process.env["HTTP_PROXY"],
|
||||
allProxy: process.env["ALL_PROXY"],
|
||||
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(during).toEqual({
|
||||
httpRequest: originalHttpRequest,
|
||||
httpProxy: undefined,
|
||||
allProxy: undefined,
|
||||
proxyActive: undefined,
|
||||
});
|
||||
expect(http.request).toBe(patchedHttpRequest);
|
||||
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
|
||||
expect(process.env["ALL_PROXY"]).toBe("http://inherited-all.example.com:8080");
|
||||
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
|
||||
|
||||
await stopProxy(handle);
|
||||
});
|
||||
|
||||
it("kill restores env synchronously during hard process exit", async () => {
|
||||
process.env["NO_PROXY"] = "corp.example.com";
|
||||
const handle = await startProxy({
|
||||
|
||||
@@ -40,8 +40,19 @@ const ALL_PROXY_ENV_KEYS = [
|
||||
...NO_PROXY_ENV_KEYS,
|
||||
...PROXY_ACTIVE_KEYS,
|
||||
] as const;
|
||||
const GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS = [
|
||||
...ALL_PROXY_ENV_KEYS,
|
||||
"all_proxy",
|
||||
"ALL_PROXY",
|
||||
] as const;
|
||||
type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number];
|
||||
type ProxyEnvSnapshot = Record<ProxyEnvKey, string | undefined>;
|
||||
type GatewayControlPlaneProxyBypassEnvKey =
|
||||
(typeof GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS)[number];
|
||||
type GatewayControlPlaneProxyBypassEnvSnapshot = Record<
|
||||
GatewayControlPlaneProxyBypassEnvKey,
|
||||
string | undefined
|
||||
>;
|
||||
type NodeHttpStackSnapshot = {
|
||||
httpRequest: typeof http.request;
|
||||
httpGet: typeof http.get;
|
||||
@@ -116,6 +127,39 @@ function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void {
|
||||
}
|
||||
}
|
||||
|
||||
function captureGatewayControlPlaneProxyBypassEnv(): GatewayControlPlaneProxyBypassEnvSnapshot {
|
||||
const snapshot = {} as GatewayControlPlaneProxyBypassEnvSnapshot;
|
||||
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
|
||||
snapshot[key] = process.env[key];
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function restoreGatewayControlPlaneProxyBypassEnv(
|
||||
snapshot: GatewayControlPlaneProxyBypassEnvSnapshot,
|
||||
): void {
|
||||
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
|
||||
const value = snapshot[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withoutGatewayControlPlaneProxyEnv<T>(run: () => T): T {
|
||||
const snapshot = captureGatewayControlPlaneProxyBypassEnv();
|
||||
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
restoreGatewayControlPlaneProxyBypassEnv(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreGlobalAgentRuntime(snapshot: ProxyEnvSnapshot): void {
|
||||
if (
|
||||
typeof global === "undefined" ||
|
||||
@@ -371,7 +415,12 @@ function isGatewayLoopbackControlPlaneUrl(value: string): boolean {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isLoopbackIpAddress(url.hostname);
|
||||
return isGatewayControlPlaneLoopbackHost(url.hostname);
|
||||
}
|
||||
|
||||
function isGatewayControlPlaneLoopbackHost(hostname: string): boolean {
|
||||
const normalizedHost = hostname.trim().toLowerCase().replace(/\.+$/, "");
|
||||
return normalizedHost === "localhost" || isLoopbackIpAddress(hostname);
|
||||
}
|
||||
|
||||
export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
|
||||
@@ -384,38 +433,40 @@ export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
|
||||
|
||||
const snapshot = nodeHttpStackSnapshot;
|
||||
if (!snapshot) {
|
||||
return run();
|
||||
return withoutGatewayControlPlaneProxyEnv(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 withoutGatewayControlPlaneProxyEnv(() => {
|
||||
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"];
|
||||
}
|
||||
}
|
||||
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"];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user