@openclaw/proxyline 0.1.0
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 +15 -0
- package/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/connect.d.ts +14 -0
- package/dist/connect.d.ts.map +1 -0
- package/dist/connect.js +153 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +522 -0
- package/dist/shared.d.ts +11 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +29 -0
- package/package.json +57 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-05-11
|
|
4
|
+
|
|
5
|
+
- Initial public release of `@openclaw/proxyline` for process-global proxy routing in Node.js.
|
|
6
|
+
- Added managed mode for fail-closed proxy policy with required `proxyUrl`, global `node:http`/`node:https` patching, global agent replacement, and undici/fetch routing through `ProxyAgent`.
|
|
7
|
+
- Added ambient mode for `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, lowercase variants, bare proxy endpoints, and `NO_PROXY` exemptions with matching diagnostics.
|
|
8
|
+
- Added proxy-aware helpers for Node agents, WebSocket clients, undici dispatchers, and explicit HTTP CONNECT tunnels.
|
|
9
|
+
- Added scoped proxy TLS trust via `proxyTls.ca` and `proxyTls.caFile`, preserving destination TLS identity while trusting private proxy CAs.
|
|
10
|
+
- Added structured observability with `explain()`, `onEvent`, redacted proxy URLs, install/stop lifecycle events, and per-decision diagnostics.
|
|
11
|
+
- Added runtime cleanup with `proxy.stop()` to restore captured Node HTTP(S) methods, global agents, and the undici global dispatcher.
|
|
12
|
+
- Added credential-safe proxy authorization handling for proxy URLs with userinfo.
|
|
13
|
+
- Added in-process proxy lab coverage for HTTP, HTTPS, CONNECT, WebSocket, undici/fetch, proxy auth, loopback blocking, HTTPS proxies, TLS preservation, and IPv6 `NO_PROXY`.
|
|
14
|
+
- Added full documentation for getting started, modes, surfaces, API reference, environment variables, proxy TLS, observability, security, troubleshooting, and testing.
|
|
15
|
+
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jesse Merhi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Proxyline
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@openclaw/proxyline)
|
|
4
|
+
[](https://nodejs.org/)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Process-global proxy routing for Node.js. One install replaces `node:http`, `node:https`, the undici/fetch global dispatcher, and routes WebSocket and explicit HTTP CONNECT traffic through the same policy.
|
|
8
|
+
|
|
9
|
+
Proxyline exists to make proxy behavior **explicit, observable, and hard to bypass accidentally** — so that "all egress goes through this gateway" is something you encode in code rather than hope for from environment variables.
|
|
10
|
+
|
|
11
|
+
Website: [proxyline.dev](https://proxyline.dev)
|
|
12
|
+
|
|
13
|
+
## Highlights
|
|
14
|
+
|
|
15
|
+
- **Two modes.** `managed` forces traffic through a configured proxy and fails closed on bad config. `ambient` reads `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` for tooling that needs environment compatibility.
|
|
16
|
+
- **Covers the surfaces that matter.** `http.request`, `http.get`, `https.request`, `https.get`, both global agents, the undici global dispatcher, and helpers for WebSocket agents and HTTP CONNECT sockets.
|
|
17
|
+
- **Replaces caller agents.** In managed mode, a per-request `http.Agent` passed by a library does not bypass the proxy. TLS options on the caller agent (`ca`, `cert`, `key`, `rejectUnauthorized`, …) are preserved so destination TLS still validates.
|
|
18
|
+
- **Scoped proxy CA trust.** `proxyTls.ca` / `proxyTls.caFile` trust a private CA for the proxy endpoint only — no `NODE_EXTRA_CA_CERTS` and no `NODE_TLS_REJECT_UNAUTHORIZED=0`.
|
|
19
|
+
- **Observable.** `proxy.explain(url)` returns a structured decision (`proxied` / `direct` with a `reason`), and an `onEvent` callback receives `runtime.installed`, `runtime.stopped`, and per-decision events. Proxy URLs are credential-redacted.
|
|
20
|
+
- **Restoreable.** `proxy.stop()` restores the captured Node HTTP(S) methods, global agents, and undici dispatcher. The runtime is a process-wide singleton — a second install throws `RUNTIME_ALREADY_ACTIVE`.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pnpm add @openclaw/proxyline
|
|
26
|
+
# or
|
|
27
|
+
npm install @openclaw/proxyline
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Node 20+.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
### Managed mode
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
38
|
+
|
|
39
|
+
const proxy = installGlobalProxy({
|
|
40
|
+
mode: "managed",
|
|
41
|
+
proxyUrl: "https://proxy.corp.example:8443",
|
|
42
|
+
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
43
|
+
onEvent: (event) => console.debug("[proxyline]", event),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
console.log(proxy.explain("https://api.example.com/"));
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Ambient mode
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
53
|
+
|
|
54
|
+
const proxy = installGlobalProxy({ mode: "ambient" });
|
|
55
|
+
if (!proxy.active) {
|
|
56
|
+
console.warn("no HTTP_PROXY/HTTPS_PROXY/ALL_PROXY set — egress will be direct");
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### WebSocket
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import WebSocket from "ws";
|
|
64
|
+
|
|
65
|
+
const socket = new WebSocket("wss://events.example.com/", {
|
|
66
|
+
agent: proxy.createWebSocketAgent(),
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Explicit HTTP CONNECT
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { openProxyConnectTunnel } from "@openclaw/proxyline";
|
|
74
|
+
|
|
75
|
+
const socket = await openProxyConnectTunnel({
|
|
76
|
+
proxyUrl: "https://proxy.corp.example:8443",
|
|
77
|
+
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
78
|
+
targetHost: "api.example.com",
|
|
79
|
+
targetPort: 443,
|
|
80
|
+
timeoutMs: 2_000,
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Feature matrix
|
|
85
|
+
|
|
86
|
+
| Surface | Covered | Notes |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| `http.request` / `http.get` | yes | global method patch + global agent swap |
|
|
89
|
+
| `https.request` / `https.get` | yes | global method patch + global agent swap |
|
|
90
|
+
| `fetch` / undici global dispatcher | yes | `setGlobalDispatcher` |
|
|
91
|
+
| WebSocket clients accepting a Node `agent` | yes | `proxy.createWebSocketAgent()` |
|
|
92
|
+
| Caller-built `http.Agent` / `https.Agent` | overridden in managed mode | TLS options preserved |
|
|
93
|
+
| Explicit HTTP CONNECT socket | yes | `openProxyConnectTunnel()` |
|
|
94
|
+
| Raw `net.connect` / `tls.connect` | no | out of scope, see [Security](./docs/security.md) |
|
|
95
|
+
| Native or private transport stacks | no | out of scope, see [Security](./docs/security.md) |
|
|
96
|
+
|
|
97
|
+
## Why not just env vars?
|
|
98
|
+
|
|
99
|
+
Environment-based proxies are best-effort. A missing variable, a stale shell, a `NO_PROXY` typo, or a library that built its own `Dispatcher` quietly turns "always through the proxy" into "sometimes direct." Proxyline encodes the policy in code, replaces caller-built agents, and exposes a structured decision so logs can prove every request went the right way.
|
|
100
|
+
|
|
101
|
+
For tooling that *should* honor whatever the operator configured, ambient mode keeps the conventional behavior — with the same observability and the same credential redaction.
|
|
102
|
+
|
|
103
|
+
## Documentation
|
|
104
|
+
|
|
105
|
+
Full docs live in [`docs/`](./docs/README.md):
|
|
106
|
+
|
|
107
|
+
- [Getting Started](./docs/getting-started.md)
|
|
108
|
+
- [Modes](./docs/modes.md) — managed vs ambient
|
|
109
|
+
- [Surfaces](./docs/surfaces.md) — per-API behavior
|
|
110
|
+
- [API Reference](./docs/api-reference.md)
|
|
111
|
+
- [Environment Variables](./docs/environment-variables.md)
|
|
112
|
+
- [Proxy TLS](./docs/proxy-tls.md)
|
|
113
|
+
- [Observability](./docs/observability.md)
|
|
114
|
+
- [Security](./docs/security.md)
|
|
115
|
+
- [Troubleshooting](./docs/troubleshooting.md)
|
|
116
|
+
- [Testing](./docs/testing.md)
|
|
117
|
+
|
|
118
|
+
## Limits
|
|
119
|
+
|
|
120
|
+
Proxyline is a Node-process runtime, not an operating-system sandbox. Code can still bypass it by using raw `net`, raw `tls`, custom native networking, or a library that owns a private transport stack. Anything that captured `http.request` or `https.request` before Proxyline installed also bypasses it — install before loading third-party integrations when proxy routing is a security policy. See [`docs/security.md`](./docs/security.md) for the full threat model.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
import { type ProxylineTlsOptions } from "./shared.js";
|
|
4
|
+
export type OpenProxyConnectTunnelOptions = Readonly<{
|
|
5
|
+
proxyUrl: string | URL;
|
|
6
|
+
proxyTls?: ProxylineTlsOptions;
|
|
7
|
+
targetHost: string;
|
|
8
|
+
targetPort: number;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}>;
|
|
11
|
+
type ProxySocket = net.Socket | tls.TLSSocket;
|
|
12
|
+
export declare function openProxyConnectTunnel(options: OpenProxyConnectTunnelOptions): Promise<ProxySocket>;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=connect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../src/connect.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,EAAkB,KAAK,mBAAmB,EAAqC,MAAM,aAAa,CAAC;AAE1G,MAAM,MAAM,6BAA6B,GAAG,QAAQ,CAAC;IACnD,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC;IACvB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC,CAAC;AAIH,KAAK,WAAW,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,SAAS,CAAC;AA6D9C,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,6BAA6B,GACrC,OAAO,CAAC,WAAW,CAAC,CA6GtB"}
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
import { ProxylineError, redactProxyUrl, resolveProxyTlsCa } from "./shared.js";
|
|
4
|
+
const MAX_CONNECT_RESPONSE_HEADER_BYTES = 16 * 1024;
|
|
5
|
+
function resolveProxyHost(proxy) {
|
|
6
|
+
return (proxy.hostname || proxy.host).replace(/^\[|\]$/g, "");
|
|
7
|
+
}
|
|
8
|
+
function resolveProxyPort(proxy) {
|
|
9
|
+
if (proxy.port) {
|
|
10
|
+
return Number(proxy.port);
|
|
11
|
+
}
|
|
12
|
+
return proxy.protocol === "https:" ? 443 : 80;
|
|
13
|
+
}
|
|
14
|
+
function resolveProxyAuthorization(proxy) {
|
|
15
|
+
if (!proxy.username && !proxy.password) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const username = decodeURIComponent(proxy.username);
|
|
19
|
+
const password = decodeURIComponent(proxy.password);
|
|
20
|
+
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
|
21
|
+
}
|
|
22
|
+
function connectToProxy(proxy, proxyTls) {
|
|
23
|
+
const host = resolveProxyHost(proxy);
|
|
24
|
+
const connectOptions = {
|
|
25
|
+
host,
|
|
26
|
+
port: resolveProxyPort(proxy),
|
|
27
|
+
};
|
|
28
|
+
if (proxy.protocol === "https:") {
|
|
29
|
+
const ca = resolveProxyTlsCa(proxyTls);
|
|
30
|
+
const servername = net.isIP(host) === 0 ? host : undefined;
|
|
31
|
+
return tls.connect({
|
|
32
|
+
...connectOptions,
|
|
33
|
+
ALPNProtocols: ["http/1.1"],
|
|
34
|
+
...(servername !== undefined ? { servername } : {}),
|
|
35
|
+
...(ca !== undefined ? { ca } : {}),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (proxy.protocol === "http:") {
|
|
39
|
+
return net.connect(connectOptions);
|
|
40
|
+
}
|
|
41
|
+
throw new ProxylineError("UNSUPPORTED_PROXY_PROTOCOL", `CONNECT tunnels support http:// and https:// proxy endpoints: ${proxy.protocol}`);
|
|
42
|
+
}
|
|
43
|
+
function writeConnectRequest(socket, proxy, target) {
|
|
44
|
+
const headers = [`CONNECT ${target} HTTP/1.1`, `Host: ${target}`, "Proxy-Connection: Keep-Alive"];
|
|
45
|
+
const authorization = resolveProxyAuthorization(proxy);
|
|
46
|
+
if (authorization !== undefined) {
|
|
47
|
+
headers.push(`Proxy-Authorization: ${authorization}`);
|
|
48
|
+
}
|
|
49
|
+
socket.write([...headers, "", ""].join("\r\n"));
|
|
50
|
+
}
|
|
51
|
+
function failConnect(proxy, error) {
|
|
52
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
+
return new ProxylineError("CONNECT_FAILED", `Proxy CONNECT failed via ${redactProxyUrl(proxy)}: ${message}`);
|
|
54
|
+
}
|
|
55
|
+
export async function openProxyConnectTunnel(options) {
|
|
56
|
+
const proxy = options.proxyUrl instanceof URL ? new URL(options.proxyUrl.href) : new URL(options.proxyUrl);
|
|
57
|
+
const target = `${options.targetHost}:${options.targetPort}`;
|
|
58
|
+
return await new Promise((resolve, reject) => {
|
|
59
|
+
let settled = false;
|
|
60
|
+
let responseBuffer = Buffer.alloc(0);
|
|
61
|
+
let timeout;
|
|
62
|
+
let socket;
|
|
63
|
+
const cleanup = () => {
|
|
64
|
+
if (timeout !== undefined) {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
timeout = undefined;
|
|
67
|
+
}
|
|
68
|
+
socket?.off("data", onData);
|
|
69
|
+
socket?.off("error", onError);
|
|
70
|
+
socket?.off("end", onClosed);
|
|
71
|
+
socket?.off("close", onClosed);
|
|
72
|
+
socket?.off("connect", onConnected);
|
|
73
|
+
socket?.off("secureConnect", onConnected);
|
|
74
|
+
};
|
|
75
|
+
const fail = (error) => {
|
|
76
|
+
if (settled) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
settled = true;
|
|
80
|
+
cleanup();
|
|
81
|
+
socket?.destroy();
|
|
82
|
+
reject(failConnect(proxy, error));
|
|
83
|
+
};
|
|
84
|
+
const succeed = (connectedSocket, tunneledBytes) => {
|
|
85
|
+
if (settled) {
|
|
86
|
+
connectedSocket.destroy();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
settled = true;
|
|
90
|
+
cleanup();
|
|
91
|
+
if (tunneledBytes !== undefined && tunneledBytes.length > 0) {
|
|
92
|
+
connectedSocket.unshift(tunneledBytes);
|
|
93
|
+
}
|
|
94
|
+
resolve(connectedSocket);
|
|
95
|
+
};
|
|
96
|
+
const onConnected = () => {
|
|
97
|
+
if (socket === undefined) {
|
|
98
|
+
fail(new Error("proxy socket missing after connect"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
writeConnectRequest(socket, proxy, target);
|
|
102
|
+
};
|
|
103
|
+
const onData = (chunk) => {
|
|
104
|
+
responseBuffer = Buffer.concat([responseBuffer, chunk]);
|
|
105
|
+
const headerEnd = responseBuffer.indexOf("\r\n\r\n");
|
|
106
|
+
if (headerEnd === -1) {
|
|
107
|
+
if (responseBuffer.length > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
|
|
108
|
+
fail(new Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`));
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const bodyOffset = headerEnd + 4;
|
|
113
|
+
if (bodyOffset > MAX_CONNECT_RESPONSE_HEADER_BYTES) {
|
|
114
|
+
fail(new Error(`proxy CONNECT response headers exceeded ${MAX_CONNECT_RESPONSE_HEADER_BYTES} bytes`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const responseHeader = responseBuffer.subarray(0, bodyOffset).toString("latin1");
|
|
118
|
+
const statusLine = responseHeader.split("\r\n", 1)[0] ?? "";
|
|
119
|
+
if (!/^HTTP\/1\.[01] 2\d\d\b/.test(statusLine)) {
|
|
120
|
+
fail(new Error(statusLine || "proxy returned an invalid CONNECT response"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (socket === undefined) {
|
|
124
|
+
fail(new Error("proxy socket missing after CONNECT response"));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const tunneledBytes = responseBuffer.length > bodyOffset ? responseBuffer.subarray(bodyOffset) : undefined;
|
|
128
|
+
succeed(socket, tunneledBytes);
|
|
129
|
+
};
|
|
130
|
+
const onError = (error) => {
|
|
131
|
+
fail(error);
|
|
132
|
+
};
|
|
133
|
+
const onClosed = () => {
|
|
134
|
+
fail(new Error("proxy socket closed before CONNECT response"));
|
|
135
|
+
};
|
|
136
|
+
try {
|
|
137
|
+
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
138
|
+
timeout = setTimeout(() => {
|
|
139
|
+
fail(new Error(`proxy CONNECT timed out after ${Math.trunc(options.timeoutMs ?? 0)}ms`));
|
|
140
|
+
}, Math.trunc(options.timeoutMs));
|
|
141
|
+
}
|
|
142
|
+
socket = connectToProxy(proxy, options.proxyTls);
|
|
143
|
+
socket.once(proxy.protocol === "https:" ? "secureConnect" : "connect", onConnected);
|
|
144
|
+
socket.on("data", onData);
|
|
145
|
+
socket.once("error", onError);
|
|
146
|
+
socket.once("end", onClosed);
|
|
147
|
+
socket.once("close", onClosed);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
fail(error);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { type Dispatcher } from "undici";
|
|
3
|
+
import { type ProxylineTlsOptions } from "./shared.js";
|
|
4
|
+
export { ProxylineError, redactProxyUrl, resolveProxyTlsCa, type ProxylineTlsOptions, } from "./shared.js";
|
|
5
|
+
export { openProxyConnectTunnel, type OpenProxyConnectTunnelOptions } from "./connect.js";
|
|
6
|
+
export type ProxylineMode = "managed" | "ambient";
|
|
7
|
+
export type ProxylineSurface = "node-http" | "node-https" | "undici" | "websocket" | "connect" | "unknown";
|
|
8
|
+
export type ProxylineOptions = Readonly<{
|
|
9
|
+
mode: ProxylineMode;
|
|
10
|
+
proxyUrl?: string | URL;
|
|
11
|
+
proxyTls?: ProxylineTlsOptions;
|
|
12
|
+
onEvent?: (event: ProxylineEvent) => void;
|
|
13
|
+
}>;
|
|
14
|
+
export type ProxylineDecision = Readonly<{
|
|
15
|
+
kind: "proxied" | "direct" | "blocked";
|
|
16
|
+
reason: string;
|
|
17
|
+
surface: ProxylineSurface;
|
|
18
|
+
url: string;
|
|
19
|
+
proxyUrl?: string;
|
|
20
|
+
}>;
|
|
21
|
+
export type ProxylineEvent = Readonly<{
|
|
22
|
+
type: "runtime.installed";
|
|
23
|
+
mode: ProxylineMode;
|
|
24
|
+
active: boolean;
|
|
25
|
+
proxyUrl?: string;
|
|
26
|
+
}> | Readonly<{
|
|
27
|
+
type: "runtime.stopped";
|
|
28
|
+
mode: ProxylineMode;
|
|
29
|
+
}> | Readonly<{
|
|
30
|
+
type: "decision";
|
|
31
|
+
decision: ProxylineDecision;
|
|
32
|
+
}> | Readonly<{
|
|
33
|
+
type: "warning";
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
}>;
|
|
37
|
+
export type ExplainOptions = Readonly<{
|
|
38
|
+
surface?: ProxylineSurface;
|
|
39
|
+
}>;
|
|
40
|
+
export type ProxylineHandle = Readonly<{
|
|
41
|
+
mode: ProxylineMode;
|
|
42
|
+
active: boolean;
|
|
43
|
+
proxyUrl?: string;
|
|
44
|
+
createNodeAgent: () => http.Agent;
|
|
45
|
+
createUndiciDispatcher: () => Dispatcher;
|
|
46
|
+
createWebSocketAgent: () => http.Agent;
|
|
47
|
+
explain: (url: string | URL, options?: ExplainOptions) => ProxylineDecision;
|
|
48
|
+
stop: () => void;
|
|
49
|
+
}>;
|
|
50
|
+
export declare function installProxyline(options: ProxylineOptions): ProxylineHandle;
|
|
51
|
+
export declare const installGlobalProxy: typeof installProxyline;
|
|
52
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAI7B,OAAO,EAEL,KAAK,UAAU,EAKhB,MAAM,QAAQ,CAAC;AAChB,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,KAAK,mBAAmB,GACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,sBAAsB,EAAE,KAAK,6BAA6B,EAAE,MAAM,cAAc,CAAC;AAE1F,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,SAAS,CAAC;AAElD,MAAM,MAAM,gBAAgB,GACxB,WAAW,GACX,YAAY,GACZ,QAAQ,GACR,WAAW,GACX,SAAS,GACT,SAAS,CAAC;AAEd,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,IAAI,EAAE,aAAa,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;IACxB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC3C,CAAC,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IACvC,IAAI,EAAE,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,gBAAgB,CAAC;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC,CAAC;AAEH,MAAM,MAAM,cAAc,GACtB,QAAQ,CAAC;IACP,IAAI,EAAE,mBAAmB,CAAC;IAC1B,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC,GACF,QAAQ,CAAC;IACP,IAAI,EAAE,iBAAiB,CAAC;IACxB,IAAI,EAAE,aAAa,CAAC;CACrB,CAAC,GACF,QAAQ,CAAC;IACP,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,iBAAiB,CAAC;CAC7B,CAAC,GACF,QAAQ,CAAC;IACP,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC,CAAC;AAEP,MAAM,MAAM,cAAc,GAAG,QAAQ,CAAC;IACpC,OAAO,CAAC,EAAE,gBAAgB,CAAC;CAC5B,CAAC,CAAC;AAEH,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC;IACrC,IAAI,EAAE,aAAa,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,IAAI,CAAC,KAAK,CAAC;IAClC,sBAAsB,EAAE,MAAM,UAAU,CAAC;IACzC,oBAAoB,EAAE,MAAM,IAAI,CAAC,KAAK,CAAC;IACvC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,OAAO,CAAC,EAAE,cAAc,KAAK,iBAAiB,CAAC;IAC5E,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC,CAAC;AAsjBH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,gBAAgB,GAAG,eAAe,CAmF3E;AAED,eAAO,MAAM,kBAAkB,yBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { ProxyAgent as NodeProxyAgent } from "proxy-agent";
|
|
5
|
+
import { Agent as UndiciAgent, EnvHttpProxyAgent, getGlobalDispatcher, ProxyAgent as UndiciProxyAgent, setGlobalDispatcher, } from "undici";
|
|
6
|
+
import { ProxylineError, redactProxyUrl, resolveProxyTlsCa, } from "./shared.js";
|
|
7
|
+
export { ProxylineError, redactProxyUrl, resolveProxyTlsCa, } from "./shared.js";
|
|
8
|
+
export { openProxyConnectTunnel } from "./connect.js";
|
|
9
|
+
function normalizeProxyUrl(value) {
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
const url = value instanceof URL ? new URL(value.href) : new URL(value);
|
|
14
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
15
|
+
throw new ProxylineError("UNSUPPORTED_PROXY_PROTOCOL", `Proxyline only supports http:// and https:// proxy endpoints in this slice: ${url.protocol}`);
|
|
16
|
+
}
|
|
17
|
+
return url;
|
|
18
|
+
}
|
|
19
|
+
function emit(onEvent, event) {
|
|
20
|
+
onEvent?.(event);
|
|
21
|
+
}
|
|
22
|
+
function formatUrl(value) {
|
|
23
|
+
return value instanceof URL ? value.href : new URL(value).href;
|
|
24
|
+
}
|
|
25
|
+
const CALLER_AGENT_TLS_OPTION_KEYS = [
|
|
26
|
+
"ca",
|
|
27
|
+
"cert",
|
|
28
|
+
"ciphers",
|
|
29
|
+
"clientCertEngine",
|
|
30
|
+
"crl",
|
|
31
|
+
"dhparam",
|
|
32
|
+
"ecdhCurve",
|
|
33
|
+
"honorCipherOrder",
|
|
34
|
+
"key",
|
|
35
|
+
"maxVersion",
|
|
36
|
+
"minVersion",
|
|
37
|
+
"passphrase",
|
|
38
|
+
"pfx",
|
|
39
|
+
"rejectUnauthorized",
|
|
40
|
+
"secureOptions",
|
|
41
|
+
"secureProtocol",
|
|
42
|
+
"sessionIdContext",
|
|
43
|
+
];
|
|
44
|
+
let activeRuntime;
|
|
45
|
+
const EMPTY_PROXY_ENV = {
|
|
46
|
+
HTTP_PROXY: undefined,
|
|
47
|
+
HTTPS_PROXY: undefined,
|
|
48
|
+
ALL_PROXY: undefined,
|
|
49
|
+
NO_PROXY: undefined,
|
|
50
|
+
http_proxy: undefined,
|
|
51
|
+
https_proxy: undefined,
|
|
52
|
+
all_proxy: undefined,
|
|
53
|
+
no_proxy: undefined,
|
|
54
|
+
};
|
|
55
|
+
function copyNodeHttpOptions(value) {
|
|
56
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
return { ...value };
|
|
60
|
+
}
|
|
61
|
+
function readAgentOptions(agent) {
|
|
62
|
+
if (agent === undefined || agent === false) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return agent.options;
|
|
66
|
+
}
|
|
67
|
+
function preserveCallerAgentOptions(options) {
|
|
68
|
+
const agentOptions = readAgentOptions(options.agent);
|
|
69
|
+
if (agentOptions === undefined) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const key of CALLER_AGENT_TLS_OPTION_KEYS) {
|
|
73
|
+
const value = agentOptions[key];
|
|
74
|
+
if (value !== undefined && options[key] === undefined) {
|
|
75
|
+
options[key] = value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function inferDestinationHostname(url, options) {
|
|
80
|
+
if (url !== undefined) {
|
|
81
|
+
return url instanceof URL ? url.hostname : new URL(url).hostname;
|
|
82
|
+
}
|
|
83
|
+
if (typeof options.hostname === "string") {
|
|
84
|
+
return options.hostname;
|
|
85
|
+
}
|
|
86
|
+
if (typeof options.host === "string") {
|
|
87
|
+
return options.host.replace(/:\d*$/, "");
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
function preserveDestinationTlsIdentity(url, options) {
|
|
92
|
+
if (options.servername !== undefined) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const hostname = inferDestinationHostname(url, options);
|
|
96
|
+
if (!hostname) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (net.isIP(hostname) === 0) {
|
|
100
|
+
options.servername = hostname;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function bindNodeHttpMethod(originalMethod, createAgent) {
|
|
104
|
+
return ((...args) => {
|
|
105
|
+
let url;
|
|
106
|
+
let options;
|
|
107
|
+
let callback;
|
|
108
|
+
const firstArg = args[0];
|
|
109
|
+
if (typeof firstArg === "string" || firstArg instanceof URL) {
|
|
110
|
+
url = firstArg;
|
|
111
|
+
if (typeof args[1] === "function") {
|
|
112
|
+
options = {};
|
|
113
|
+
callback = args[1];
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
options = copyNodeHttpOptions(args[1]);
|
|
117
|
+
callback = args[2];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
options = copyNodeHttpOptions(firstArg);
|
|
122
|
+
callback = args[1];
|
|
123
|
+
}
|
|
124
|
+
preserveCallerAgentOptions(options);
|
|
125
|
+
preserveDestinationTlsIdentity(url, options);
|
|
126
|
+
const agent = createAgent(options);
|
|
127
|
+
options.agent = agent;
|
|
128
|
+
delete options.createConnection;
|
|
129
|
+
if (url !== undefined) {
|
|
130
|
+
const request = originalMethod(url, options, callback);
|
|
131
|
+
request.once("close", () => {
|
|
132
|
+
agent.destroy();
|
|
133
|
+
});
|
|
134
|
+
return request;
|
|
135
|
+
}
|
|
136
|
+
const request = originalMethod(options, callback);
|
|
137
|
+
request.once("close", () => {
|
|
138
|
+
agent.destroy();
|
|
139
|
+
});
|
|
140
|
+
return request;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function readProxyEnv() {
|
|
144
|
+
return {
|
|
145
|
+
HTTP_PROXY: process.env.HTTP_PROXY,
|
|
146
|
+
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
|
147
|
+
ALL_PROXY: process.env.ALL_PROXY,
|
|
148
|
+
NO_PROXY: process.env.NO_PROXY,
|
|
149
|
+
http_proxy: process.env.http_proxy,
|
|
150
|
+
https_proxy: process.env.https_proxy,
|
|
151
|
+
all_proxy: process.env.all_proxy,
|
|
152
|
+
no_proxy: process.env.no_proxy,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function normalizeEnvValue(value) {
|
|
156
|
+
const trimmed = value?.trim();
|
|
157
|
+
return trimmed ? trimmed : undefined;
|
|
158
|
+
}
|
|
159
|
+
function upperProxyEnvKey(key) {
|
|
160
|
+
switch (key) {
|
|
161
|
+
case "http_proxy":
|
|
162
|
+
return "HTTP_PROXY";
|
|
163
|
+
case "https_proxy":
|
|
164
|
+
return "HTTPS_PROXY";
|
|
165
|
+
case "all_proxy":
|
|
166
|
+
return "ALL_PROXY";
|
|
167
|
+
case "no_proxy":
|
|
168
|
+
return "NO_PROXY";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function readProxyEnvValue(env, key) {
|
|
172
|
+
return normalizeEnvValue(env[key]) ?? normalizeEnvValue(env[upperProxyEnvKey(key)]);
|
|
173
|
+
}
|
|
174
|
+
function proxyUrlWithDefaultScheme(proxyUrl) {
|
|
175
|
+
return proxyUrl.includes("://") ? proxyUrl : `http://${proxyUrl}`;
|
|
176
|
+
}
|
|
177
|
+
function defaultPort(protocol) {
|
|
178
|
+
if (protocol === "http:" || protocol === "ws:") {
|
|
179
|
+
return 80;
|
|
180
|
+
}
|
|
181
|
+
if (protocol === "https:" || protocol === "wss:") {
|
|
182
|
+
return 443;
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
function matchesNoProxy(url, env) {
|
|
187
|
+
const rawNoProxy = readProxyEnvValue(env, "no_proxy")?.toLowerCase();
|
|
188
|
+
if (!rawNoProxy) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (rawNoProxy === "*") {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
const hostname = normalizeNoProxyHost(url.hostname);
|
|
195
|
+
const port = Number.parseInt(url.port, 10) || defaultPort(url.protocol);
|
|
196
|
+
for (const rawEntry of rawNoProxy.split(/[,\s]/)) {
|
|
197
|
+
if (!rawEntry) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const { host: parsedHost, port: entryPort } = parseNoProxyEntry(rawEntry);
|
|
201
|
+
let entryHost = normalizeNoProxyHost(parsedHost);
|
|
202
|
+
if (entryPort && entryPort !== port) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!/^[.*]/.test(entryHost)) {
|
|
206
|
+
if (hostname === entryHost) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (entryHost.startsWith("*")) {
|
|
212
|
+
entryHost = entryHost.slice(1);
|
|
213
|
+
}
|
|
214
|
+
if (hostname.endsWith(entryHost)) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
function normalizeNoProxyHost(hostname) {
|
|
221
|
+
const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
|
|
222
|
+
return normalized.startsWith("[") && normalized.endsWith("]")
|
|
223
|
+
? normalized.slice(1, -1)
|
|
224
|
+
: normalized;
|
|
225
|
+
}
|
|
226
|
+
function parseNoProxyEntry(entry) {
|
|
227
|
+
const bracketedIpv6 = entry.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
228
|
+
if (bracketedIpv6) {
|
|
229
|
+
return {
|
|
230
|
+
host: bracketedIpv6[1] ?? "",
|
|
231
|
+
port: bracketedIpv6[2] ? Number.parseInt(bracketedIpv6[2], 10) : 0,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const lastColon = entry.lastIndexOf(":");
|
|
235
|
+
const hasSingleColon = lastColon !== -1 && entry.indexOf(":") === lastColon;
|
|
236
|
+
if (hasSingleColon) {
|
|
237
|
+
const possiblePort = entry.slice(lastColon + 1);
|
|
238
|
+
if (/^\d+$/.test(possiblePort)) {
|
|
239
|
+
return {
|
|
240
|
+
host: entry.slice(0, lastColon),
|
|
241
|
+
port: Number.parseInt(possiblePort, 10),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { host: entry, port: 0 };
|
|
246
|
+
}
|
|
247
|
+
function formatNoProxyEntryForUndici(entry) {
|
|
248
|
+
const { host, port } = parseNoProxyEntry(entry);
|
|
249
|
+
const normalizedHost = normalizeNoProxyHost(host);
|
|
250
|
+
const formattedHost = normalizedHost.includes(":") ? `[${normalizedHost}]` : host;
|
|
251
|
+
return port ? `${formattedHost}:${port}` : formattedHost;
|
|
252
|
+
}
|
|
253
|
+
function normalizeNoProxyForUndici(noProxy) {
|
|
254
|
+
if (noProxy === undefined) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const entries = noProxy
|
|
258
|
+
.split(/[,\s]/)
|
|
259
|
+
.map((entry) => entry.trim())
|
|
260
|
+
.filter(Boolean)
|
|
261
|
+
.map(formatNoProxyEntryForUndici);
|
|
262
|
+
return entries.length > 0 ? entries.join(",") : undefined;
|
|
263
|
+
}
|
|
264
|
+
function proxyEnvKeyForProtocol(protocol) {
|
|
265
|
+
if (protocol === "http:" || protocol === "ws:") {
|
|
266
|
+
return "http_proxy";
|
|
267
|
+
}
|
|
268
|
+
if (protocol === "https:" || protocol === "wss:") {
|
|
269
|
+
return "https_proxy";
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
function resolveAmbientProxyForUrl(url, env) {
|
|
274
|
+
let parsedUrl;
|
|
275
|
+
try {
|
|
276
|
+
parsedUrl = url instanceof URL ? new URL(url.href) : new URL(url);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const protocol = parsedUrl.protocol;
|
|
282
|
+
if (protocol !== "http:" &&
|
|
283
|
+
protocol !== "https:" &&
|
|
284
|
+
protocol !== "ws:" &&
|
|
285
|
+
protocol !== "wss:") {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
if (matchesNoProxy(parsedUrl, env)) {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
const protocolProxyKey = proxyEnvKeyForProtocol(protocol);
|
|
292
|
+
if (protocolProxyKey === undefined) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
const proxy = readProxyEnvValue(env, protocolProxyKey) ?? readProxyEnvValue(env, "all_proxy");
|
|
296
|
+
return proxy ? proxyUrlWithDefaultScheme(proxy) : undefined;
|
|
297
|
+
}
|
|
298
|
+
function createManagedProxyResolver(proxyUrl) {
|
|
299
|
+
const redactedProxyUrl = redactProxyUrl(proxyUrl);
|
|
300
|
+
return {
|
|
301
|
+
active: true,
|
|
302
|
+
describeProxy: () => redactedProxyUrl,
|
|
303
|
+
explain: (url, surface) => ({
|
|
304
|
+
kind: "proxied",
|
|
305
|
+
reason: "managed-proxy-active",
|
|
306
|
+
surface,
|
|
307
|
+
url: formatUrl(url),
|
|
308
|
+
proxyUrl: redactedProxyUrl,
|
|
309
|
+
}),
|
|
310
|
+
getProxyForUrl: (url) => {
|
|
311
|
+
const protocol = new URL(url).protocol;
|
|
312
|
+
return protocol === "http:" ||
|
|
313
|
+
protocol === "https:" ||
|
|
314
|
+
protocol === "ws:" ||
|
|
315
|
+
protocol === "wss:"
|
|
316
|
+
? proxyUrl.href
|
|
317
|
+
: "";
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function createAmbientProxyResolver(env) {
|
|
322
|
+
const configuredProxy = readProxyEnvValue(env, "http_proxy") ??
|
|
323
|
+
readProxyEnvValue(env, "https_proxy") ??
|
|
324
|
+
readProxyEnvValue(env, "all_proxy");
|
|
325
|
+
return {
|
|
326
|
+
active: configuredProxy !== undefined,
|
|
327
|
+
describeProxy: () => configuredProxy
|
|
328
|
+
? redactProxyUrl(proxyUrlWithDefaultScheme(configuredProxy))
|
|
329
|
+
: undefined,
|
|
330
|
+
explain: (url, surface) => {
|
|
331
|
+
const formattedUrl = formatUrl(url);
|
|
332
|
+
const proxyUrl = resolveAmbientProxyForUrl(formattedUrl, env);
|
|
333
|
+
if (proxyUrl !== undefined) {
|
|
334
|
+
return {
|
|
335
|
+
kind: "proxied",
|
|
336
|
+
reason: "ambient-proxy-active",
|
|
337
|
+
surface,
|
|
338
|
+
url: formattedUrl,
|
|
339
|
+
proxyUrl: redactProxyUrl(proxyUrl),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
kind: "direct",
|
|
344
|
+
reason: matchesNoProxy(new URL(formattedUrl), env)
|
|
345
|
+
? "no-proxy-match"
|
|
346
|
+
: "ambient-proxy-not-configured",
|
|
347
|
+
surface,
|
|
348
|
+
url: formattedUrl,
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
getProxyForUrl: (url) => resolveAmbientProxyForUrl(url, env) ?? "",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function createNodeProxyAgent(resolver, proxyCa, options) {
|
|
355
|
+
return new NodeProxyAgent({
|
|
356
|
+
...(proxyCa !== undefined ? { ca: proxyCa } : {}),
|
|
357
|
+
...(options?.cert !== undefined ? { cert: options.cert } : {}),
|
|
358
|
+
...(options?.ciphers !== undefined ? { ciphers: options.ciphers } : {}),
|
|
359
|
+
...(options?.clientCertEngine !== undefined ? { clientCertEngine: options.clientCertEngine } : {}),
|
|
360
|
+
...(options?.crl !== undefined ? { crl: options.crl } : {}),
|
|
361
|
+
...(options?.dhparam !== undefined ? { dhparam: options.dhparam } : {}),
|
|
362
|
+
...(options?.ecdhCurve !== undefined ? { ecdhCurve: options.ecdhCurve } : {}),
|
|
363
|
+
...(options?.honorCipherOrder !== undefined ? { honorCipherOrder: options.honorCipherOrder } : {}),
|
|
364
|
+
...(options?.key !== undefined ? { key: options.key } : {}),
|
|
365
|
+
...(options?.maxVersion !== undefined ? { maxVersion: options.maxVersion } : {}),
|
|
366
|
+
...(options?.minVersion !== undefined ? { minVersion: options.minVersion } : {}),
|
|
367
|
+
...(options?.passphrase !== undefined ? { passphrase: options.passphrase } : {}),
|
|
368
|
+
...(options?.pfx !== undefined ? { pfx: options.pfx } : {}),
|
|
369
|
+
...(options?.rejectUnauthorized !== undefined ? { rejectUnauthorized: options.rejectUnauthorized } : {}),
|
|
370
|
+
...(options?.secureOptions !== undefined ? { secureOptions: options.secureOptions } : {}),
|
|
371
|
+
...(options?.secureProtocol !== undefined ? { secureProtocol: options.secureProtocol } : {}),
|
|
372
|
+
...(options?.servername !== undefined ? { servername: options.servername } : {}),
|
|
373
|
+
...(options?.sessionIdContext !== undefined ? { sessionIdContext: options.sessionIdContext } : {}),
|
|
374
|
+
getProxyForUrl: resolver.getProxyForUrl,
|
|
375
|
+
httpAgent: new http.Agent(),
|
|
376
|
+
httpsAgent: new https.Agent(),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
function createUndiciProxyDispatcher(options, proxyCa) {
|
|
380
|
+
if (options.mode === "ambient") {
|
|
381
|
+
if (!options.active) {
|
|
382
|
+
return new UndiciAgent();
|
|
383
|
+
}
|
|
384
|
+
const rawHttpProxy = readProxyEnvValue(options.env, "http_proxy") ?? readProxyEnvValue(options.env, "all_proxy");
|
|
385
|
+
const rawHttpsProxy = readProxyEnvValue(options.env, "https_proxy") ??
|
|
386
|
+
readProxyEnvValue(options.env, "all_proxy");
|
|
387
|
+
const noProxy = normalizeNoProxyForUndici(readProxyEnvValue(options.env, "no_proxy"));
|
|
388
|
+
return new EnvHttpProxyAgent({
|
|
389
|
+
...(rawHttpProxy !== undefined
|
|
390
|
+
? { httpProxy: proxyUrlWithDefaultScheme(rawHttpProxy) }
|
|
391
|
+
: {}),
|
|
392
|
+
...(rawHttpsProxy !== undefined
|
|
393
|
+
? { httpsProxy: proxyUrlWithDefaultScheme(rawHttpsProxy) }
|
|
394
|
+
: {}),
|
|
395
|
+
...(noProxy !== undefined ? { noProxy } : {}),
|
|
396
|
+
...(proxyCa !== undefined ? { proxyTls: { ca: proxyCa } } : {}),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
return new UndiciProxyAgent({
|
|
400
|
+
uri: options.proxyUrl,
|
|
401
|
+
...(proxyCa !== undefined ? { proxyTls: { ca: proxyCa } } : {}),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function installRuntime(resolver, dispatcherOptions, proxyCa) {
|
|
405
|
+
if (activeRuntime !== undefined) {
|
|
406
|
+
throw new ProxylineError("RUNTIME_ALREADY_ACTIVE", "Proxyline already has an active runtime.");
|
|
407
|
+
}
|
|
408
|
+
const snapshot = {
|
|
409
|
+
httpRequest: http.request,
|
|
410
|
+
httpGet: http.get,
|
|
411
|
+
httpGlobalAgent: http.globalAgent,
|
|
412
|
+
httpsRequest: https.request,
|
|
413
|
+
httpsGet: https.get,
|
|
414
|
+
httpsGlobalAgent: https.globalAgent,
|
|
415
|
+
};
|
|
416
|
+
const nodeAgent = createNodeProxyAgent(resolver, proxyCa);
|
|
417
|
+
const originalDispatcher = getGlobalDispatcher();
|
|
418
|
+
const runtime = {
|
|
419
|
+
nodeAgent,
|
|
420
|
+
originalDispatcher,
|
|
421
|
+
snapshot,
|
|
422
|
+
};
|
|
423
|
+
activeRuntime = runtime;
|
|
424
|
+
try {
|
|
425
|
+
http.globalAgent = nodeAgent;
|
|
426
|
+
https.globalAgent = nodeAgent;
|
|
427
|
+
http.request = bindNodeHttpMethod(snapshot.httpRequest, (options) => createNodeProxyAgent(resolver, proxyCa, options));
|
|
428
|
+
http.get = bindNodeHttpMethod(snapshot.httpGet, (options) => createNodeProxyAgent(resolver, proxyCa, options));
|
|
429
|
+
https.request = bindNodeHttpMethod(snapshot.httpsRequest, (options) => createNodeProxyAgent(resolver, proxyCa, options));
|
|
430
|
+
https.get = bindNodeHttpMethod(snapshot.httpsGet, (options) => createNodeProxyAgent(resolver, proxyCa, options));
|
|
431
|
+
setGlobalDispatcher(createUndiciProxyDispatcher(dispatcherOptions, proxyCa));
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
activeRuntime = undefined;
|
|
435
|
+
nodeAgent.destroy();
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
return runtime;
|
|
439
|
+
}
|
|
440
|
+
function stopRuntime(runtime) {
|
|
441
|
+
if (activeRuntime !== runtime) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
http.request = runtime.snapshot.httpRequest;
|
|
445
|
+
http.get = runtime.snapshot.httpGet;
|
|
446
|
+
http.globalAgent = runtime.snapshot.httpGlobalAgent;
|
|
447
|
+
https.request = runtime.snapshot.httpsRequest;
|
|
448
|
+
https.get = runtime.snapshot.httpsGet;
|
|
449
|
+
https.globalAgent = runtime.snapshot.httpsGlobalAgent;
|
|
450
|
+
setGlobalDispatcher(runtime.originalDispatcher);
|
|
451
|
+
runtime.nodeAgent.destroy();
|
|
452
|
+
activeRuntime = undefined;
|
|
453
|
+
}
|
|
454
|
+
export function installProxyline(options) {
|
|
455
|
+
const proxyUrl = options.mode === "managed" ? normalizeProxyUrl(options.proxyUrl) : undefined;
|
|
456
|
+
if (options.mode === "managed" && proxyUrl === undefined) {
|
|
457
|
+
throw new ProxylineError("MANAGED_PROXY_URL_REQUIRED", "Proxyline managed mode requires an explicit proxyUrl.");
|
|
458
|
+
}
|
|
459
|
+
let stopped = false;
|
|
460
|
+
const proxyCa = resolveProxyTlsCa(options.proxyTls);
|
|
461
|
+
const ambientEnv = proxyUrl === undefined ? readProxyEnv() : undefined;
|
|
462
|
+
const resolver = proxyUrl !== undefined
|
|
463
|
+
? createManagedProxyResolver(proxyUrl)
|
|
464
|
+
: createAmbientProxyResolver(ambientEnv ?? EMPTY_PROXY_ENV);
|
|
465
|
+
const redactedProxyUrl = resolver.describeProxy();
|
|
466
|
+
const hasActiveProxy = resolver.active;
|
|
467
|
+
const runtime = hasActiveProxy
|
|
468
|
+
? installRuntime(resolver, proxyUrl !== undefined
|
|
469
|
+
? { mode: "managed", proxyUrl: proxyUrl.href }
|
|
470
|
+
: { mode: "ambient", env: ambientEnv ?? EMPTY_PROXY_ENV, active: hasActiveProxy }, proxyCa)
|
|
471
|
+
: undefined;
|
|
472
|
+
emit(options.onEvent, {
|
|
473
|
+
type: "runtime.installed",
|
|
474
|
+
mode: options.mode,
|
|
475
|
+
active: hasActiveProxy,
|
|
476
|
+
...(redactedProxyUrl ? { proxyUrl: redactedProxyUrl } : {}),
|
|
477
|
+
});
|
|
478
|
+
const handle = {
|
|
479
|
+
mode: options.mode,
|
|
480
|
+
active: hasActiveProxy,
|
|
481
|
+
...(redactedProxyUrl ? { proxyUrl: redactedProxyUrl } : {}),
|
|
482
|
+
createNodeAgent: () => {
|
|
483
|
+
if (!hasActiveProxy) {
|
|
484
|
+
return new http.Agent();
|
|
485
|
+
}
|
|
486
|
+
return createNodeProxyAgent(resolver, proxyCa);
|
|
487
|
+
},
|
|
488
|
+
createUndiciDispatcher: () => createUndiciProxyDispatcher(proxyUrl !== undefined
|
|
489
|
+
? { mode: "managed", proxyUrl: proxyUrl.href }
|
|
490
|
+
: { mode: "ambient", env: ambientEnv ?? EMPTY_PROXY_ENV, active: hasActiveProxy }, proxyCa),
|
|
491
|
+
createWebSocketAgent: () => {
|
|
492
|
+
if (!hasActiveProxy) {
|
|
493
|
+
return new http.Agent();
|
|
494
|
+
}
|
|
495
|
+
return createNodeProxyAgent(resolver, proxyCa);
|
|
496
|
+
},
|
|
497
|
+
explain: (url, explainOptions) => {
|
|
498
|
+
const decision = stopped
|
|
499
|
+
? {
|
|
500
|
+
kind: "direct",
|
|
501
|
+
reason: "runtime-stopped",
|
|
502
|
+
surface: explainOptions?.surface ?? "unknown",
|
|
503
|
+
url: formatUrl(url),
|
|
504
|
+
}
|
|
505
|
+
: resolver.explain(url, explainOptions?.surface ?? "unknown");
|
|
506
|
+
emit(options.onEvent, { type: "decision", decision });
|
|
507
|
+
return decision;
|
|
508
|
+
},
|
|
509
|
+
stop: () => {
|
|
510
|
+
if (stopped) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
stopped = true;
|
|
514
|
+
if (runtime !== undefined) {
|
|
515
|
+
stopRuntime(runtime);
|
|
516
|
+
}
|
|
517
|
+
emit(options.onEvent, { type: "runtime.stopped", mode: options.mode });
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
return handle;
|
|
521
|
+
}
|
|
522
|
+
export const installGlobalProxy = installProxyline;
|
package/dist/shared.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ProxylineTlsOptions = Readonly<{
|
|
2
|
+
ca?: string;
|
|
3
|
+
caFile?: string;
|
|
4
|
+
}>;
|
|
5
|
+
export declare class ProxylineError extends Error {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
constructor(code: string, message: string);
|
|
8
|
+
}
|
|
9
|
+
export declare function resolveProxyTlsCa(options: ProxylineTlsOptions | undefined): string | undefined;
|
|
10
|
+
export declare function redactProxyUrl(value: string | URL): string;
|
|
11
|
+
//# sourceMappingURL=shared.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shared.d.ts","sourceRoot":"","sources":["../src/shared.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC;IACzC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC,CAAC;AAEH,qBAAa,cAAe,SAAQ,KAAK;IACvC,SAAgB,IAAI,EAAE,MAAM,CAAC;gBAEV,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAKjD;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAW9F;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,MAAM,CAO1D"}
|
package/dist/shared.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
export class ProxylineError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ProxylineError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function resolveProxyTlsCa(options) {
|
|
11
|
+
if (!options) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
if (options.ca !== undefined) {
|
|
15
|
+
return options.ca;
|
|
16
|
+
}
|
|
17
|
+
if (options.caFile !== undefined) {
|
|
18
|
+
return fs.readFileSync(options.caFile, "utf8");
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
export function redactProxyUrl(value) {
|
|
23
|
+
const url = value instanceof URL ? new URL(value.href) : new URL(value);
|
|
24
|
+
url.username = "";
|
|
25
|
+
url.password = "";
|
|
26
|
+
url.search = "";
|
|
27
|
+
url.hash = "";
|
|
28
|
+
return url.href;
|
|
29
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openclaw/proxyline",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Process-global proxy routing for Node.js.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jesse Merhi",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public",
|
|
10
|
+
"registry": "https://registry.npmjs.org/"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/openclaw/proxyline.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/openclaw/proxyline/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://proxyline.dev",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/*.js",
|
|
28
|
+
"dist/*.d.ts",
|
|
29
|
+
"dist/*.d.ts.map",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^20.19.25",
|
|
36
|
+
"@types/ws": "^8.18.1",
|
|
37
|
+
"tsx": "^4.21.0",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"ws": "^8.20.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"proxy-agent": "^8.0.1",
|
|
46
|
+
"undici": "^7.25.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"check": "pnpm typecheck && pnpm test && pnpm build",
|
|
51
|
+
"docs:build": "node scripts/build-docs-site.mjs",
|
|
52
|
+
"package:dry-run": "pnpm pack --dry-run",
|
|
53
|
+
"publish:dry-run": "pnpm publish --dry-run --access public",
|
|
54
|
+
"test": "tsx --test test/index.test.ts test/e2e.test.ts",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|