@openclaw/proxyline 0.2.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 +11 -1
- package/LICENSE +1 -0
- package/README.md +15 -15
- package/dist/dispatcher-brand.d.ts +3 -0
- package/dist/dispatcher-brand.d.ts.map +1 -0
- package/dist/dispatcher-brand.js +6 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/node-http.d.ts +2 -2
- package/dist/node-http.d.ts.map +1 -1
- package/dist/node-http.js +17 -7
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +171 -33
- package/dist/types.d.ts +18 -1
- package/dist/types.d.ts.map +1 -1
- 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 +17 -5
- 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/docs/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Overview
|
|
3
|
+
permalink: /
|
|
4
|
+
description: "Process-global proxy routing for Node.js. One install routes node:http, node:https, undici/fetch, plus WebSocket and HTTP CONNECT helpers through a single explicit policy."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Proxyline Documentation
|
|
8
|
+
|
|
9
|
+
Process-global proxy routing for Node.js. Proxyline patches the network surfaces a Node process can reach without owning a private transport stack, so a single policy applies to `node:http`, `node:https`, undici/fetch, WebSocket clients that accept agents, and explicit HTTP CONNECT helpers.
|
|
10
|
+
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
- [Getting Started](./getting-started.md) — install, first proxy, shutdown.
|
|
14
|
+
- [Modes](./modes.md) — `managed` vs `ambient` safety postures.
|
|
15
|
+
- [Surfaces](./surfaces.md) — which network APIs Proxyline covers and how.
|
|
16
|
+
- [API Reference](./api-reference.md) — every exported type, function, and field.
|
|
17
|
+
- [Environment Variables](./environment-variables.md) — how `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` are interpreted.
|
|
18
|
+
- [Proxy TLS](./proxy-tls.md) — scoping CA trust to the proxy endpoint.
|
|
19
|
+
- [Observability](./observability.md) — events, `explain()`, credential redaction.
|
|
20
|
+
- [Security](./security.md) — threat model, limits, what Proxyline does **not** do.
|
|
21
|
+
- [Troubleshooting](./troubleshooting.md) — common failure modes and fixes.
|
|
22
|
+
- [Testing](./testing.md) — the in-process proxy lab.
|
|
23
|
+
|
|
24
|
+
## Product coverage
|
|
25
|
+
|
|
26
|
+
- `http.request` / `http.get`: covered by global method patching and global agent replacement.
|
|
27
|
+
- `https.request` / `https.get`: covered by global method patching and global agent replacement.
|
|
28
|
+
- `fetch` / undici global dispatcher: covered by the `globalThis.fetch` patch and `setGlobalDispatcher`.
|
|
29
|
+
- WebSocket clients accepting a Node `agent`: covered with `proxy.createWebSocketAgent()`.
|
|
30
|
+
- WebSocket clients without an `agent` option: partially covered when the upgrade path reuses patched `http.request`.
|
|
31
|
+
- Explicit HTTP CONNECT sockets: covered with `openProxyConnectTunnel()`.
|
|
32
|
+
- Caller-built `http.Agent` / `https.Agent`: overridden per request in managed and active ambient mode.
|
|
33
|
+
- Raw `net.connect` / `tls.connect`: out of scope; see [Security](./security.md).
|
|
34
|
+
- Native or third-party transport stacks: out of scope; see [Security](./security.md).
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
Every public export, with the exact shape from `src/index.ts`, `src/connect.ts`, and `src/node-http.ts`.
|
|
4
|
+
|
|
5
|
+
## Functions
|
|
6
|
+
|
|
7
|
+
### `installProxyline(options): ProxylineHandle`
|
|
8
|
+
|
|
9
|
+
Aliased as `installGlobalProxy`. Installs the runtime and returns a handle.
|
|
10
|
+
|
|
11
|
+
- Throws `ProxylineError` with code `MANAGED_PROXY_URL_REQUIRED` if `mode: "managed"` is used without a `proxyUrl`.
|
|
12
|
+
- Throws `ProxylineError` with code `UNSUPPORTED_PROXY_PROTOCOL` if managed-mode `proxyUrl` is not `http://` or `https://`.
|
|
13
|
+
- Throws `ProxylineError` with code `RUNTIME_ALREADY_ACTIVE` if another Proxyline runtime is already installed and `ifActive` is omitted or cannot safely reuse the active runtime.
|
|
14
|
+
|
|
15
|
+
In managed mode (and active ambient mode), `installProxyline`:
|
|
16
|
+
|
|
17
|
+
- Captures originals for `http.request`, `http.get`, `http.globalAgent`, `https.request`, `https.get`, `https.globalAgent`.
|
|
18
|
+
- Captures the current undici global dispatcher and fetch globals.
|
|
19
|
+
- Installs patched `http.request`/`get`, `https.request`/`get`.
|
|
20
|
+
- Replaces `http.globalAgent` and `https.globalAgent` with Proxyline's HTTP/HTTPS Node agent.
|
|
21
|
+
- Calls `undici.setGlobalDispatcher` with a `ProxyAgent` (managed) or Proxyline's ambient dispatcher (ambient), and patches `globalThis.fetch` plus `Request`, `Response`, `Headers`, and `FormData` to use that dispatcher-compatible fetch stack.
|
|
22
|
+
- Emits `runtime.installed`.
|
|
23
|
+
|
|
24
|
+
In inactive ambient mode (no supported proxy env variables), no patches are installed; the handle returns a passive observer with `active: false`.
|
|
25
|
+
|
|
26
|
+
### `openProxyConnectTunnel(options): Promise<net.Socket | tls.TLSSocket>`
|
|
27
|
+
|
|
28
|
+
Opens a one-shot HTTP CONNECT tunnel through a proxy. See [Surfaces — HTTP CONNECT tunnel](./surfaces.md#http-connect-tunnel).
|
|
29
|
+
|
|
30
|
+
### `hasAmbientNodeProxyConfigured(options?): boolean`
|
|
31
|
+
|
|
32
|
+
Returns `true` when the ambient proxy environment would proxy a probe URL for the requested protocol. Defaults to `protocol: "https"`.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
hasAmbientNodeProxyConfigured({ protocol: "https" });
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### `createAmbientNodeProxyAgent(options?): http.Agent | undefined`
|
|
39
|
+
|
|
40
|
+
Returns a Proxyline-backed Node agent when ambient env proxy settings apply, or `undefined` when no proxy is configured for the requested protocol. This is for libraries that accept a Node `agent` option but should stay direct when the operator has no proxy env configured.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const agent = createAmbientNodeProxyAgent({
|
|
44
|
+
protocol: "https",
|
|
45
|
+
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### `redactProxyUrl(value: string | URL): string`
|
|
50
|
+
|
|
51
|
+
Strips userinfo, search, and fragment from a URL. Used internally to keep events and decisions free of credentials. Safe to use on log lines you build yourself.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
redactProxyUrl("https://user:secret@proxy.example:8443/path?q=1#frag");
|
|
55
|
+
// → "https://proxy.example:8443/path"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### `resolveProxyTlsCa(options): string | undefined`
|
|
59
|
+
|
|
60
|
+
Resolves a `ProxylineTlsOptions` value to a PEM string by reading `caFile` from disk if needed. Returns `undefined` when no CA material is supplied. Exposed so callers can pre-resolve before passing values into their own TLS-using code.
|
|
61
|
+
|
|
62
|
+
## Classes
|
|
63
|
+
|
|
64
|
+
### `ProxylineError extends Error`
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
class ProxylineError extends Error {
|
|
68
|
+
readonly code: string;
|
|
69
|
+
readonly name: "ProxylineError";
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Codes:
|
|
74
|
+
|
|
75
|
+
- `MANAGED_PROXY_URL_REQUIRED` — `mode: "managed"` was used without `proxyUrl`.
|
|
76
|
+
- `UNSUPPORTED_PROXY_PROTOCOL` — proxy URL scheme is not `http://` or `https://`.
|
|
77
|
+
- `RUNTIME_ALREADY_ACTIVE` — another Proxyline runtime is already installed.
|
|
78
|
+
- `CONNECT_FAILED` — `openProxyConnectTunnel` failed (bad response, timeout, header overrun, or socket error).
|
|
79
|
+
- `INVALID_CONNECT_TARGET` — `openProxyConnectTunnel` received an empty or unsafe target host, invalid bracket syntax, or an invalid target port.
|
|
80
|
+
|
|
81
|
+
## Types
|
|
82
|
+
|
|
83
|
+
### `ProxylineMode`
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
type ProxylineMode = "managed" | "ambient";
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See [Modes](./modes.md).
|
|
90
|
+
|
|
91
|
+
### `ProxylineSurface`
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
type ProxylineSurface =
|
|
95
|
+
| "node-http"
|
|
96
|
+
| "node-https"
|
|
97
|
+
| "undici"
|
|
98
|
+
| "websocket"
|
|
99
|
+
| "connect"
|
|
100
|
+
| "unknown";
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Used in `explain()` decisions and event payloads to identify which network surface a decision is for. Pass it via `explain(url, { surface })`.
|
|
104
|
+
|
|
105
|
+
### `ProxylineOptions`
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
type ProxylineOptions = Readonly<{
|
|
109
|
+
mode: ProxylineMode;
|
|
110
|
+
proxyUrl?: string | URL;
|
|
111
|
+
proxyTls?: ProxylineTlsOptions;
|
|
112
|
+
bypassPolicy?: ProxylineBypassPolicy;
|
|
113
|
+
ifActive?: "error" | "reuse-compatible" | "replace";
|
|
114
|
+
onEvent?: (event: ProxylineEvent) => void;
|
|
115
|
+
undici?: ProxylineUndiciOptions;
|
|
116
|
+
}>;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- `mode` — required. `"managed"` or `"ambient"`.
|
|
120
|
+
- `proxyUrl` — required in managed mode, ignored in ambient mode. Managed-mode URLs must be `http://` or `https://`.
|
|
121
|
+
- `proxyTls` — CA trust scoped to the proxy endpoint. See [Proxy TLS](./proxy-tls.md).
|
|
122
|
+
- `bypassPolicy` — managed-mode escape hatch for trusted traffic that should go direct. Ignored in ambient mode.
|
|
123
|
+
- `ifActive` — process singleton behavior when Proxyline is already active. Defaults to `"error"`. `"reuse-compatible"` returns the active handle when mode/proxy/TLS/bypass/undici settings match; ambient mode also requires the captured proxy env snapshot to match. `"replace"` stops the active runtime before installing the new one.
|
|
124
|
+
- `onEvent` — callback fired with every `ProxylineEvent`.
|
|
125
|
+
- `undici` — default options for Proxyline-owned undici dispatchers.
|
|
126
|
+
|
|
127
|
+
### `ProxylineUndiciOptions`
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
type ProxylineUndiciOptions = Readonly<{
|
|
131
|
+
allowH2?: boolean;
|
|
132
|
+
bodyTimeout?: number;
|
|
133
|
+
headersTimeout?: number;
|
|
134
|
+
connect?: Readonly<{
|
|
135
|
+
autoSelectFamily?: boolean;
|
|
136
|
+
autoSelectFamilyAttemptTimeout?: number;
|
|
137
|
+
}>;
|
|
138
|
+
}>;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
These options apply to the global dispatcher installed by `installGlobalProxy()` and to dispatchers returned by `createUndiciDispatcher()`.
|
|
142
|
+
|
|
143
|
+
### `ProxylineTlsOptions`
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
type ProxylineTlsOptions = Readonly<{
|
|
147
|
+
ca?: string; // PEM string
|
|
148
|
+
caFile?: string; // path read with fs.readFileSync(..., "utf8")
|
|
149
|
+
}>;
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
When both are provided, `ca` wins.
|
|
153
|
+
|
|
154
|
+
### `ProxylineDecision`
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
type ProxylineDecision = Readonly<{
|
|
158
|
+
kind: "proxied" | "direct" | "blocked";
|
|
159
|
+
reason: string;
|
|
160
|
+
surface: ProxylineSurface;
|
|
161
|
+
url: string;
|
|
162
|
+
proxyUrl?: string; // redacted
|
|
163
|
+
}>;
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Known `reason` values:
|
|
167
|
+
|
|
168
|
+
- `"managed-proxy-active"` — managed mode applied.
|
|
169
|
+
- `"managed-proxy-bypass-policy"` — managed mode was active, but `bypassPolicy` intentionally sent the URL direct.
|
|
170
|
+
- `"managed-proxy-unsupported-url-scheme"` — managed mode is active, but the URL scheme is not one Proxyline can proxy.
|
|
171
|
+
- `"ambient-proxy-active"` — ambient mode resolved a proxy from env.
|
|
172
|
+
- `"ambient-proxy-not-configured"` — ambient mode has no proxy env set, or the URL scheme is unsupported.
|
|
173
|
+
- `"no-proxy-match"` — the URL matched `NO_PROXY`.
|
|
174
|
+
- `"runtime-stopped"` — `explain()` was called after `stop()`.
|
|
175
|
+
|
|
176
|
+
`kind: "blocked"` is reserved for future explicit deny rules; the current implementation does not produce blocked decisions.
|
|
177
|
+
|
|
178
|
+
### `ProxylineBypassRequest`
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
type ProxylineBypassRequest = Readonly<{
|
|
182
|
+
surface: ProxylineSurface;
|
|
183
|
+
url: string;
|
|
184
|
+
}>;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### `ProxylineBypassPolicy`
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
type ProxylineBypassPolicy = (request: ProxylineBypassRequest) => boolean;
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Return `true` to send that managed-mode request direct. Use this only for explicitly trusted destinations such as loopback control-plane endpoints.
|
|
194
|
+
|
|
195
|
+
### `ProxylineEvent`
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
type ProxylineEvent =
|
|
199
|
+
| Readonly<{ type: "runtime.installed"; mode: ProxylineMode; active: boolean; proxyUrl?: string }>
|
|
200
|
+
| Readonly<{ type: "runtime.stopped"; mode: ProxylineMode }>
|
|
201
|
+
| Readonly<{ type: "decision"; decision: ProxylineDecision }>
|
|
202
|
+
| Readonly<{ type: "warning"; code: string; message: string }>;
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`decision` events fire from inside `explain()`. `runtime.installed` and `runtime.stopped` fire from `installProxyline` and `handle.stop()` respectively. `warning` is reserved for future runtime diagnostics.
|
|
206
|
+
|
|
207
|
+
### `ExplainOptions`
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
type ExplainOptions = Readonly<{
|
|
211
|
+
surface?: ProxylineSurface;
|
|
212
|
+
}>;
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `ProxylineHandle`
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
type ProxylineHandle = Readonly<{
|
|
219
|
+
mode: ProxylineMode;
|
|
220
|
+
active: boolean;
|
|
221
|
+
proxyUrl?: string;
|
|
222
|
+
createNodeAgent: () => http.Agent;
|
|
223
|
+
createUndiciDispatcher: () => Dispatcher;
|
|
224
|
+
createWebSocketAgent: () => http.Agent;
|
|
225
|
+
explain: (url: string | URL, options?: ExplainOptions) => ProxylineDecision;
|
|
226
|
+
registerBypass: (registration: ProxylineBypassRegistration) => () => void;
|
|
227
|
+
stop: () => void;
|
|
228
|
+
withBypass: <T>(registration: ProxylineBypassRegistration, run: () => T) => T;
|
|
229
|
+
}>;
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- `mode` — the mode this handle was installed with.
|
|
233
|
+
- `active` — `true` when the runtime is installed and forcing/respecting a proxy.
|
|
234
|
+
- `proxyUrl` — redacted proxy URL string when active.
|
|
235
|
+
- `createNodeAgent()` — proxy-aware `http.Agent` for ad-hoc node:http(s) use. Returns a direct agent when inactive or after `stop()`.
|
|
236
|
+
- `createUndiciDispatcher()` — proxy-aware undici `Dispatcher`. Returns a direct `UndiciAgent()` when ambient-inactive or after `stop()`.
|
|
237
|
+
- `createWebSocketAgent()` — same as `createNodeAgent()` but typed for WebSocket clients.
|
|
238
|
+
- `explain(url, options?)` — returns a `ProxylineDecision` and emits a `decision` event.
|
|
239
|
+
- `registerBypass({ url, surface? })` — managed-mode process-wide bypass. Returns an unregister callback. When `surface` is omitted, the bypass matches any surface for that exact URL.
|
|
240
|
+
- `stop()` — restores the captured Node HTTP(S) stack, undici dispatcher, and fetch globals, destroys Proxyline-owned runtime agents/dispatchers, emits `runtime.stopped`. Idempotent.
|
|
241
|
+
- `withBypass(registration, run)` — applies a bypass only to `run()` and async work created inside that callback.
|
|
242
|
+
|
|
243
|
+
### Dispatcher Detection
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
isProxylineDispatcher(dispatcher: unknown): boolean;
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Returns `true` for Proxyline-owned managed and ambient undici dispatchers. Use this instead of constructor-name checks when integrating with code that also manages undici globals. Import from `@openclaw/proxyline/dispatcher-brand` when detection must not load Proxyline's full runtime.
|
|
250
|
+
|
|
251
|
+
### `OpenProxyConnectTunnelOptions`
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
type OpenProxyConnectTunnelOptions = Readonly<{
|
|
255
|
+
proxyUrl: string | URL;
|
|
256
|
+
proxyTls?: ProxylineTlsOptions;
|
|
257
|
+
targetHost: string;
|
|
258
|
+
targetPort: number;
|
|
259
|
+
timeoutMs?: number;
|
|
260
|
+
}>;
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
- `proxyUrl` — `http://` or `https://`. Userinfo becomes a `Proxy-Authorization: Basic` header.
|
|
264
|
+
- `proxyTls` — CA trust for HTTPS proxies. See [Proxy TLS](./proxy-tls.md).
|
|
265
|
+
- `targetHost` / `targetPort` — what to ask the proxy to connect to.
|
|
266
|
+
- `timeoutMs` — overall budget for the CONNECT handshake.
|
|
267
|
+
|
|
268
|
+
### `AmbientNodeProxyAgentOptions`
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
type AmbientNodeProxyAgentOptions = {
|
|
272
|
+
env?: ProxyEnvSnapshot;
|
|
273
|
+
protocol?: "http" | "https";
|
|
274
|
+
proxyTls?: ProxylineTlsOptions;
|
|
275
|
+
};
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- `env` — optional env snapshot. Defaults to reading process env.
|
|
279
|
+
- `protocol` — probe protocol, defaulting to `"https"`.
|
|
280
|
+
- `proxyTls` — CA trust for HTTPS proxy endpoints. See [Proxy TLS](./proxy-tls.md).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Environment Variables
|
|
2
|
+
|
|
3
|
+
Ambient mode reads its configuration from the environment at install time. Managed mode does not.
|
|
4
|
+
|
|
5
|
+
## Variables
|
|
6
|
+
|
|
7
|
+
| Variable | Used for | Notes |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `HTTP_PROXY` | proxy for `http:` and `ws:` URLs | lower case wins when both are set |
|
|
10
|
+
| `HTTPS_PROXY` | proxy for `https:` and `wss:` URLs | |
|
|
11
|
+
| `ALL_PROXY` | fallback when the protocol-specific variable is unset or unsupported | |
|
|
12
|
+
| `NO_PROXY` | comma- or whitespace-separated list of exemptions | matched against the destination URL |
|
|
13
|
+
| `http_proxy` | lowercase alias for `HTTP_PROXY` | takes precedence over uppercase |
|
|
14
|
+
| `https_proxy` | lowercase alias | |
|
|
15
|
+
| `all_proxy` | lowercase alias | |
|
|
16
|
+
| `no_proxy` | lowercase alias | |
|
|
17
|
+
|
|
18
|
+
Empty or whitespace-only values are treated as unset.
|
|
19
|
+
|
|
20
|
+
## Activation
|
|
21
|
+
|
|
22
|
+
The ambient runtime is **active** when any of `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` (or their lowercase forms) is set to a supported `http://` or `https://` proxy endpoint. Bare endpoints count because Proxyline defaults them to `http://`.
|
|
23
|
+
|
|
24
|
+
`NO_PROXY` alone does not activate the runtime. With only `NO_PROXY` set, `proxy.active` is `false` and Proxyline installs no patches.
|
|
25
|
+
|
|
26
|
+
## Snapshot at install
|
|
27
|
+
|
|
28
|
+
The values are read **once** at `installProxyline` time. Changing `process.env.HTTP_PROXY` afterwards does not retroactively activate or reconfigure Proxyline. Call `proxy.stop()` and re-install if the environment changes.
|
|
29
|
+
|
|
30
|
+
## Proxy URL parsing
|
|
31
|
+
|
|
32
|
+
- Bare endpoints (no scheme) default to `http://`. `HTTPS_PROXY=proxy.corp:8080` becomes `http://proxy.corp:8080`.
|
|
33
|
+
- `http://` and `https://` are the only accepted schemes. Anything else is ignored.
|
|
34
|
+
- Userinfo (`user:pass@`) in the proxy URL becomes a `Proxy-Authorization: Basic` header.
|
|
35
|
+
|
|
36
|
+
## NO_PROXY matching
|
|
37
|
+
|
|
38
|
+
Entries are split on commas and whitespace. Matching rules, in order:
|
|
39
|
+
|
|
40
|
+
1. `*` alone matches everything. The URL goes direct.
|
|
41
|
+
2. Entries can include a port: `internal.corp:8443`. When present, the entry only matches the URL's port (defaulting to `80` for `http`/`ws` and `443` for `https`/`wss`).
|
|
42
|
+
3. Entries starting with `.` or `*` are suffix matches. `.corp.example` matches `api.corp.example` and `corp.example`. `*.corp.example` is equivalent.
|
|
43
|
+
4. Other entries are exact host matches.
|
|
44
|
+
5. IPv6 entries may be bracketed (`[::1]:8443`) or bare (`::1`). Bracketed forms are stripped before comparison.
|
|
45
|
+
6. Hostnames are lowercased and trailing dots are stripped before comparison.
|
|
46
|
+
|
|
47
|
+
When a URL matches `NO_PROXY`, `explain()` returns `kind: "direct"`, `reason: "no-proxy-match"`.
|
|
48
|
+
|
|
49
|
+
## Examples
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# go through the proxy for everything except internal hosts
|
|
53
|
+
export HTTPS_PROXY="https://proxy.corp.example:8443"
|
|
54
|
+
export NO_PROXY=".corp.example,localhost,127.0.0.1,::1"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# only https traffic via proxy, http stays direct
|
|
59
|
+
export HTTPS_PROXY="https://proxy.corp.example:8443"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# fall through to ALL_PROXY for everything
|
|
64
|
+
export ALL_PROXY="socks-not-supported://example" # ignored — only http/https proxies are honored
|
|
65
|
+
export ALL_PROXY="http://gateway.corp:3128" # used for both http and https URLs
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Interaction with `fetch`
|
|
69
|
+
|
|
70
|
+
The undici global dispatcher is installed with Proxyline's own ambient resolver over the same `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` snapshot Proxyline read at install time. `fetch` and `node:http` therefore agree on the proxy decision for any URL.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
Proxyline targets Node.js 20.18.1+. It is published as `@openclaw/proxyline` and ships ESM with TypeScript types.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @openclaw/proxyline
|
|
9
|
+
# or
|
|
10
|
+
npm install @openclaw/proxyline
|
|
11
|
+
# or
|
|
12
|
+
yarn add @openclaw/proxyline
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install Proxyline first
|
|
16
|
+
|
|
17
|
+
Patches replace `http.request`, `http.get`, `https.request`, `https.get`, `http.globalAgent`, `https.globalAgent`, the undici global dispatcher, and the fetch globals. Any module that captured the original references before Proxyline installs will bypass the runtime. Initialize Proxyline before importing third-party HTTP clients when proxy routing is a security policy.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// entry.ts — first import in your application
|
|
21
|
+
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
22
|
+
|
|
23
|
+
const proxy = installGlobalProxy({
|
|
24
|
+
mode: "managed",
|
|
25
|
+
proxyUrl: "https://proxy.corp.example:8443",
|
|
26
|
+
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// only now load the rest of the app
|
|
30
|
+
await import("./app.js");
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Minimal managed proxy
|
|
34
|
+
|
|
35
|
+
Managed mode treats proxy routing as policy: setup failures throw, requests are forced through the configured proxy, and caller-supplied `http.Agent` / `https.Agent` instances are replaced per request.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
39
|
+
|
|
40
|
+
const proxy = installGlobalProxy({
|
|
41
|
+
mode: "managed",
|
|
42
|
+
proxyUrl: "https://proxy.corp.example:8443",
|
|
43
|
+
onEvent: (event) => console.debug("[proxyline]", event),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const decision = proxy.explain("https://api.example.com/");
|
|
47
|
+
console.log(decision.kind, decision.reason, decision.proxyUrl);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Minimal ambient proxy
|
|
51
|
+
|
|
52
|
+
Ambient mode reads the usual environment variables. With no proxy configured, requests stay direct and `explain()` reports `ambient-proxy-not-configured`.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export HTTPS_PROXY="http://proxy.corp.example:8080"
|
|
56
|
+
export NO_PROXY="metadata.google.internal,127.0.0.1"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
61
|
+
|
|
62
|
+
const proxy = installGlobalProxy({ mode: "ambient" });
|
|
63
|
+
console.log(proxy.active); // true if a supported HTTP_PROXY/HTTPS_PROXY/ALL_PROXY is set
|
|
64
|
+
console.log(proxy.proxyUrl); // redacted URL string when active
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
See [Modes](./modes.md) for the full posture contract and [Environment Variables](./environment-variables.md) for parsing rules.
|
|
68
|
+
|
|
69
|
+
## Shutdown
|
|
70
|
+
|
|
71
|
+
Call `proxy.stop()` from your shutdown path (and from tests). It restores the saved Node HTTP(S) methods and global agents, restores the previous undici global dispatcher and fetch globals, and destroys the internal proxy agent. Only one Proxyline runtime can be active at a time; a second install throws `RUNTIME_ALREADY_ACTIVE` by default unless you opt into compatible reuse or replacement with `ifActive`.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
process.once("SIGTERM", () => proxy.stop());
|
|
75
|
+
process.once("SIGINT", () => proxy.stop());
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## What to read next
|
|
79
|
+
|
|
80
|
+
- [Surfaces](./surfaces.md) for per-API behavior, including WebSocket and CONNECT helpers.
|
|
81
|
+
- [Observability](./observability.md) to log decisions without leaking credentials.
|
|
82
|
+
- [Security](./security.md) for the boundaries of what Proxyline can enforce.
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Proxyline
|
|
2
|
+
|
|
3
|
+
Process-global proxy routing for Node.js.
|
|
4
|
+
|
|
5
|
+
Proxyline patches the network surfaces a Node process can reach without owning a private transport stack, so one policy applies to `node:http`, `node:https`, undici/fetch, WebSocket clients that accept agents, and explicit HTTP CONNECT helpers.
|
|
6
|
+
|
|
7
|
+
## Start
|
|
8
|
+
|
|
9
|
+
- [Getting Started](./getting-started.md)
|
|
10
|
+
- [Modes](./modes.md)
|
|
11
|
+
- [Surfaces](./surfaces.md)
|
|
12
|
+
- [API Reference](./api-reference.md)
|
|
13
|
+
- [Environment Variables](./environment-variables.md)
|
|
14
|
+
- [Proxy TLS](./proxy-tls.md)
|
|
15
|
+
- [Observability](./observability.md)
|
|
16
|
+
- [Security](./security.md)
|
|
17
|
+
- [Troubleshooting](./troubleshooting.md)
|
|
18
|
+
- [Testing](./testing.md)
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add @openclaw/proxyline
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Managed Mode
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
30
|
+
|
|
31
|
+
const proxy = installGlobalProxy({
|
|
32
|
+
mode: "managed",
|
|
33
|
+
proxyUrl: "https://proxy.corp.example:8443",
|
|
34
|
+
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log(proxy.explain("https://api.example.com/"));
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Ambient Mode
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { installGlobalProxy } from "@openclaw/proxyline";
|
|
44
|
+
|
|
45
|
+
const proxy = installGlobalProxy({ mode: "ambient" });
|
|
46
|
+
```
|
package/docs/modes.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Modes
|
|
2
|
+
|
|
3
|
+
Proxyline models two safety postures. The `mode` option selects one explicitly — there is no implicit fallback between them.
|
|
4
|
+
|
|
5
|
+
## Managed
|
|
6
|
+
|
|
7
|
+
Managed mode treats proxy routing as a security policy.
|
|
8
|
+
|
|
9
|
+
- `proxyUrl` is **required**. Omitting it throws `ProxylineError` with code `MANAGED_PROXY_URL_REQUIRED`.
|
|
10
|
+
- Only `http://` and `https://` proxy endpoints are accepted. Other schemes throw `UNSUPPORTED_PROXY_PROTOCOL`.
|
|
11
|
+
- The managed proxy is forced for HTTP(S) and WS(S) requests on the patched surfaces.
|
|
12
|
+
- `bypassPolicy`, `registerBypass()`, and `withBypass()` are the managed-mode direct-routing escape hatches. They match `{ surface, url }` and intentionally send matching requests direct. `registerBypass()` is process-wide until unregistered; `withBypass()` is limited to the callback's async context.
|
|
13
|
+
- Caller-supplied `http.Agent` or `https.Agent` values are replaced per request. TLS-relevant agent options (`ca`, `cert`, `key`, `ciphers`, `minVersion`, `maxVersion`, `rejectUnauthorized`, etc.) are copied onto the proxy request so destination TLS identity is preserved. See [Surfaces — TLS identity preservation](./surfaces.md#tls-identity-preservation) for the full list.
|
|
14
|
+
- The undici global dispatcher is replaced with Proxyline's managed dispatcher, backed by `undici.ProxyAgent` instances pointed at `proxyUrl`.
|
|
15
|
+
- Environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, ...) are **ignored**.
|
|
16
|
+
- `explain()` returns `kind: "proxied"` with `reason: "managed-proxy-active"` for supported URL schemes. Bypassed URLs return `kind: "direct"` with `reason: "managed-proxy-bypass-policy"`. Unsupported schemes return `kind: "direct"` with `reason: "managed-proxy-unsupported-url-scheme"` because Proxyline has no safe proxy mapping for them.
|
|
17
|
+
|
|
18
|
+
Use managed mode when "go direct" must never be silent. If your network policy demands traffic egress through a specific gateway, this is the posture you want.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const proxy = installGlobalProxy({
|
|
22
|
+
mode: "managed",
|
|
23
|
+
proxyUrl: "https://proxy.corp.example:8443",
|
|
24
|
+
proxyTls: { caFile: "/etc/proxy-ca.pem" },
|
|
25
|
+
bypassPolicy: ({ url }) => new URL(url).hostname === "127.0.0.1",
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Ambient
|
|
30
|
+
|
|
31
|
+
Ambient mode mirrors the conventional "respect the environment" behavior used by most CLI tooling.
|
|
32
|
+
|
|
33
|
+
- `proxyUrl` is **ignored** if supplied. Configuration comes from `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` (and their lowercase forms).
|
|
34
|
+
- The runtime only installs patches when at least one of `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` resolves to a supported `http://` or `https://` proxy endpoint. `proxy.active` is `false` otherwise and the handle behaves as a passive observer.
|
|
35
|
+
- Per-request proxy resolution honors protocol-specific variables, falls back to `ALL_PROXY`, and applies `NO_PROXY` matching (suffix, wildcard, exact, IPv6).
|
|
36
|
+
- Bare endpoints (no scheme) default to `http://` — e.g. `HTTPS_PROXY=proxy.corp:8080` becomes `http://proxy.corp:8080`.
|
|
37
|
+
- The undici global dispatcher becomes Proxyline's ambient dispatcher so `fetch` sees the same routing rules.
|
|
38
|
+
- `explain()` returns one of:
|
|
39
|
+
- `kind: "proxied"`, `reason: "ambient-proxy-active"` — a proxy applies.
|
|
40
|
+
- `kind: "direct"`, `reason: "no-proxy-match"` — `NO_PROXY` exempted the host.
|
|
41
|
+
- `kind: "direct"`, `reason: "ambient-proxy-not-configured"` — no supported proxy variables are set, the configured proxy scheme is unsupported, or the URL scheme is unsupported.
|
|
42
|
+
|
|
43
|
+
Use ambient mode for tooling and CLIs that need best-effort compatibility with whatever the operator has configured.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const proxy = installGlobalProxy({ mode: "ambient" });
|
|
47
|
+
if (!proxy.active) {
|
|
48
|
+
console.warn("no HTTP_PROXY/HTTPS_PROXY/ALL_PROXY set — direct egress");
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Comparison
|
|
53
|
+
|
|
54
|
+
| Behavior | `managed` | `ambient` |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| Requires `proxyUrl` | yes | no (ignored if passed) |
|
|
57
|
+
| Reads env variables | no | yes |
|
|
58
|
+
| Honors `NO_PROXY` | no | yes |
|
|
59
|
+
| Supports `bypassPolicy` | yes | no |
|
|
60
|
+
| Forces traffic through proxy | yes | only when env says so |
|
|
61
|
+
| Replaces caller-supplied agents | yes | yes (when active) |
|
|
62
|
+
| Installs undici dispatcher | `ProxyAgent` | Proxyline ambient dispatcher |
|
|
63
|
+
| `explain()` direct reason | `managed-proxy-bypass-policy` or `managed-proxy-unsupported-url-scheme` | `no-proxy-match` or `ambient-proxy-not-configured` |
|
|
64
|
+
| Setup failure mode | throws | inactive but installed |
|
|
65
|
+
|
|
66
|
+
## Why a strict managed mode
|
|
67
|
+
|
|
68
|
+
If you mix environment-based configuration with a security policy you get drift: a missing variable, a forgotten `NO_PROXY` entry, or a transient unset turns "always through the proxy" into "sometimes direct." Managed mode refuses to start without the URL and refuses to honor environment overrides, so the policy is encoded in code, not in shell state.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Observability
|
|
2
|
+
|
|
3
|
+
Proxyline exposes its decisions through two channels: an `onEvent` callback and an `explain()` method on the handle. Both share the same `ProxylineDecision` shape and the same credential-redaction rules.
|
|
4
|
+
|
|
5
|
+
## Events
|
|
6
|
+
|
|
7
|
+
Subscribe at install time:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const proxy = installGlobalProxy({
|
|
11
|
+
mode: "managed",
|
|
12
|
+
proxyUrl: "https://user:secret@proxy.corp.example:8443",
|
|
13
|
+
onEvent: (event) => log(event),
|
|
14
|
+
});
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Event types:
|
|
18
|
+
|
|
19
|
+
- `runtime.installed` — fired once at install. Includes `mode`, `active`, and the redacted `proxyUrl`.
|
|
20
|
+
- `runtime.stopped` — fired from `handle.stop()`. Includes `mode`.
|
|
21
|
+
- `decision` — fired from every `explain()` call. Includes the full decision.
|
|
22
|
+
- `warning` — reserved for future runtime diagnostics.
|
|
23
|
+
|
|
24
|
+
`runtime.installed` is the cleanest place to confirm "this is the proxy we're actually using" in logs.
|
|
25
|
+
|
|
26
|
+
## explain()
|
|
27
|
+
|
|
28
|
+
Ask Proxyline what it would do for a given URL:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const decision = proxy.explain("https://api.example.com/v1/users", {
|
|
32
|
+
surface: "undici",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// decision.kind: "proxied" | "direct" | "blocked"
|
|
36
|
+
// decision.reason: "managed-proxy-active" | "ambient-proxy-active" | ...
|
|
37
|
+
// decision.surface: "undici"
|
|
38
|
+
// decision.url: "https://api.example.com/v1/users"
|
|
39
|
+
// decision.proxyUrl: "https://proxy.corp.example:8443/" (redacted)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`explain()` does not perform a request; it inspects the resolver and returns its verdict. It also emits a `decision` event, so a single callback can capture every explicit routing probe in your logs.
|
|
43
|
+
|
|
44
|
+
After `stop()`, `explain()` returns `kind: "direct"`, `reason: "runtime-stopped"`.
|
|
45
|
+
|
|
46
|
+
## Credential redaction
|
|
47
|
+
|
|
48
|
+
Anywhere Proxyline reports a proxy URL — in `handle.proxyUrl`, `decision.proxyUrl`, and the `runtime.installed` event — it passes the URL through `redactProxyUrl`. That strips userinfo, search, and fragment:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
redactProxyUrl("https://user:secret@proxy.example:8443/path?token=abc#fragment");
|
|
52
|
+
// → "https://proxy.example:8443/path"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use it on URLs you log yourself to keep credentials out of operational data.
|
|
56
|
+
|
|
57
|
+
## Logging recipes
|
|
58
|
+
|
|
59
|
+
### Per-request decision log
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const proxy = installGlobalProxy({
|
|
63
|
+
mode: "managed",
|
|
64
|
+
proxyUrl: process.env.PROXY_URL!,
|
|
65
|
+
onEvent: (event) => {
|
|
66
|
+
if (event.type === "decision") {
|
|
67
|
+
logger.info({
|
|
68
|
+
url: event.decision.url,
|
|
69
|
+
proxyUrl: event.decision.proxyUrl,
|
|
70
|
+
kind: event.decision.kind,
|
|
71
|
+
reason: event.decision.reason,
|
|
72
|
+
surface: event.decision.surface,
|
|
73
|
+
}, "proxy decision");
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`explain()` is decoupled from real requests, so wire it into the codepaths you actually care about, e.g. inside a fetch wrapper:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
async function loggedFetch(url: string, init?: RequestInit) {
|
|
83
|
+
proxy.explain(url, { surface: "undici" });
|
|
84
|
+
return fetch(url, init);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Install / stop audit
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
onEvent: (event) => {
|
|
92
|
+
if (event.type === "runtime.installed") {
|
|
93
|
+
logger.info({ mode: event.mode, active: event.active, proxyUrl: event.proxyUrl }, "proxyline up");
|
|
94
|
+
} else if (event.type === "runtime.stopped") {
|
|
95
|
+
logger.info({ mode: event.mode }, "proxyline down");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|