@openclaw/proxyline 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +11 -13
- package/dist/node-http.d.ts.map +1 -1
- package/dist/node-http.js +8 -5
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +18 -19
- package/docs/CNAME +1 -0
- package/docs/README.md +34 -0
- package/docs/api-reference.md +280 -0
- package/docs/environment-variables.md +70 -0
- package/docs/getting-started.md +82 -0
- package/docs/index.md +46 -0
- package/docs/modes.md +68 -0
- package/docs/observability.md +98 -0
- package/docs/proxy-tls.md +66 -0
- package/docs/security.md +83 -0
- package/docs/surfaces.md +116 -0
- package/docs/testing.md +78 -0
- package/docs/troubleshooting.md +71 -0
- package/package.json +9 -3
- package/scripts/prepack-build.mjs +32 -0
- package/src/connect.ts +222 -0
- package/src/dispatcher-brand.ts +13 -0
- package/src/env.ts +250 -0
- package/src/index.ts +27 -0
- package/src/node-http.ts +901 -0
- package/src/runtime.ts +906 -0
- package/src/shared.ts +42 -0
- package/src/types.ts +98 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/proxyline",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Process-global proxy routing for Node.js.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,13 +32,19 @@
|
|
|
32
32
|
"dist/*.js",
|
|
33
33
|
"dist/*.d.ts",
|
|
34
34
|
"dist/*.d.ts.map",
|
|
35
|
+
"src/**/*.ts",
|
|
36
|
+
"docs/*.md",
|
|
37
|
+
"docs/CNAME",
|
|
38
|
+
"scripts/prepack-build.mjs",
|
|
39
|
+
"tsconfig.json",
|
|
40
|
+
"tsconfig.build.json",
|
|
35
41
|
"CHANGELOG.md",
|
|
36
42
|
"README.md",
|
|
37
43
|
"LICENSE"
|
|
38
44
|
],
|
|
39
45
|
"scripts": {
|
|
40
46
|
"build": "tsc -p tsconfig.build.json",
|
|
41
|
-
"check": "pnpm build && tsc --noEmit && pnpm coverage:run",
|
|
47
|
+
"check": "pnpm build && tsc --noEmit && pnpm coverage:run && tsx --test test/package-artifact.test.ts",
|
|
42
48
|
"coverage": "pnpm build && pnpm coverage:run",
|
|
43
49
|
"coverage:run": "node scripts/run-coverage.mjs",
|
|
44
50
|
"docs:build": "node scripts/build-docs-site.mjs",
|
|
@@ -46,7 +52,7 @@
|
|
|
46
52
|
"prepack": "node scripts/prepack-build.mjs",
|
|
47
53
|
"prepare": "node scripts/prepack-build.mjs",
|
|
48
54
|
"publish:dry-run": "pnpm publish --dry-run --access public",
|
|
49
|
-
"test": "pnpm build && tsx --test test/index.test.ts test/e2e.test.ts test/package.test.ts",
|
|
55
|
+
"test": "pnpm build && tsx --test test/index.test.ts test/e2e.test.ts test/package.test.ts test/scripts.test.ts && tsx --test test/package-artifact.test.ts",
|
|
50
56
|
"typecheck": "pnpm build && tsc --noEmit"
|
|
51
57
|
},
|
|
52
58
|
"devDependencies": {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
function resolveTypeScriptCompiler() {
|
|
8
|
+
try {
|
|
9
|
+
return require.resolve("typescript/bin/tsc");
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function run(command, args, env = {}) {
|
|
16
|
+
const result = spawnSync(command, args, {
|
|
17
|
+
stdio: "inherit",
|
|
18
|
+
env: { ...process.env, ...env },
|
|
19
|
+
});
|
|
20
|
+
if (result.status !== 0) {
|
|
21
|
+
process.exit(result.status ?? 1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const tscBin = resolveTypeScriptCompiler();
|
|
26
|
+
|
|
27
|
+
if (!tscBin) {
|
|
28
|
+
console.error("TypeScript compiler is unavailable. Run `pnpm install --frozen-lockfile` before packing.");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
run(process.execPath, [tscBin, "-p", "tsconfig.build.json"]);
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
import { ProxylineError, type ProxylineTlsOptions, redactProxyUrl, resolveProxyTlsCa } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
export type OpenProxyConnectTunnelOptions = Readonly<{
|
|
6
|
+
proxyUrl: string | URL;
|
|
7
|
+
proxyTls?: ProxylineTlsOptions;
|
|
8
|
+
targetHost: string;
|
|
9
|
+
targetPort: number;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
|
|
14
|
+
const INVALID_CONNECT_AUTHORITY_PATTERN = /[\u0000-\u0020\u007f]/;
|
|
15
|
+
const INVALID_CONNECT_HOST_DELIMITER_PATTERN = /[/:?#@\\]/;
|
|
16
|
+
|
|
17
|
+
type ProxySocket = net.Socket | tls.TLSSocket;
|
|
18
|
+
|
|
19
|
+
function resolveProxyHost(proxy: URL): string {
|
|
20
|
+
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveProxyPort(proxy: URL): number {
|
|
24
|
+
if (proxy.port) {
|
|
25
|
+
return Number(proxy.port);
|
|
26
|
+
}
|
|
27
|
+
return proxy.protocol === "https:" ? 443 : 80;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveProxyAuthorization(proxy: URL): string | undefined {
|
|
31
|
+
if (!proxy.username && !proxy.password) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const username = decodeURIComponent(proxy.username);
|
|
35
|
+
const password = decodeURIComponent(proxy.password);
|
|
36
|
+
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatConnectAuthority(targetHost: string, targetPort: number): string {
|
|
40
|
+
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65_535) {
|
|
41
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", `Invalid CONNECT target port: ${targetPort}`);
|
|
42
|
+
}
|
|
43
|
+
if (!targetHost || INVALID_CONNECT_AUTHORITY_PATTERN.test(targetHost)) {
|
|
44
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is empty or unsafe.");
|
|
45
|
+
}
|
|
46
|
+
const unbracketedHost =
|
|
47
|
+
targetHost.startsWith("[") && targetHost.endsWith("]")
|
|
48
|
+
? targetHost.slice(1, -1)
|
|
49
|
+
: targetHost;
|
|
50
|
+
if (net.isIP(unbracketedHost) === 6) {
|
|
51
|
+
return `[${unbracketedHost}]:${targetPort}`;
|
|
52
|
+
}
|
|
53
|
+
if (targetHost.includes("[") || targetHost.includes("]")) {
|
|
54
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host has invalid brackets.");
|
|
55
|
+
}
|
|
56
|
+
if (targetHost.includes(":") || INVALID_CONNECT_HOST_DELIMITER_PATTERN.test(targetHost)) {
|
|
57
|
+
throw new ProxylineError("INVALID_CONNECT_TARGET", "CONNECT target host is not a host name.");
|
|
58
|
+
}
|
|
59
|
+
return `${targetHost}:${targetPort}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function connectToProxy(proxy: URL, proxyTls: ProxylineTlsOptions | undefined): ProxySocket {
|
|
63
|
+
const host = resolveProxyHost(proxy);
|
|
64
|
+
const connectOptions = {
|
|
65
|
+
host,
|
|
66
|
+
port: resolveProxyPort(proxy),
|
|
67
|
+
};
|
|
68
|
+
if (proxy.protocol === "https:") {
|
|
69
|
+
const ca = resolveProxyTlsCa(proxyTls);
|
|
70
|
+
const servername = net.isIP(host) === 0 ? host : undefined;
|
|
71
|
+
return tls.connect({
|
|
72
|
+
...connectOptions,
|
|
73
|
+
ALPNProtocols: ["http/1.1"],
|
|
74
|
+
...(servername !== undefined ? { servername } : {}),
|
|
75
|
+
...(ca !== undefined ? { ca } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (proxy.protocol === "http:") {
|
|
79
|
+
return net.connect(connectOptions);
|
|
80
|
+
}
|
|
81
|
+
throw new ProxylineError(
|
|
82
|
+
"UNSUPPORTED_PROXY_PROTOCOL",
|
|
83
|
+
`CONNECT tunnels support http:// and https:// proxy endpoints: ${proxy.protocol}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertSupportedConnectProxyProtocol(proxy: URL): void {
|
|
88
|
+
if (proxy.protocol !== "http:" && proxy.protocol !== "https:") {
|
|
89
|
+
throw new ProxylineError(
|
|
90
|
+
"UNSUPPORTED_PROXY_PROTOCOL",
|
|
91
|
+
`CONNECT tunnels support http:// and https:// proxy endpoints: ${proxy.protocol}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeConnectRequest(socket: net.Socket, proxy: URL, target: string): void {
|
|
97
|
+
const headers = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`, "Proxy-Connection: Keep-Alive"];
|
|
98
|
+
const authorization = resolveProxyAuthorization(proxy);
|
|
99
|
+
if (authorization !== undefined) {
|
|
100
|
+
headers.push(`Proxy-Authorization: ${authorization}`);
|
|
101
|
+
}
|
|
102
|
+
socket.write([...headers, "", ""].join("\r\n"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function failConnect(proxy: URL, error: unknown): Error {
|
|
106
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
107
|
+
return new ProxylineError("CONNECT_FAILED", `Proxy CONNECT failed via ${redactProxyUrl(proxy)}: ${message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function openProxyConnectTunnel(
|
|
111
|
+
options: OpenProxyConnectTunnelOptions,
|
|
112
|
+
): Promise<ProxySocket> {
|
|
113
|
+
const proxy = options.proxyUrl instanceof URL ? new URL(options.proxyUrl.href) : new URL(options.proxyUrl);
|
|
114
|
+
assertSupportedConnectProxyProtocol(proxy);
|
|
115
|
+
const target = formatConnectAuthority(options.targetHost, options.targetPort);
|
|
116
|
+
|
|
117
|
+
return await new Promise<ProxySocket>((resolve, reject) => {
|
|
118
|
+
let settled = false;
|
|
119
|
+
let responseBuffer = Buffer.alloc(0);
|
|
120
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
121
|
+
let socket: ProxySocket | undefined;
|
|
122
|
+
|
|
123
|
+
const cleanup = (): void => {
|
|
124
|
+
if (timeout !== undefined) {
|
|
125
|
+
clearTimeout(timeout);
|
|
126
|
+
timeout = undefined;
|
|
127
|
+
}
|
|
128
|
+
socket?.off("data", onData);
|
|
129
|
+
socket?.off("error", onError);
|
|
130
|
+
socket?.off("end", onClosed);
|
|
131
|
+
socket?.off("close", onClosed);
|
|
132
|
+
socket?.off("connect", onConnected);
|
|
133
|
+
socket?.off("secureConnect", onConnected);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const fail = (error: unknown): void => {
|
|
137
|
+
if (settled) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
settled = true;
|
|
141
|
+
cleanup();
|
|
142
|
+
socket?.destroy();
|
|
143
|
+
reject(failConnect(proxy, error));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const succeed = (connectedSocket: ProxySocket, tunneledBytes: Buffer | undefined): void => {
|
|
147
|
+
if (settled) {
|
|
148
|
+
connectedSocket.destroy();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
settled = true;
|
|
152
|
+
cleanup();
|
|
153
|
+
if (tunneledBytes !== undefined && tunneledBytes.length > 0) {
|
|
154
|
+
connectedSocket.unshift(tunneledBytes);
|
|
155
|
+
}
|
|
156
|
+
resolve(connectedSocket);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const onConnected = (): void => {
|
|
160
|
+
if (socket === undefined) {
|
|
161
|
+
fail(new Error("proxy socket missing after connect"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
writeConnectRequest(socket, proxy, target);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const onData = (chunk: Buffer): void => {
|
|
168
|
+
responseBuffer = Buffer.concat([responseBuffer, chunk]);
|
|
169
|
+
const headerEnd = responseBuffer.indexOf("\r\n\r\n");
|
|
170
|
+
if (headerEnd === -1) {
|
|
171
|
+
if (responseBuffer.length > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
|
|
172
|
+
fail(new Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`));
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const bodyOffset = headerEnd + 4;
|
|
178
|
+
if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
|
|
179
|
+
fail(new Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const responseHeader = responseBuffer.subarray(0, bodyOffset).toString("latin1");
|
|
183
|
+
const statusLine = responseHeader.split("\r\n", 1)[0] ?? "";
|
|
184
|
+
if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
|
|
185
|
+
fail(new Error(statusLine || "proxy returned an invalid CONNECT response"));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (socket === undefined) {
|
|
190
|
+
fail(new Error("proxy socket missing after CONNECT response"));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const tunneledBytes =
|
|
194
|
+
responseBuffer.length > bodyOffset ? responseBuffer.subarray(bodyOffset) : undefined;
|
|
195
|
+
succeed(socket, tunneledBytes);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const onError = (error: Error): void => {
|
|
199
|
+
fail(error);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const onClosed = (): void => {
|
|
203
|
+
fail(new Error("proxy socket closed before CONNECT response"));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
208
|
+
timeout = setTimeout(() => {
|
|
209
|
+
fail(new Error(`proxy CONNECT timed out after ${Math.trunc(options.timeoutMs ?? 0)}ms`));
|
|
210
|
+
}, Math.trunc(options.timeoutMs));
|
|
211
|
+
}
|
|
212
|
+
socket = connectToProxy(proxy, options.proxyTls);
|
|
213
|
+
socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
|
214
|
+
socket.on("data", onData);
|
|
215
|
+
socket.once("error", onError);
|
|
216
|
+
socket.once("end", onClosed);
|
|
217
|
+
socket.once("close", onClosed);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
fail(error);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Dispatcher } from "undici";
|
|
2
|
+
|
|
3
|
+
export const PROXYLINE_DISPATCHER_BRAND = Symbol.for("@openclaw/proxyline.dispatcher");
|
|
4
|
+
|
|
5
|
+
type ProxylineDispatcher = Dispatcher & {
|
|
6
|
+
[PROXYLINE_DISPATCHER_BRAND]?: true;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function isProxylineDispatcher(dispatcher: unknown): boolean {
|
|
10
|
+
return typeof dispatcher === "object" &&
|
|
11
|
+
dispatcher !== null &&
|
|
12
|
+
(dispatcher as ProxylineDispatcher)[PROXYLINE_DISPATCHER_BRAND] === true;
|
|
13
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { formatUrl, redactProxyUrl } from "./shared.js";
|
|
2
|
+
import type { ProxyResolver } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type ProxyEnvKey =
|
|
5
|
+
| "HTTP_PROXY"
|
|
6
|
+
| "HTTPS_PROXY"
|
|
7
|
+
| "ALL_PROXY"
|
|
8
|
+
| "NO_PROXY"
|
|
9
|
+
| "http_proxy"
|
|
10
|
+
| "https_proxy"
|
|
11
|
+
| "all_proxy"
|
|
12
|
+
| "no_proxy";
|
|
13
|
+
|
|
14
|
+
type LowerProxyEnvKey = "http_proxy" | "https_proxy" | "all_proxy" | "no_proxy";
|
|
15
|
+
|
|
16
|
+
export type ProxyEnvSnapshot = Readonly<Record<ProxyEnvKey, string | undefined>>;
|
|
17
|
+
|
|
18
|
+
export const EMPTY_PROXY_ENV: ProxyEnvSnapshot = {
|
|
19
|
+
HTTP_PROXY: undefined,
|
|
20
|
+
HTTPS_PROXY: undefined,
|
|
21
|
+
ALL_PROXY: undefined,
|
|
22
|
+
NO_PROXY: undefined,
|
|
23
|
+
http_proxy: undefined,
|
|
24
|
+
https_proxy: undefined,
|
|
25
|
+
all_proxy: undefined,
|
|
26
|
+
no_proxy: undefined,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function readProxyEnv(): ProxyEnvSnapshot {
|
|
30
|
+
return {
|
|
31
|
+
HTTP_PROXY: process.env.HTTP_PROXY,
|
|
32
|
+
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
|
33
|
+
ALL_PROXY: process.env.ALL_PROXY,
|
|
34
|
+
NO_PROXY: process.env.NO_PROXY,
|
|
35
|
+
http_proxy: process.env.http_proxy,
|
|
36
|
+
https_proxy: process.env.https_proxy,
|
|
37
|
+
all_proxy: process.env.all_proxy,
|
|
38
|
+
no_proxy: process.env.no_proxy,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeEnvValue(value: string | undefined): string | undefined {
|
|
43
|
+
const trimmed = value?.trim();
|
|
44
|
+
return trimmed ? trimmed : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function upperProxyEnvKey(key: LowerProxyEnvKey): ProxyEnvKey {
|
|
48
|
+
switch (key) {
|
|
49
|
+
case "http_proxy":
|
|
50
|
+
return "HTTP_PROXY";
|
|
51
|
+
case "https_proxy":
|
|
52
|
+
return "HTTPS_PROXY";
|
|
53
|
+
case "all_proxy":
|
|
54
|
+
return "ALL_PROXY";
|
|
55
|
+
case "no_proxy":
|
|
56
|
+
return "NO_PROXY";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readProxyEnvValue(
|
|
61
|
+
env: ProxyEnvSnapshot,
|
|
62
|
+
key: LowerProxyEnvKey,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
return normalizeEnvValue(env[key]) ?? normalizeEnvValue(env[upperProxyEnvKey(key)]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function proxyUrlWithDefaultScheme(proxyUrl: string): string {
|
|
68
|
+
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeAmbientProxyUrl(proxyUrl: string | undefined): string | undefined {
|
|
72
|
+
if (proxyUrl === undefined) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(proxyUrlWithDefaultScheme(proxyUrl));
|
|
77
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function defaultPort(protocol: string): number {
|
|
84
|
+
if (protocol === "http:" || protocol === "ws:") {
|
|
85
|
+
return 80;
|
|
86
|
+
}
|
|
87
|
+
if (protocol === "https:" || protocol === "wss:") {
|
|
88
|
+
return 443;
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matchesNoProxy(url: URL, env: ProxyEnvSnapshot): boolean {
|
|
94
|
+
const rawNoProxy = readProxyEnvValue(env, "no_proxy")?.toLowerCase();
|
|
95
|
+
if (!rawNoProxy) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (rawNoProxy === "*") {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const hostname = normalizeNoProxyHost(url.hostname);
|
|
103
|
+
const port = Number.parseInt(url.port, 10) || defaultPort(url.protocol);
|
|
104
|
+
for (const rawEntry of rawNoProxy.split(/[,\s]/)) {
|
|
105
|
+
if (!rawEntry) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const { host: parsedHost, port: entryPort } = parseNoProxyEntry(rawEntry);
|
|
109
|
+
let entryHost = normalizeNoProxyHost(parsedHost);
|
|
110
|
+
if (entryPort && entryPort !== port) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!/^[.*]/.test(entryHost)) {
|
|
115
|
+
if (hostname === entryHost) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (entryHost.startsWith("*")) {
|
|
121
|
+
entryHost = entryHost.slice(1);
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
entryHost.startsWith(".") &&
|
|
125
|
+
(hostname === entryHost.slice(1) || hostname.endsWith(entryHost))
|
|
126
|
+
) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (!entryHost.startsWith(".") && hostname.endsWith(entryHost)) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeNoProxyHost(hostname: string): string {
|
|
137
|
+
const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
|
|
138
|
+
return normalized.startsWith("[") && normalized.endsWith("]")
|
|
139
|
+
? normalized.slice(1, -1)
|
|
140
|
+
: normalized;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseNoProxyEntry(entry: string): { host: string; port: number } {
|
|
144
|
+
const bracketedIpv6 = entry.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
145
|
+
if (bracketedIpv6) {
|
|
146
|
+
return {
|
|
147
|
+
host: bracketedIpv6[1] ?? "",
|
|
148
|
+
port: bracketedIpv6[2] ? Number.parseInt(bracketedIpv6[2], 10) : 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const lastColon = entry.lastIndexOf(":");
|
|
153
|
+
const hasSingleColon = lastColon !== -1 && entry.indexOf(":") === lastColon;
|
|
154
|
+
if (hasSingleColon) {
|
|
155
|
+
const possiblePort = entry.slice(lastColon + 1);
|
|
156
|
+
if (/^\d+$/.test(possiblePort)) {
|
|
157
|
+
return {
|
|
158
|
+
host: entry.slice(0, lastColon),
|
|
159
|
+
port: Number.parseInt(possiblePort, 10),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { host: entry, port: 0 };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function proxyEnvKeyForProtocol(protocol: string): LowerProxyEnvKey | undefined {
|
|
168
|
+
if (protocol === "http:" || protocol === "ws:") {
|
|
169
|
+
return "http_proxy";
|
|
170
|
+
}
|
|
171
|
+
if (protocol === "https:" || protocol === "wss:") {
|
|
172
|
+
return "https_proxy";
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function supportsProxyForUrlProtocol(protocol: string): boolean {
|
|
178
|
+
return protocol === "http:" || protocol === "https:" || protocol === "ws:" || protocol === "wss:";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveAmbientProxyEnvValue(
|
|
182
|
+
env: ProxyEnvSnapshot,
|
|
183
|
+
key: LowerProxyEnvKey,
|
|
184
|
+
): string | undefined {
|
|
185
|
+
return normalizeAmbientProxyUrl(readProxyEnvValue(env, key));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function resolveAmbientProxyForUrl(
|
|
189
|
+
url: string | URL,
|
|
190
|
+
env: ProxyEnvSnapshot,
|
|
191
|
+
): string | undefined {
|
|
192
|
+
let parsedUrl: URL;
|
|
193
|
+
try {
|
|
194
|
+
parsedUrl = url instanceof URL ? new URL(url.href) : new URL(url);
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
const protocol = parsedUrl.protocol;
|
|
199
|
+
if (!supportsProxyForUrlProtocol(protocol)) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
if (matchesNoProxy(parsedUrl, env)) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
const protocolProxyKey = proxyEnvKeyForProtocol(protocol);
|
|
206
|
+
if (protocolProxyKey === undefined) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return (
|
|
210
|
+
resolveAmbientProxyEnvValue(env, protocolProxyKey) ??
|
|
211
|
+
resolveAmbientProxyEnvValue(env, "all_proxy")
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createAmbientProxyResolver(env: ProxyEnvSnapshot): ProxyResolver {
|
|
216
|
+
const configuredProxy =
|
|
217
|
+
resolveAmbientProxyEnvValue(env, "http_proxy") ??
|
|
218
|
+
resolveAmbientProxyEnvValue(env, "https_proxy") ??
|
|
219
|
+
resolveAmbientProxyEnvValue(env, "all_proxy");
|
|
220
|
+
return {
|
|
221
|
+
active: configuredProxy !== undefined,
|
|
222
|
+
describeProxy: () =>
|
|
223
|
+
configuredProxy
|
|
224
|
+
? redactProxyUrl(proxyUrlWithDefaultScheme(configuredProxy))
|
|
225
|
+
: undefined,
|
|
226
|
+
explain: (url, surface) => {
|
|
227
|
+
const formattedUrl = formatUrl(url);
|
|
228
|
+
const parsedUrl = new URL(formattedUrl);
|
|
229
|
+
const proxyUrl = resolveAmbientProxyForUrl(formattedUrl, env);
|
|
230
|
+
if (proxyUrl !== undefined) {
|
|
231
|
+
return {
|
|
232
|
+
kind: "proxied",
|
|
233
|
+
reason: "ambient-proxy-active",
|
|
234
|
+
surface,
|
|
235
|
+
url: formattedUrl,
|
|
236
|
+
proxyUrl: redactProxyUrl(proxyUrl),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
kind: "direct",
|
|
241
|
+
reason: supportsProxyForUrlProtocol(parsedUrl.protocol) && matchesNoProxy(parsedUrl, env)
|
|
242
|
+
? "no-proxy-match"
|
|
243
|
+
: "ambient-proxy-not-configured",
|
|
244
|
+
surface,
|
|
245
|
+
url: formattedUrl,
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
getProxyForUrl: (url) => resolveAmbientProxyForUrl(url, env) ?? "",
|
|
249
|
+
};
|
|
250
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { openProxyConnectTunnel, type OpenProxyConnectTunnelOptions } from "./connect.js";
|
|
2
|
+
export {
|
|
3
|
+
createAmbientNodeProxyAgent,
|
|
4
|
+
hasAmbientNodeProxyConfigured,
|
|
5
|
+
type AmbientNodeProxyAgentOptions,
|
|
6
|
+
} from "./node-http.js";
|
|
7
|
+
export { installGlobalProxy, installProxyline } from "./runtime.js";
|
|
8
|
+
export { isProxylineDispatcher, PROXYLINE_DISPATCHER_BRAND } from "./dispatcher-brand.js";
|
|
9
|
+
export {
|
|
10
|
+
ProxylineError,
|
|
11
|
+
redactProxyUrl,
|
|
12
|
+
resolveProxyTlsCa,
|
|
13
|
+
type ProxylineTlsOptions,
|
|
14
|
+
} from "./shared.js";
|
|
15
|
+
export type {
|
|
16
|
+
ExplainOptions,
|
|
17
|
+
ProxylineBypassRegistration,
|
|
18
|
+
ProxylineBypassPolicy,
|
|
19
|
+
ProxylineBypassRequest,
|
|
20
|
+
ProxylineDecision,
|
|
21
|
+
ProxylineEvent,
|
|
22
|
+
ProxylineHandle,
|
|
23
|
+
ProxylineMode,
|
|
24
|
+
ProxylineOptions,
|
|
25
|
+
ProxylineSurface,
|
|
26
|
+
ProxylineUndiciOptions,
|
|
27
|
+
} from "./types.js";
|