@openclaw/proxyline 0.3.0 → 0.3.2
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 +10 -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 +50 -20
- 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 +951 -0
- package/src/shared.ts +42 -0
- package/src/types.ts +98 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
## `ProxylineError: MANAGED_PROXY_URL_REQUIRED`
|
|
4
|
+
|
|
5
|
+
You used `mode: "managed"` without `proxyUrl`. Managed mode does not fall back to the environment. Supply a URL or switch to `mode: "ambient"`.
|
|
6
|
+
|
|
7
|
+
## `ProxylineError: UNSUPPORTED_PROXY_PROTOCOL`
|
|
8
|
+
|
|
9
|
+
`proxyUrl` is not `http://` or `https://`. SOCKS and other transports are not supported.
|
|
10
|
+
|
|
11
|
+
## `ProxylineError: RUNTIME_ALREADY_ACTIVE`
|
|
12
|
+
|
|
13
|
+
Two parts of your code called `installProxyline` without an intervening `stop()` or compatible `ifActive` policy. Find the existing handle and reuse it, pass `ifActive: "reuse-compatible"` when the settings should match, or call `stop()` before the second install. Common causes: test setup that re-installs on every test, double-invoked entry points, hot-reload tooling that re-evaluates the entry module.
|
|
14
|
+
|
|
15
|
+
## `ProxylineError: CONNECT_FAILED`
|
|
16
|
+
|
|
17
|
+
Returned by `openProxyConnectTunnel`. Inspect the message for the immediate cause:
|
|
18
|
+
|
|
19
|
+
- `proxy CONNECT timed out after Nms` — bump `timeoutMs` or check connectivity to the proxy.
|
|
20
|
+
- A status line such as `HTTP/1.1 407 Proxy Authentication Required` — include credentials in `proxyUrl`.
|
|
21
|
+
- A status line such as `HTTP/1.1 403 Forbidden` — the proxy refused the destination.
|
|
22
|
+
- `proxy socket closed before CONNECT response` — the proxy disconnected. Often a TLS mismatch; verify `proxyTls.ca`.
|
|
23
|
+
- `proxy CONNECT response headers exceeded 16384 bytes` — defensive cap. Unexpected from a normal proxy.
|
|
24
|
+
|
|
25
|
+
## fetch / undici still goes direct
|
|
26
|
+
|
|
27
|
+
- In ambient mode, a library may be using its own `Dispatcher` and passing it to `fetch` explicitly. Inspect the `dispatcher` option; it overrides the global one outside managed `globalThis.fetch`. Use `proxy.createUndiciDispatcher()` if you need a Proxyline-aware dispatcher.
|
|
28
|
+
- The library was loaded **before** `installProxyline`. Some libraries cache a dispatcher at import time. Install Proxyline first.
|
|
29
|
+
|
|
30
|
+
## Caller agent is being ignored
|
|
31
|
+
|
|
32
|
+
That is the intended managed-mode behavior — caller agents are replaced per request. TLS-relevant options (`ca`, `key`, `cert`, `rejectUnauthorized`, `minVersion`, `maxVersion`, `ciphers`, …) are lifted off the caller's agent so destination TLS still validates. If your option is missing, see [Surfaces — TLS identity preservation](./surfaces.md#tls-identity-preservation) for the full list of preserved keys.
|
|
33
|
+
|
|
34
|
+
## "ECONNREFUSED" against the proxy
|
|
35
|
+
|
|
36
|
+
Verify the proxy is reachable from this process:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
curl -v -x "$HTTPS_PROXY" https://api.example.com
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If `curl` succeeds but Node fails, the most common causes are:
|
|
43
|
+
|
|
44
|
+
- Wrong scheme (`HTTPS_PROXY=proxy.corp:8080` defaults to `http://proxy.corp:8080`).
|
|
45
|
+
- A private CA presented by the proxy that Node does not trust. Use `proxyTls`.
|
|
46
|
+
- Code captured `http.request` before Proxyline installed. Install Proxyline first.
|
|
47
|
+
|
|
48
|
+
## TLS errors against the proxy
|
|
49
|
+
|
|
50
|
+
The proxy presents a certificate Node does not trust. Pass `proxyTls.caFile` (or `proxyTls.ca`) — see [Proxy TLS](./proxy-tls.md). Do not disable TLS verification process-wide.
|
|
51
|
+
|
|
52
|
+
## TLS errors against the destination
|
|
53
|
+
|
|
54
|
+
Proxyline does not modify destination TLS. Use the same `ca` / `cert` / `key` options you would use without a proxy. When passed via an `https.Agent`, Proxyline copies them onto the request automatically.
|
|
55
|
+
|
|
56
|
+
## Tests interfere with each other
|
|
57
|
+
|
|
58
|
+
Each test should call `proxy.stop()` in its teardown. Without that, the next install throws `RUNTIME_ALREADY_ACTIVE` and the rest of the suite fails. The patches and the undici dispatcher are global, so leaks affect every concurrent test as well.
|
|
59
|
+
|
|
60
|
+
## Ambient mode reports `active: false`
|
|
61
|
+
|
|
62
|
+
No supported proxy variables are set. Check `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` (and their lowercase forms), and make sure their values use `http://` or `https://`. `NO_PROXY` alone is not enough to activate the runtime. See [Environment Variables](./environment-variables.md).
|
|
63
|
+
|
|
64
|
+
## `NO_PROXY` is not matching as expected
|
|
65
|
+
|
|
66
|
+
- Suffix matches need a leading `.` or `*`. `corp.example` matches only the exact host; `.corp.example` matches `api.corp.example`.
|
|
67
|
+
- A port suffix (`internal.corp:8443`) restricts the match to that port.
|
|
68
|
+
- IPv6 hosts may be bracketed or bare (`[::1]`, `::1`).
|
|
69
|
+
- Hostnames are lowercased and trailing dots are stripped before comparison.
|
|
70
|
+
|
|
71
|
+
When in doubt, use `proxy.explain(url)` — `reason: "no-proxy-match"` confirms the URL was exempted.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/proxyline",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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";
|