| |
| import { mkdtemp, rm } from "node:fs/promises"; |
| import { createServer as createHttpServer } from "node:http"; |
| import { Socket, type AddressInfo } from "node:net"; |
| import { tmpdir } from "node:os"; |
| import { join } from "node:path"; |
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; |
| import type { DebugProxySettings } from "./env.js"; |
| import { startDebugProxyServer } from "./proxy-server.js"; |
| import { closeDebugProxyCaptureStore } from "./store.sqlite.js"; |
|
|
| vi.mock("./ca.js", () => ({ |
| ensureDebugProxyCa: async () => ({ certPath: "test", keyPath: "test" }), |
| })); |
|
|
| let testRoot: string | undefined; |
| const originalStateDir = process.env.OPENCLAW_STATE_DIR; |
|
|
| async function cleanupTestDirs(): Promise<void> { |
| closeDebugProxyCaptureStore(); |
| closeOpenClawStateDatabaseForTest(); |
| if (originalStateDir === undefined) { |
| delete process.env.OPENCLAW_STATE_DIR; |
| } else { |
| process.env.OPENCLAW_STATE_DIR = originalStateDir; |
| } |
| if (!testRoot) { |
| return; |
| } |
| const root = testRoot; |
| testRoot = undefined; |
| await rm(root, { recursive: true, force: true }); |
| } |
|
|
| async function makeSettings(): Promise<DebugProxySettings> { |
| testRoot = await mkdtemp(join(tmpdir(), "openclaw-debug-proxy-managed-proxy-")); |
| const certDir = join(testRoot, "certs"); |
| process.env.OPENCLAW_STATE_DIR = testRoot; |
| return { |
| enabled: true, |
| required: false, |
| dbPath: join(testRoot, "capture.sqlite"), |
| blobDir: join(testRoot, "blobs"), |
| certDir, |
| sessionId: "debug-proxy-managed-proxy-test", |
| sourceProcess: "test", |
| }; |
| } |
|
|
| async function connectThroughProxy( |
| proxyUrl: string, |
| connectTarget = "example.com:443", |
| ): Promise<string> { |
| const target = new URL(proxyUrl); |
| const socket = new Socket(); |
| let data = ""; |
| socket.setEncoding("utf8"); |
| socket.on("data", (chunk) => { |
| data += chunk.toString(); |
| }); |
| await new Promise<void>((resolve, reject) => { |
| socket.once("error", reject); |
| socket.connect(Number(target.port), target.hostname, resolve); |
| }); |
| socket.write(`CONNECT ${connectTarget} HTTP/1.1\r\nHost: ${connectTarget}\r\n\r\n`); |
| await new Promise<void>((resolve) => { |
| socket.once("end", resolve); |
| }); |
| socket.destroy(); |
| return data; |
| } |
|
|
| async function requestThroughProxy(proxyUrl: string, targetUrl: string): Promise<string> { |
| const proxy = new URL(proxyUrl); |
| const target = new URL(targetUrl); |
| const socket = new Socket(); |
| let data = ""; |
| socket.setEncoding("utf8"); |
| socket.on("data", (chunk) => { |
| data += chunk.toString(); |
| }); |
| await new Promise<void>((resolve, reject) => { |
| socket.once("error", reject); |
| socket.connect(Number(proxy.port), proxy.hostname, resolve); |
| }); |
| socket.write(`GET ${target.href} HTTP/1.1\r\nHost: ${target.host}\r\nConnection: close\r\n\r\n`); |
| await new Promise<void>((resolve) => { |
| socket.once("end", resolve); |
| }); |
| socket.destroy(); |
| return data; |
| } |
|
|
| async function requestRawThroughProxy(proxyUrl: string, request: string): Promise<string> { |
| const proxy = new URL(proxyUrl); |
| const socket = new Socket(); |
| let data = ""; |
| socket.setEncoding("utf8"); |
| socket.on("data", (chunk) => { |
| data += chunk.toString(); |
| }); |
| await new Promise<void>((resolve, reject) => { |
| socket.once("error", reject); |
| socket.connect(Number(proxy.port), proxy.hostname, resolve); |
| }); |
| socket.write(request); |
| await new Promise<void>((resolve) => { |
| socket.once("end", resolve); |
| }); |
| socket.destroy(); |
| return data; |
| } |
|
|
| async function startCanaryOrigin(): Promise<{ |
| requestCount: () => number; |
| stop: () => Promise<void>; |
| url: string; |
| }> { |
| let requests = 0; |
| const server = createHttpServer((_req, res) => { |
| requests += 1; |
| res.end("ok"); |
| }); |
| 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() as AddressInfo; |
| return { |
| requestCount: () => requests, |
| stop: async () => |
| await new Promise<void>((resolve, reject) => { |
| server.close((error) => { |
| if (error) { |
| reject(error); |
| return; |
| } |
| resolve(); |
| }); |
| }), |
| url: `http://127.0.0.1:${address.port}/metadata`, |
| }; |
| } |
|
|
| describe("debug proxy managed-proxy direct upstream policy", () => { |
| const originalProxyActive = process.env["OPENCLAW_PROXY_ACTIVE"]; |
| const originalAllowDirect = |
| process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"]; |
|
|
| beforeEach(async () => { |
| await cleanupTestDirs(); |
| delete process.env["OPENCLAW_PROXY_ACTIVE"]; |
| delete process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"]; |
| }); |
|
|
| afterEach(async () => { |
| if (originalProxyActive === undefined) { |
| delete process.env["OPENCLAW_PROXY_ACTIVE"]; |
| } else { |
| process.env["OPENCLAW_PROXY_ACTIVE"] = originalProxyActive; |
| } |
| if (originalAllowDirect === undefined) { |
| delete process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"]; |
| } else { |
| process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"] = |
| originalAllowDirect; |
| } |
| await cleanupTestDirs(); |
| }); |
|
|
| it("allows HTTP upstreams with the explicit diagnostic override", async () => { |
| process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; |
| process.env["OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY"] = "1"; |
| const origin = await startCanaryOrigin(); |
| const server = await startDebugProxyServer({ settings: await makeSettings() }); |
| try { |
| const response = await requestThroughProxy(server.proxyUrl, origin.url); |
|
|
| expect(response).toContain("200 OK"); |
| expect(response).toContain("ok"); |
| expect(origin.requestCount()).toBe(1); |
| } finally { |
| await server.stop(); |
| await origin.stop(); |
| } |
| }); |
|
|
| it("rejects CONNECT upstreams before opening direct sockets while managed proxy mode is active", async () => { |
| process.env["OPENCLAW_PROXY_ACTIVE"] = "true"; |
| const server = await startDebugProxyServer({ settings: await makeSettings() }); |
| try { |
| const response = await connectThroughProxy(server.proxyUrl); |
|
|
| expect(response).toContain("403 Forbidden"); |
| expect(response).toContain("Connection: close"); |
| expect(response).toContain("Debug proxy direct upstream forwarding is disabled"); |
| } finally { |
| await server.stop(); |
| } |
| }); |
|
|
| it("accepts bracketed IPv6 CONNECT targets before applying upstream policy", async () => { |
| process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; |
| const server = await startDebugProxyServer({ settings: await makeSettings() }); |
| try { |
| const response = await connectThroughProxy(server.proxyUrl, "[::1]:8443"); |
|
|
| expect(response).toContain("403 Forbidden"); |
| expect(response).not.toContain("400 Bad Request"); |
| } finally { |
| await server.stop(); |
| } |
| }); |
|
|
| it.each(["[::1]:99999", "api.openai.com:1e3", "api.openai.com:0x50"])( |
| "rejects invalid CONNECT target %s", |
| async (connectTarget) => { |
| const server = await startDebugProxyServer({ settings: await makeSettings() }); |
| try { |
| const response = await connectThroughProxy(server.proxyUrl, connectTarget); |
|
|
| expect(response).toContain("400 Bad Request"); |
| } finally { |
| await server.stop(); |
| } |
| }, |
| ); |
|
|
| it("rejects absolute-form HTTP proxy requests before opening direct upstreams while managed proxy mode is active", async () => { |
| process.env["OPENCLAW_PROXY_ACTIVE"] = "1"; |
| const origin = await startCanaryOrigin(); |
| const server = await startDebugProxyServer({ settings: await makeSettings() }); |
| try { |
| const response = await requestThroughProxy(server.proxyUrl, origin.url); |
|
|
| expect(response).toContain("403 Forbidden"); |
| expect(response).toContain("Connection: close"); |
| expect(response).toContain("Debug proxy direct upstream forwarding is disabled"); |
| expect(origin.requestCount()).toBe(0); |
| } finally { |
| await server.stop(); |
| await origin.stop(); |
| } |
| }); |
|
|
| it("rejects malformed relative-form HTTP proxy targets before upstream handling", async () => { |
| const server = await startDebugProxyServer({ settings: await makeSettings() }); |
| try { |
| const response = await requestRawThroughProxy( |
| server.proxyUrl, |
| "GET /capture HTTP/1.1\r\nHost: [\r\nConnection: close\r\n\r\n", |
| ); |
|
|
| expect(response).toContain("400 Bad Request"); |
| expect(response).toContain("Connection: close"); |
| expect(response).toContain("Invalid proxy target URL"); |
| } finally { |
| await server.stop(); |
| } |
| }); |
| }); |
|
|