




















@@ -1,11 +1,116 @@
1-import { spawn } from "node:child_process";
1+import { execFileSync, spawn } from "node:child_process";
2+import { readFileSync, writeFileSync } from "node:fs";
23import { createServer, request as httpRequest, type Server } from "node:http";
4+import { createServer as createHttpsServer } from "node:https";
35import * as net from "node:net";
6+import { join } from "node:path";
7+import type { Duplex } from "node:stream";
48import { afterEach, describe, expect, it } from "vitest";
59import { WebSocketServer } from "ws";
10+import { withTempDir } from "../../../test-helpers/temp-dir.js";
11+import { resolveSystemBin } from "../../resolve-system-bin.js";
12+import { resolvePreferredOpenClawTmpDir } from "../../tmp-openclaw-dir.js";
613714const CHILD_PROCESS_TIMEOUT_MS = process.env.CI ? 45_000 : 15_000;
815const PROBE_TIMEOUT_MS = process.env.CI ? 15_000 : 5_000;
16+const PROXY_TUNNEL_SOCKETS = new WeakMap<Server, Set<Duplex>>();
17+type DiscordTlsFixture = {
18+caPath: string;
19+cert: string;
20+key: string;
21+};
22+23+function createDiscordTlsFixture(dir: string): DiscordTlsFixture {
24+const openssl = resolveSystemBin("openssl");
25+if (!openssl) {
26+throw new Error("openssl is required to generate proxy TLS test certificates");
27+}
28+const caKeyPath = join(dir, "ca-key.pem");
29+const caCertPath = join(dir, "ca-cert.pem");
30+const serverKeyPath = join(dir, "server-key.pem");
31+const serverCsrPath = join(dir, "server.csr");
32+const serverCertPath = join(dir, "server-cert.pem");
33+const extPath = join(dir, "server-ext.cnf");
34+35+execFileSync(
36+openssl,
37+[
38+"req",
39+"-x509",
40+"-newkey",
41+"rsa:2048",
42+"-nodes",
43+"-keyout",
44+caKeyPath,
45+"-out",
46+caCertPath,
47+"-days",
48+"1",
49+"-subj",
50+"/CN=OpenClaw Proxy Test CA",
51+],
52+{ stdio: "ignore" },
53+);
54+execFileSync(
55+openssl,
56+[
57+"req",
58+"-newkey",
59+"rsa:2048",
60+"-nodes",
61+"-keyout",
62+serverKeyPath,
63+"-out",
64+serverCsrPath,
65+"-subj",
66+"/CN=discord.com",
67+],
68+{ stdio: "ignore" },
69+);
70+writeFileSync(extPath, "subjectAltName=DNS:discord.com\n");
71+execFileSync(
72+openssl,
73+[
74+"x509",
75+"-req",
76+"-in",
77+serverCsrPath,
78+"-CA",
79+caCertPath,
80+"-CAkey",
81+caKeyPath,
82+"-CAcreateserial",
83+"-out",
84+serverCertPath,
85+"-days",
86+"1",
87+"-sha256",
88+"-extfile",
89+extPath,
90+],
91+{ stdio: "ignore" },
92+);
93+94+return {
95+caPath: caCertPath,
96+cert: readFileSync(serverCertPath, "utf8"),
97+key: readFileSync(serverKeyPath, "utf8"),
98+};
99+}
100+101+async function withDiscordTlsFixture<T>(
102+run: (fixture: DiscordTlsFixture) => Promise<T>,
103+): Promise<T> {
104+return await withTempDir(
105+{
106+prefix: "openclaw-discord-tls-",
107+parentDir: resolvePreferredOpenClawTmpDir(),
108+},
109+async (dir) => {
110+return await run(createDiscordTlsFixture(dir));
111+},
112+);
113+}
911410115async function listenOnLoopback(server: Server): Promise<number> {
11116return new Promise((resolve, reject) => {
@@ -26,6 +131,10 @@ async function closeServer(server: Server | null): Promise<void> {
26131if (server === null || !server.listening) {
27132return;
28133}
134+for (const socket of PROXY_TUNNEL_SOCKETS.get(server) ?? []) {
135+socket.destroy();
136+}
137+server.closeAllConnections?.();
29138await new Promise<void>((resolve, reject) => {
30139server.close((err) => {
31140if (err) {
@@ -37,7 +146,16 @@ async function closeServer(server: Server | null): Promise<void> {
37146});
38147}
3914840-function createTunnelProxy(seenConnectTargets: string[]): Server {
149+type ConnectTargetOverride = {
150+hostname: string;
151+port: number;
152+};
153+154+function createTunnelProxy(
155+seenConnectTargets: string[],
156+connectTargetOverrides: Record<string, ConnectTargetOverride> = {},
157+): Server {
158+const tunnelSockets = new Set<Duplex>();
41159const proxy = createServer((req, res) => {
42160const target = req.url ?? "";
43161seenConnectTargets.push(target);
@@ -71,6 +189,7 @@ function createTunnelProxy(seenConnectTargets: string[]): Server {
71189});
72190req.pipe(upstream);
73191});
192+PROXY_TUNNEL_SOCKETS.set(proxy, tunnelSockets);
7419375194proxy.on("connect", (req, clientSocket, head) => {
76195const target = req.url ?? "";
@@ -84,13 +203,26 @@ function createTunnelProxy(seenConnectTargets: string[]): Server {
84203return;
85204}
8620587-const upstream = net.connect(Number(targetUrl.port), targetUrl.hostname, () => {
88-clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
89-if (head.length > 0) {
90-upstream.write(head);
91-}
92-upstream.pipe(clientSocket);
93-clientSocket.pipe(upstream);
206+const override = connectTargetOverrides[target];
207+tunnelSockets.add(clientSocket);
208+clientSocket.once("close", () => {
209+tunnelSockets.delete(clientSocket);
210+});
211+const upstream = net.connect(
212+override?.port ?? Number(targetUrl.port),
213+override?.hostname ?? targetUrl.hostname,
214+() => {
215+clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
216+if (head.length > 0) {
217+upstream.write(head);
218+}
219+upstream.pipe(clientSocket);
220+clientSocket.pipe(upstream);
221+},
222+);
223+tunnelSockets.add(upstream);
224+upstream.once("close", () => {
225+tunnelSockets.delete(upstream);
94226});
9522796228upstream.on("error", () => {
@@ -155,6 +287,7 @@ async function runNodeModule(
155287describe("SSRF external proxy routing", () => {
156288let target: Server | null = null;
157289let httpsLikeTarget: Server | null = null;
290+let tlsTarget: Server | null = null;
158291let proxy: Server | null = null;
159292let wss: WebSocketServer | null = null;
160293@@ -167,10 +300,12 @@ describe("SSRF external proxy routing", () => {
167300wss.close(() => resolve());
168301});
169302await closeServer(proxy);
303+await closeServer(tlsTarget);
170304await closeServer(httpsLikeTarget);
171305await closeServer(target);
172306wss = null;
173307proxy = null;
308+tlsTarget = null;
174309httpsLikeTarget = null;
175310target = null;
176311});
@@ -328,4 +463,72 @@ describe("SSRF external proxy routing", () => {
328463expect(seenConnectTargets).toContain(`http://127.0.0.1:${targetPort}/websocket-proxied`);
329464expect(seenConnectTargets).not.toContain(`http://127.0.0.1:${targetPort}/gateway-bypass`);
330465});
466+467+it("preserves the target TLS hostname for Node HTTPS requests through the managed proxy", async () => {
468+await withDiscordTlsFixture(async (tlsFixture) => {
469+tlsTarget = createHttpsServer({ key: tlsFixture.key, cert: tlsFixture.cert }, (_req, res) => {
470+res.writeHead(209, { "content-type": "text/plain" });
471+res.end("discord target tls ok");
472+});
473+const tlsTargetPort = await listenOnLoopback(tlsTarget);
474+475+const seenConnectTargets: string[] = [];
476+proxy = createTunnelProxy(seenConnectTargets, {
477+[`discord.com:${tlsTargetPort}`]: { hostname: "127.0.0.1", port: tlsTargetPort },
478+});
479+const proxyPort = await listenOnLoopback(proxy);
480+481+const child = await runNodeModule(
482+`
483+ import https from "node:https";
484+ import { startProxy, stopProxy } from "./src/infra/net/proxy/proxy-lifecycle.ts";
485+486+ async function nodeHttpsGet(url) {
487+ return new Promise((resolve, reject) => {
488+ const req = https.get(url, (response) => {
489+ let body = "";
490+ response.setEncoding("utf8");
491+ response.on("data", (chunk) => {
492+ body += chunk;
493+ });
494+ response.on("end", () => {
495+ resolve({ status: response.statusCode, body });
496+ });
497+ });
498+ req.setTimeout(${PROBE_TIMEOUT_MS}, () => {
499+ req.destroy(new Error("node:https request timed out"));
500+ });
501+ req.on("error", reject);
502+ });
503+ }
504+505+ const handle = await startProxy({ enabled: true });
506+ if (handle === null) {
507+ throw new Error("expected external proxy routing to start");
508+ }
509+ try {
510+ const response = await nodeHttpsGet(process.env.OPENCLAW_TEST_DISCORD_TLS_URL);
511+ console.log(JSON.stringify(response));
512+ } finally {
513+ await stopProxy(handle);
514+ }
515+ `,
516+{
517+ ...process.env,
518+NODE_EXTRA_CA_CERTS: tlsFixture.caPath,
519+OPENCLAW_PROXY_URL: `http://127.0.0.1:${proxyPort}`,
520+OPENCLAW_TEST_DISCORD_TLS_URL: `https://discord.com:${tlsTargetPort}/tls-proxy-proof`,
521+NO_PROXY: "127.0.0.1,localhost",
522+no_proxy: "localhost",
523+GLOBAL_AGENT_NO_PROXY: "localhost",
524+},
525+);
526+527+expect(child.stderr).toBe("");
528+expect(child.code).toBe(0);
529+expect(child.stdout).toContain('"status":209');
530+expect(child.stdout).toContain('"body":"discord target tls ok"');
531+expect(seenConnectTargets).toContain(`discord.com:${tlsTargetPort}`);
532+});
533+});
331534});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。