@karoc/dsh-proxy 0.1.4 → 0.1.18
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 +416 -0
- package/CONTRIBUTING.md +38 -7
- package/README.md +112 -15
- package/README.zh.md +65 -9
- package/lib/client.js +295 -56
- package/lib/index.js +696 -77
- package/package.json +6 -4
package/lib/index.js
CHANGED
|
@@ -1,9 +1,186 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { Agent, createServer, request } from "node:http";
|
|
6
|
+
import { connect, isIPv6 } from "node:net";
|
|
5
7
|
import { connect as connect$1 } from "node:tls";
|
|
6
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
//#region src/egress.ts
|
|
10
|
+
/**
|
|
11
|
+
* In-process egress for dsh-proxy: put the harness's OWN requests (model calls,
|
|
12
|
+
* web fetch/search) behind this plugin's loopback forward proxy.
|
|
13
|
+
*
|
|
14
|
+
* Why this is not just "set some env vars": dsh resolves its egress policy from
|
|
15
|
+
* the environment it was LAUNCHED with, before any plugin mounts
|
|
16
|
+
* (`apps/cli/src/profile-boot.ts` → `@deepseek-ai/dsh-http-proxy`
|
|
17
|
+
* `installProxyFromEnvironment`), and that package owns undici's global
|
|
18
|
+
* dispatcher. Child processes inherit variables this plugin publishes, but the
|
|
19
|
+
* harness's own `fetch` does not read them afterwards — which is why model
|
|
20
|
+
* traffic stayed direct on a systemd-run `dsh web` while npm/git/curl were
|
|
21
|
+
* proxied.
|
|
22
|
+
*
|
|
23
|
+
* The same public installer is reachable from a plugin, and layering a policy is
|
|
24
|
+
* a designed behavior (the package's own suite covers a second install layered
|
|
25
|
+
* over the launcher's). So when — and only when — the launch environment named
|
|
26
|
+
* no proxy at all, this plugin installs a policy that points http/https at its
|
|
27
|
+
* own listener.
|
|
28
|
+
*
|
|
29
|
+
* The "only when" is a correctness rule, not politeness: this plugin's proxy
|
|
30
|
+
* sends hosts that are not ticked DIRECT, so layering it over a launcher's proxy
|
|
31
|
+
* would silently bypass that launcher's egress policy (a desktop shell or a
|
|
32
|
+
* corporate launcher that set `HTTP_PROXY` on purpose). A launcher that already
|
|
33
|
+
* decides egress keeps deciding it; the settings page says so and offers the
|
|
34
|
+
* port/launch-environment route instead.
|
|
35
|
+
*/
|
|
36
|
+
/** The harness package that owns undici's global dispatcher and the route matcher. */
|
|
37
|
+
const POLICY_PACKAGE = "@deepseek-ai/dsh-http-proxy";
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the policy package the way a plugin installed INTO A PROFILE would:
|
|
40
|
+
* the bare specifier first (hoisted layouts), then anchored at each candidate
|
|
41
|
+
* file — the profile's own `package.json` (that is where the harness resolved it
|
|
42
|
+
* from, so the import lands on the same module instance and `proxyRouteFor`
|
|
43
|
+
* callers see our policy too), the dsh entry point, and the working directory.
|
|
44
|
+
*
|
|
45
|
+
* Resolution is best-effort by design: a failure is REPORTED (the page then
|
|
46
|
+
* tells the user to declare the proxy in the launch environment instead), never
|
|
47
|
+
* silently ignored.
|
|
48
|
+
*/
|
|
49
|
+
function createPolicyImporter(anchors) {
|
|
50
|
+
return async () => {
|
|
51
|
+
const failures = [];
|
|
52
|
+
try {
|
|
53
|
+
return await import(POLICY_PACKAGE);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
failures.push(`bare specifier — ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
for (const anchor of anchors) try {
|
|
58
|
+
const resolved = createRequire(anchor).resolve(POLICY_PACKAGE);
|
|
59
|
+
const module = await import(pathToFileURL(resolved).href);
|
|
60
|
+
if (typeof module.installProxyFromEnvironment !== "function") {
|
|
61
|
+
failures.push(`${anchor} — resolved ${resolved} without installProxyFromEnvironment`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
return module;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
failures.push(`${anchor} — ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`${POLICY_PACKAGE} is not resolvable here (${failures.join(" | ")})`);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Install the harness policy that routes http/https at `proxyUrl`, unless a
|
|
73
|
+
* launcher already declared egress.
|
|
74
|
+
*
|
|
75
|
+
* @param opts.proxyUrl - this plugin's loopback proxy, e.g. `http://127.0.0.1:41234`.
|
|
76
|
+
* @param opts.launch - the launch-time proxy variable, or null when there was none.
|
|
77
|
+
* @param opts.importer - resolves the harness policy module.
|
|
78
|
+
* @param opts.log - receives the policy module's own diagnostics.
|
|
79
|
+
* @returns the outcome, including the disposer that restores the previous policy.
|
|
80
|
+
*/
|
|
81
|
+
async function installInProcessEgress(opts) {
|
|
82
|
+
if (opts.launch !== null) return {
|
|
83
|
+
status: "launcher",
|
|
84
|
+
detail: `${opts.launch.name} was set before dsh started — the launcher owns egress`,
|
|
85
|
+
dispose: null
|
|
86
|
+
};
|
|
87
|
+
let policy;
|
|
88
|
+
try {
|
|
89
|
+
policy = await opts.importer();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
status: "unavailable",
|
|
93
|
+
detail: error.message,
|
|
94
|
+
dispose: null
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const values = {
|
|
98
|
+
HTTP_PROXY: opts.proxyUrl,
|
|
99
|
+
HTTPS_PROXY: opts.proxyUrl,
|
|
100
|
+
NO_PROXY: "127.0.0.1,localhost,::1,[::1]"
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
return {
|
|
104
|
+
status: "self-installed",
|
|
105
|
+
detail: null,
|
|
106
|
+
dispose: await policy.installProxyFromEnvironment({ get(name) {
|
|
107
|
+
const hit = values[name] ?? values[name.toUpperCase()];
|
|
108
|
+
return hit === void 0 ? void 0 : { value: hit };
|
|
109
|
+
} }, opts.log)
|
|
110
|
+
};
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return {
|
|
113
|
+
status: "unavailable",
|
|
114
|
+
detail: error.message,
|
|
115
|
+
dispose: null
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/host-match.ts
|
|
121
|
+
/**
|
|
122
|
+
* Host matching shared by the HOST half (routing, observation, provider list) and
|
|
123
|
+
* the CLIENT half (which row reads *via upstream*). Kept in one module because a
|
|
124
|
+
* second copy is how the IPv6 spellings drifted apart in the first place: a URL
|
|
125
|
+
* parser hands over `[::1]`, Node normalizes an IPv4-mapped literal to
|
|
126
|
+
* `::ffff:7f00:1`, and a CONNECT authority is `[::1]:443` — all the same host.
|
|
127
|
+
*
|
|
128
|
+
* No node builtins here: this module is bundled into the browser bundle too.
|
|
129
|
+
*/
|
|
130
|
+
const LOOPBACK = /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|::1|0\.0\.0\.0)$/i;
|
|
131
|
+
/**
|
|
132
|
+
* Normalize a host so ONE comparison covers every spelling it arrives in.
|
|
133
|
+
* @param host - hostname, possibly bracketed (`[::1]`) or IPv4-mapped.
|
|
134
|
+
* @returns the comparable form (brackets stripped, IPv4-mapped collapsed).
|
|
135
|
+
*/
|
|
136
|
+
function normalizeHostForMatch(host) {
|
|
137
|
+
const bare = String(host ?? "").trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
138
|
+
const dotted = bare.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
139
|
+
if (dotted !== null) return dotted[1];
|
|
140
|
+
const mapped = bare.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
141
|
+
if (mapped === null) return bare;
|
|
142
|
+
const hi = Number.parseInt(mapped[1], 16);
|
|
143
|
+
const lo = Number.parseInt(mapped[2], 16);
|
|
144
|
+
return [
|
|
145
|
+
hi >> 8,
|
|
146
|
+
hi & 255,
|
|
147
|
+
lo >> 8,
|
|
148
|
+
lo & 255
|
|
149
|
+
].join(".");
|
|
150
|
+
}
|
|
151
|
+
/** Whether `host` is a loopback/unspecified target in any of its spellings. */
|
|
152
|
+
function isLoopbackHost(host) {
|
|
153
|
+
return LOOPBACK.test(normalizeHostForMatch(host));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Split a CONNECT authority into the routing host and port. `[v6]:port` keeps its
|
|
157
|
+
* brackets around the ADDRESS only — splitting on ':' turns the whole thing into
|
|
158
|
+
* host `[`, which both defeats the loopback test and pollutes the host list.
|
|
159
|
+
* @param authority - the CONNECT request target.
|
|
160
|
+
* @returns the host and port, or null when the authority is unusable.
|
|
161
|
+
*/
|
|
162
|
+
function parseConnectAuthority(authority) {
|
|
163
|
+
const value = String(authority ?? "");
|
|
164
|
+
const match = value.match(/^\[([^\]]+)\](?::(\d+))?$/) ?? value.match(/^([^:]+):(\d+)$/);
|
|
165
|
+
if (match === null) return null;
|
|
166
|
+
const host = match[1].trim().toLowerCase();
|
|
167
|
+
if (host === "") return null;
|
|
168
|
+
const port = Number(match[2]);
|
|
169
|
+
return {
|
|
170
|
+
host,
|
|
171
|
+
port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 443
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Format host:port for a request line / Host header (brackets for IPv6).
|
|
176
|
+
* @param host - bare or bracketed host.
|
|
177
|
+
* @param port - target port.
|
|
178
|
+
*/
|
|
179
|
+
function formatAuthority(host, port) {
|
|
180
|
+
const bare = String(host ?? "").trim();
|
|
181
|
+
return bare.includes(":") ? `[${bare}]:${port}` : `${bare}:${port}`;
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
7
184
|
//#region src/proxy-core.ts
|
|
8
185
|
/**
|
|
9
186
|
* dsh-proxy core: a loopback forward proxy with per-host routing, ported
|
|
@@ -31,14 +208,64 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
31
208
|
* * loopback targets are ALWAYS direct (never sent to an upstream proxy);
|
|
32
209
|
* * an upstream pointing back at this very proxy is treated as disabled
|
|
33
210
|
* (self-loop guard);
|
|
34
|
-
* * the proxy itself uses raw net/http
|
|
35
|
-
* never route its own outbound connections
|
|
211
|
+
* * the proxy itself uses raw net/http through an explicit Agent (never the
|
|
212
|
+
* environment proxy), so it can never route its own outbound connections
|
|
213
|
+
* back through itself — even when the launch environment sets
|
|
214
|
+
* NODE_USE_ENV_PROXY for the host's own fetch.
|
|
36
215
|
*/
|
|
37
|
-
const LOOPBACK = /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|::1|0\.0\.0\.0)$/i;
|
|
38
216
|
/** Upstream proxy protocol, defaulting to http (legacy configs have no field). */
|
|
39
217
|
function upstreamProtocol(cfg) {
|
|
40
218
|
return String(cfg?.upstream?.protocol || "http").toLowerCase();
|
|
41
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Expand an IPv6 literal into its 16 wire bytes (SOCKS5 ATYP=4). Handles `::`
|
|
222
|
+
* compression, an embedded IPv4 tail (`::ffff:1.2.3.4`) and a zone id
|
|
223
|
+
* (`fe80::1%eth0` — a zone has no wire representation and is dropped).
|
|
224
|
+
* Returns null when the text is not a valid IPv6 address.
|
|
225
|
+
*/
|
|
226
|
+
function ipv6ToBytes(address) {
|
|
227
|
+
let text = String(address ?? "").trim().toLowerCase();
|
|
228
|
+
const zone = text.indexOf("%");
|
|
229
|
+
if (zone >= 0) text = text.slice(0, zone);
|
|
230
|
+
if (text === "") return null;
|
|
231
|
+
const halves = text.split("::");
|
|
232
|
+
if (halves.length > 2) return null;
|
|
233
|
+
const parseGroups = (part) => {
|
|
234
|
+
if (part === "") return [];
|
|
235
|
+
const groups = [];
|
|
236
|
+
for (const group of part.split(":")) {
|
|
237
|
+
if (group.includes(".")) {
|
|
238
|
+
const octets = group.split(".");
|
|
239
|
+
if (octets.length !== 4) return null;
|
|
240
|
+
for (const octet of octets) if (!/^\d{1,3}$/.test(octet) || Number(octet) > 255) return null;
|
|
241
|
+
const [a, b, c, d] = octets.map(Number);
|
|
242
|
+
groups.push(a << 8 | b, c << 8 | d);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
|
|
246
|
+
groups.push(Number.parseInt(group, 16));
|
|
247
|
+
}
|
|
248
|
+
return groups;
|
|
249
|
+
};
|
|
250
|
+
const head = parseGroups(halves[0]);
|
|
251
|
+
if (head === null) return null;
|
|
252
|
+
let groups;
|
|
253
|
+
if (halves.length === 2) {
|
|
254
|
+
const tail = parseGroups(halves[1]);
|
|
255
|
+
if (tail === null) return null;
|
|
256
|
+
const missing = 8 - head.length - tail.length;
|
|
257
|
+
if (missing < 1) return null;
|
|
258
|
+
groups = [
|
|
259
|
+
...head,
|
|
260
|
+
...new Array(missing).fill(0),
|
|
261
|
+
...tail
|
|
262
|
+
];
|
|
263
|
+
} else {
|
|
264
|
+
if (head.length !== 8) return null;
|
|
265
|
+
groups = head;
|
|
266
|
+
}
|
|
267
|
+
return Buffer.from(groups.flatMap((group) => [group >> 8 & 255, group & 255]));
|
|
268
|
+
}
|
|
42
269
|
/** Read exactly n bytes from a socket (SOCKS5 fixed-size frames). */
|
|
43
270
|
function readExactly(socket, n, timeoutMs = 1e4) {
|
|
44
271
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
@@ -130,7 +357,11 @@ async function socks5Handshake(socket, upstream, targetHost, targetPort) {
|
|
|
130
357
|
const isIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(targetHost);
|
|
131
358
|
let addrPart;
|
|
132
359
|
if (isIpv4) addrPart = Buffer.concat([Buffer.from([1]), ...targetHost.split(".").map((o) => Buffer.from([Number(o) & 255]))]);
|
|
133
|
-
else {
|
|
360
|
+
else if (isIPv6(targetHost)) {
|
|
361
|
+
const bytes = ipv6ToBytes(targetHost);
|
|
362
|
+
if (bytes === null) throw new Error(`socks5: cannot encode IPv6 target ${targetHost}`);
|
|
363
|
+
addrPart = Buffer.concat([Buffer.from([4]), bytes]);
|
|
364
|
+
} else {
|
|
134
365
|
const name = Buffer.from(targetHost, "ascii");
|
|
135
366
|
addrPart = Buffer.concat([Buffer.from([3, name.length]), name]);
|
|
136
367
|
}
|
|
@@ -187,7 +418,7 @@ function openUpstreamTunnel(cfg, targetHost, targetPort, authHeader) {
|
|
|
187
418
|
rest: Buffer.alloc(0)
|
|
188
419
|
});
|
|
189
420
|
} else {
|
|
190
|
-
const lines = [`CONNECT ${targetHost
|
|
421
|
+
const lines = [`CONNECT ${formatAuthority(targetHost, targetPort)} HTTP/1.1`, `Host: ${formatAuthority(targetHost, targetPort)}`];
|
|
191
422
|
if (authHeader) lines.push(`Proxy-Authorization: ${authHeader}`);
|
|
192
423
|
socket.write(lines.join("\r\n") + "\r\n\r\n");
|
|
193
424
|
const { head, rest } = await readUntil(socket, "\r\n\r\n");
|
|
@@ -215,29 +446,148 @@ function defaultProxyConfig() {
|
|
|
215
446
|
password: ""
|
|
216
447
|
},
|
|
217
448
|
proxiedHosts: [],
|
|
218
|
-
knownHosts: []
|
|
449
|
+
knownHosts: [],
|
|
450
|
+
listenPort: 0,
|
|
451
|
+
routeMode: "only-listed"
|
|
219
452
|
};
|
|
220
453
|
}
|
|
221
454
|
/** Read <configFile> (proxy.json), tolerant of a missing/corrupt file. */
|
|
455
|
+
const lastGoodConfig = /* @__PURE__ */ new Map();
|
|
456
|
+
/**
|
|
457
|
+
* Write a file so that a concurrent reader can never observe a half-written
|
|
458
|
+
* document: the bytes land in a sibling temp file first and `rename` swaps it in
|
|
459
|
+
* atomically. Without this, a read landing between truncate and write parses as
|
|
460
|
+
* broken and the request it belongs to falls back to the DEFAULT config — the
|
|
461
|
+
* upstream silently stops being used for that request.
|
|
462
|
+
*/
|
|
463
|
+
function writeFileAtomic(file, text) {
|
|
464
|
+
const temp = `${file}.tmp-${process.pid}`;
|
|
465
|
+
try {
|
|
466
|
+
writeFileSync(temp, text);
|
|
467
|
+
renameSync(temp, file);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
try {
|
|
470
|
+
unlinkSync(temp);
|
|
471
|
+
} catch {}
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** Read + sanitize proxy.json, remembering the last document that parsed. */
|
|
222
476
|
function readProxyConfig(configFile) {
|
|
223
477
|
try {
|
|
224
478
|
const raw = JSON.parse(readFileSync(configFile, "utf8"));
|
|
225
|
-
|
|
479
|
+
const cfg = {
|
|
226
480
|
...defaultProxyConfig(),
|
|
227
481
|
...raw ?? {},
|
|
228
482
|
upstream: {
|
|
229
483
|
...defaultProxyConfig().upstream,
|
|
230
484
|
...raw?.upstream ?? {}
|
|
231
|
-
}
|
|
485
|
+
},
|
|
486
|
+
listenPort: normalizeListenPort(raw?.listenPort),
|
|
487
|
+
routeMode: normalizeRouteMode(raw?.routeMode)
|
|
232
488
|
};
|
|
489
|
+
lastGoodConfig.set(configFile, cfg);
|
|
490
|
+
return cfg;
|
|
233
491
|
} catch {
|
|
234
|
-
return defaultProxyConfig();
|
|
492
|
+
return lastGoodConfig.get(configFile) ?? defaultProxyConfig();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The endpoint the harness itself uses when `llm-deepseek` configures no
|
|
497
|
+
* `baseURL`: its schema has no default for that field, so the live config carries
|
|
498
|
+
* no host to read and the DeepSeek route would be missing from the list even
|
|
499
|
+
* though it carries real traffic. Keep in sync with the plugin's public constant
|
|
500
|
+
* (`PUBLIC_BASE_URL` = https://api.deepseek.com/anthropic).
|
|
501
|
+
*/
|
|
502
|
+
const DEEPSEEK_PUBLIC_BASE_URL = "https://api.deepseek.com/anthropic";
|
|
503
|
+
/** Friendly name for a namespace whose providers carry no `displayName`. */
|
|
504
|
+
function defaultDisplayName(ns) {
|
|
505
|
+
return ns === "llm-deepseek" ? "DeepSeek" : null;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Append one provider unit's hosts. `name` is the dsh namespace (or
|
|
509
|
+
* `ns/provider`); loopback hosts are dropped — they can never be proxied
|
|
510
|
+
* (routing sends them direct no matter what), so offering them as a toggle
|
|
511
|
+
* would be a checkbox that does nothing.
|
|
512
|
+
*/
|
|
513
|
+
function pushProviderHosts(out, unit) {
|
|
514
|
+
const name = unit.provider ? `${unit.ns}/${unit.provider}` : unit.ns;
|
|
515
|
+
const displayName = unit.displayName || defaultDisplayName(unit.ns);
|
|
516
|
+
for (const baseUrl of unit.baseUrls) for (const candidate of String(baseUrl).split(",").map((s) => s.trim()).filter(Boolean)) try {
|
|
517
|
+
const host = new URL(candidate).hostname;
|
|
518
|
+
if (host && !isLoopbackHost(host)) out.push({
|
|
519
|
+
name,
|
|
520
|
+
...displayName ? { displayName } : {},
|
|
521
|
+
host
|
|
522
|
+
});
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
/** True when `value` is a plain object we can read fields from. */
|
|
526
|
+
function isRecord(value) {
|
|
527
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Model provider hosts from the LIVE settings document — the shape
|
|
531
|
+
* `settings.describe()` answers with (and the GUI's own model page renders).
|
|
532
|
+
* Sources of truth move: dsh ≥ 0.2 imports the old `<home>/settings.yaml` into
|
|
533
|
+
* the active profile's config document and deletes it, so a file reader
|
|
534
|
+
* silently returns nothing. Reading the harness's own live values survives
|
|
535
|
+
* that (and any future document move).
|
|
536
|
+
*
|
|
537
|
+
* Accepts the descriptor container (`{ namespaces: [...] }`) or the bare array,
|
|
538
|
+
* and reads each `llm-*` namespace: `providers.<key>.{baseURL, displayName}`
|
|
539
|
+
* (llm-pi-ai) or a namespace-level `baseURL` (llm-deepseek). Non-`llm-*`
|
|
540
|
+
* namespaces are ignored — a settings document holds every plugin's entries,
|
|
541
|
+
* and this list is the model provider list.
|
|
542
|
+
*/
|
|
543
|
+
function providerHostsFromEntries(entries) {
|
|
544
|
+
const list = Array.isArray(entries) ? entries : isRecord(entries) && Array.isArray(entries["namespaces"]) ? entries["namespaces"] : [];
|
|
545
|
+
const out = [];
|
|
546
|
+
for (const entry of list) {
|
|
547
|
+
if (!isRecord(entry)) continue;
|
|
548
|
+
const ns = typeof entry["ns"] === "string" ? entry["ns"] : "";
|
|
549
|
+
if (!ns.startsWith("llm-")) continue;
|
|
550
|
+
const value = entry["value"];
|
|
551
|
+
if (!isRecord(value)) continue;
|
|
552
|
+
const before = out.length;
|
|
553
|
+
if (isRecord(value["providers"])) for (const [provider, block] of Object.entries(value["providers"])) {
|
|
554
|
+
if (!isRecord(block)) continue;
|
|
555
|
+
pushProviderHosts(out, {
|
|
556
|
+
ns,
|
|
557
|
+
provider,
|
|
558
|
+
displayName: (typeof block["displayName"] === "string" ? block["displayName"].trim() : "") || null,
|
|
559
|
+
baseUrls: typeof block["baseURL"] === "string" ? [block["baseURL"]] : []
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
else if (typeof value["baseURL"] === "string") pushProviderHosts(out, {
|
|
563
|
+
ns,
|
|
564
|
+
provider: null,
|
|
565
|
+
displayName: null,
|
|
566
|
+
baseUrls: [value["baseURL"]]
|
|
567
|
+
});
|
|
568
|
+
if (ns === "llm-deepseek" && out.length === before) pushProviderHosts(out, {
|
|
569
|
+
ns,
|
|
570
|
+
provider: null,
|
|
571
|
+
displayName: "DeepSeek",
|
|
572
|
+
baseUrls: [DEEPSEEK_PUBLIC_BASE_URL]
|
|
573
|
+
});
|
|
235
574
|
}
|
|
575
|
+
return out;
|
|
236
576
|
}
|
|
237
577
|
/**
|
|
238
|
-
*
|
|
239
|
-
* (
|
|
240
|
-
*
|
|
578
|
+
* Resolve the model-provider list from the best source available: the LIVE
|
|
579
|
+
* settings document when the harness exposes one (`live` is an array — empty
|
|
580
|
+
* included), else the legacy `<home>/settings.yaml` of dsh < 0.2. A readable
|
|
581
|
+
* live document always wins: on dsh ≥ 0.2 the legacy file has been renamed
|
|
582
|
+
* `.imported`, so falling back to it could only resurrect stale hosts.
|
|
583
|
+
*/
|
|
584
|
+
function resolveProviderHosts(opts) {
|
|
585
|
+
return opts.live !== void 0 ? providerHostsFromEntries(opts.live) : providerHostsFromSettings(opts.legacyPath);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Best-effort extraction of model provider hosts from a LEGACY dsh settings.yaml
|
|
589
|
+
* (dsh < 0.2: llm-deepseek.baseURL / llm-pi-ai.providers.<n>.baseURL / any other
|
|
590
|
+
* llm-* namespace). Returns [{name, host, displayName?}]: `name` is the dsh
|
|
241
591
|
* namespace (or `ns/provider`), `displayName` the friendly name when known
|
|
242
592
|
* (llm-pi-ai providers carry a `displayName` field; llm-deepseek is fixed to
|
|
243
593
|
* "DeepSeek"). Empty when unreadable — the proxy's observed-host list still
|
|
@@ -252,16 +602,7 @@ function providerHostsFromSettings(settingsPath) {
|
|
|
252
602
|
return out;
|
|
253
603
|
}
|
|
254
604
|
const push = (current) => {
|
|
255
|
-
|
|
256
|
-
const displayName = current.displayName || (current.ns === "llm-deepseek" ? "DeepSeek" : null);
|
|
257
|
-
for (const baseUrl of current.baseUrls) for (const candidate of String(baseUrl).split(",").map((s) => s.trim()).filter(Boolean)) try {
|
|
258
|
-
const host = new URL(candidate).hostname;
|
|
259
|
-
if (host) out.push({
|
|
260
|
-
name,
|
|
261
|
-
...displayName ? { displayName } : {},
|
|
262
|
-
host
|
|
263
|
-
});
|
|
264
|
-
} catch {}
|
|
605
|
+
pushProviderHosts(out, current);
|
|
265
606
|
};
|
|
266
607
|
let ns = null;
|
|
267
608
|
let inProviders = false;
|
|
@@ -334,21 +675,27 @@ function providerHostsFromSettings(settingsPath) {
|
|
|
334
675
|
*/
|
|
335
676
|
function shouldProxy(cfg, host, selfPort) {
|
|
336
677
|
const h = String(host ?? "").toLowerCase();
|
|
337
|
-
if (
|
|
678
|
+
if (isLoopbackHost(h)) return false;
|
|
338
679
|
const u = cfg?.upstream;
|
|
339
|
-
const selfRef = u?.host &&
|
|
340
|
-
|
|
680
|
+
const selfRef = u?.host && isLoopbackHost(u.host) && Number(u.port) === selfPort;
|
|
681
|
+
if (!Boolean(u?.enabled) || selfRef) return false;
|
|
682
|
+
const wanted = normalizeHostForMatch(h);
|
|
683
|
+
const listed = (cfg?.proxiedHosts ?? []).some((entry) => normalizeHostForMatch(entry) === wanted);
|
|
684
|
+
return normalizeRouteMode(cfg?.routeMode) === "except-listed" ? !listed : listed;
|
|
341
685
|
}
|
|
342
686
|
/**
|
|
343
687
|
* Start a forward proxy bound to 127.0.0.1:<random>.
|
|
344
688
|
*/
|
|
345
689
|
function createForwardProxy(opts) {
|
|
346
690
|
const { configFile, onHosts, log = () => {} } = opts;
|
|
691
|
+
const requestedPort = normalizeListenPort(opts.listenPort ?? 0);
|
|
692
|
+
let boundPort = null;
|
|
347
693
|
const seenHosts = /* @__PURE__ */ new Set();
|
|
348
694
|
let notifyTimer = null;
|
|
695
|
+
const outboundAgent = new Agent({ keepAlive: true });
|
|
349
696
|
const markHost = (host) => {
|
|
350
697
|
const h = String(host ?? "").trim().toLowerCase();
|
|
351
|
-
if (!h ||
|
|
698
|
+
if (!h || isLoopbackHost(h)) return;
|
|
352
699
|
if (seenHosts.has(h)) return;
|
|
353
700
|
seenHosts.add(h);
|
|
354
701
|
if (notifyTimer) clearTimeout(notifyTimer);
|
|
@@ -414,6 +761,7 @@ function createForwardProxy(opts) {
|
|
|
414
761
|
const headers = { ...req.headers };
|
|
415
762
|
delete headers["proxy-connection"];
|
|
416
763
|
delete headers["connection"];
|
|
764
|
+
headers.host = u.host;
|
|
417
765
|
let useUpstream = via === "upstream";
|
|
418
766
|
if (useUpstream && upstreamProtocol(cfg) === "socks5") {
|
|
419
767
|
log(`socks5 upstream: http target ${host} falls back to direct`);
|
|
@@ -422,32 +770,48 @@ function createForwardProxy(opts) {
|
|
|
422
770
|
if (useUpstream) {
|
|
423
771
|
const auth = upstreamAuth(cfg);
|
|
424
772
|
if (auth) headers["proxy-authorization"] = auth;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
773
|
+
let up;
|
|
774
|
+
try {
|
|
775
|
+
up = request({
|
|
776
|
+
host: cfg.upstream.host,
|
|
777
|
+
port: Number(cfg.upstream.port),
|
|
778
|
+
method: req.method,
|
|
779
|
+
path: req.url ?? "",
|
|
780
|
+
headers,
|
|
781
|
+
agent: outboundAgent
|
|
782
|
+
}, (upRes) => {
|
|
783
|
+
res.writeHead(upRes.statusCode ?? 502, upRes.headers);
|
|
784
|
+
upRes.pipe(res);
|
|
785
|
+
});
|
|
786
|
+
} catch (e) {
|
|
787
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
788
|
+
res.end(`proxy: upstream error: ${e.message}`);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
435
791
|
up.on("error", (e) => {
|
|
436
792
|
res.writeHead(502, { "content-type": "text/plain" });
|
|
437
793
|
res.end(`proxy: upstream error: ${e.message}`);
|
|
438
794
|
});
|
|
439
795
|
req.pipe(up);
|
|
440
796
|
} else {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
797
|
+
let up;
|
|
798
|
+
try {
|
|
799
|
+
up = request({
|
|
800
|
+
host,
|
|
801
|
+
port,
|
|
802
|
+
method: req.method,
|
|
803
|
+
path: u.pathname + u.search,
|
|
804
|
+
headers,
|
|
805
|
+
agent: outboundAgent
|
|
806
|
+
}, (upRes) => {
|
|
807
|
+
res.writeHead(upRes.statusCode ?? 502, upRes.headers);
|
|
808
|
+
upRes.pipe(res);
|
|
809
|
+
});
|
|
810
|
+
} catch (e) {
|
|
811
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
812
|
+
res.end(`proxy: origin error: ${e.message}`);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
451
815
|
up.on("error", (e) => {
|
|
452
816
|
res.writeHead(502, { "content-type": "text/plain" });
|
|
453
817
|
res.end(`proxy: origin error: ${e.message}`);
|
|
@@ -456,13 +820,12 @@ function createForwardProxy(opts) {
|
|
|
456
820
|
}
|
|
457
821
|
});
|
|
458
822
|
server.on("connect", (req, clientSocket, head) => {
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
const port = Number(portRaw) || 443;
|
|
462
|
-
if (!host || !port) {
|
|
823
|
+
const target = parseConnectAuthority(String(req.url ?? ""));
|
|
824
|
+
if (target === null) {
|
|
463
825
|
clientSocket.destroy();
|
|
464
826
|
return;
|
|
465
827
|
}
|
|
828
|
+
const { host, port } = target;
|
|
466
829
|
markHost(host);
|
|
467
830
|
const { via, cfg } = routeFor(host, server.address()?.port);
|
|
468
831
|
const rejectClient = (reason) => {
|
|
@@ -496,8 +859,31 @@ function createForwardProxy(opts) {
|
|
|
496
859
|
});
|
|
497
860
|
return {
|
|
498
861
|
port: new Promise((resolve) => {
|
|
499
|
-
|
|
862
|
+
const listen = (candidate, isFallback) => {
|
|
863
|
+
const onError = (error) => {
|
|
864
|
+
server.removeListener("listening", onListening);
|
|
865
|
+
if (candidate > 0 && !isFallback) {
|
|
866
|
+
log(`proxy: listen port ${candidate} unavailable (${error.code ?? error.message}) — falling back to an ephemeral port; a launcher pointing at ${candidate} will not reach this proxy`);
|
|
867
|
+
listen(0, true);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
log(`proxy: listen failed: ${error.message}`);
|
|
871
|
+
resolve(0);
|
|
872
|
+
};
|
|
873
|
+
const onListening = () => {
|
|
874
|
+
server.removeListener("error", onError);
|
|
875
|
+
boundPort = server.address()?.port ?? 0;
|
|
876
|
+
resolve(boundPort);
|
|
877
|
+
};
|
|
878
|
+
server.once("error", onError);
|
|
879
|
+
server.once("listening", onListening);
|
|
880
|
+
server.listen(candidate, "127.0.0.1");
|
|
881
|
+
};
|
|
882
|
+
listen(requestedPort, false);
|
|
500
883
|
}),
|
|
884
|
+
requestedPort,
|
|
885
|
+
portHonored: () => requestedPort > 0 && boundPort === requestedPort,
|
|
886
|
+
address: () => server.address()?.address ?? null,
|
|
501
887
|
hosts: () => [...seenHosts].sort(),
|
|
502
888
|
config,
|
|
503
889
|
/** Merge observed hosts into proxy.json's knownHosts (UI candidate list). */
|
|
@@ -505,7 +891,7 @@ function createForwardProxy(opts) {
|
|
|
505
891
|
try {
|
|
506
892
|
const cfg = config();
|
|
507
893
|
const merged = [.../* @__PURE__ */ new Set([...cfg.knownHosts ?? [], ...seenHosts])].sort();
|
|
508
|
-
|
|
894
|
+
writeFileAtomic(configFile, JSON.stringify({
|
|
509
895
|
...cfg,
|
|
510
896
|
knownHosts: merged
|
|
511
897
|
}, null, 2) + "\n");
|
|
@@ -513,8 +899,28 @@ function createForwardProxy(opts) {
|
|
|
513
899
|
log(`proxy: persist known hosts failed: ${e.message}`);
|
|
514
900
|
}
|
|
515
901
|
},
|
|
902
|
+
/**
|
|
903
|
+
* Clear the observed-host list. The session's own set is cleared too: leaving
|
|
904
|
+
* it would make the very next persist merge the just-forgotten hosts back into
|
|
905
|
+
* proxy.json, so the button would look like it did nothing.
|
|
906
|
+
*/
|
|
907
|
+
forgetHosts: () => {
|
|
908
|
+
seenHosts.clear();
|
|
909
|
+
if (notifyTimer) clearTimeout(notifyTimer);
|
|
910
|
+
try {
|
|
911
|
+
const cfg = config();
|
|
912
|
+
writeFileAtomic(configFile, JSON.stringify({
|
|
913
|
+
...cfg,
|
|
914
|
+
knownHosts: []
|
|
915
|
+
}, null, 2) + "\n");
|
|
916
|
+
} catch (e) {
|
|
917
|
+
log(`proxy: clear known hosts failed: ${e.message}`);
|
|
918
|
+
}
|
|
919
|
+
onHosts?.([]);
|
|
920
|
+
},
|
|
516
921
|
close: () => {
|
|
517
922
|
if (notifyTimer) clearTimeout(notifyTimer);
|
|
923
|
+
outboundAgent.destroy();
|
|
518
924
|
server.close();
|
|
519
925
|
}
|
|
520
926
|
};
|
|
@@ -553,23 +959,25 @@ function sanitizeUpstream(v) {
|
|
|
553
959
|
* arbitrary keys). Takes effect immediately: the running proxy re-reads
|
|
554
960
|
* proxy.json on every request.
|
|
555
961
|
*/
|
|
556
|
-
function writeProxyConfig(configFile,
|
|
962
|
+
function writeProxyConfig(configFile, patch) {
|
|
557
963
|
const cfg = readProxyConfig(configFile);
|
|
558
964
|
const hosts = [];
|
|
559
|
-
for (const h of proxiedHosts ?? []) {
|
|
965
|
+
for (const h of patch.proxiedHosts ?? cfg.proxiedHosts ?? []) {
|
|
560
966
|
const t = normalizeHost(h);
|
|
561
967
|
if (t !== "") hosts.push(t);
|
|
562
968
|
}
|
|
563
969
|
const next = {
|
|
564
970
|
...cfg,
|
|
565
|
-
upstream: sanitizeUpstream(upstream),
|
|
566
|
-
proxiedHosts: hosts
|
|
971
|
+
upstream: patch.upstream === void 0 ? cfg.upstream : sanitizeUpstream(patch.upstream),
|
|
972
|
+
proxiedHosts: hosts,
|
|
973
|
+
listenPort: patch.listenPort === void 0 ? cfg.listenPort : normalizeListenPort(patch.listenPort),
|
|
974
|
+
routeMode: patch.routeMode === void 0 ? cfg.routeMode : normalizeRouteMode(patch.routeMode)
|
|
567
975
|
};
|
|
568
976
|
const text = JSON.stringify(next, null, 2);
|
|
569
977
|
try {
|
|
570
978
|
mkdirSync(dirname(configFile), { recursive: true });
|
|
571
979
|
} catch {}
|
|
572
|
-
|
|
980
|
+
writeFileAtomic(configFile, text + "\n");
|
|
573
981
|
return next;
|
|
574
982
|
}
|
|
575
983
|
/**
|
|
@@ -756,6 +1164,17 @@ function testUpstreamProxy(upstream) {
|
|
|
756
1164
|
});
|
|
757
1165
|
});
|
|
758
1166
|
}
|
|
1167
|
+
/** Coerce a stored/edited route mode; anything unknown means the default. */
|
|
1168
|
+
function normalizeRouteMode(value) {
|
|
1169
|
+
return value === "except-listed" ? "except-listed" : "only-listed";
|
|
1170
|
+
}
|
|
1171
|
+
/** Coerce a stored/edited listen port: integer 1..65535, anything else -> 0 (ephemeral). */
|
|
1172
|
+
function normalizeListenPort(value) {
|
|
1173
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
1174
|
+
if (!Number.isFinite(n)) return 0;
|
|
1175
|
+
const port = Math.trunc(n);
|
|
1176
|
+
return port >= 1 && port <= 65535 ? port : 0;
|
|
1177
|
+
}
|
|
759
1178
|
//#endregion
|
|
760
1179
|
//#region src/index.ts
|
|
761
1180
|
const name = "@karoc/dsh-proxy";
|
|
@@ -770,7 +1189,7 @@ function resolveDshHome() {
|
|
|
770
1189
|
function proxyConfigPath(home = resolveDshHome()) {
|
|
771
1190
|
return join(home, "proxy.json");
|
|
772
1191
|
}
|
|
773
|
-
/** The dsh settings.yaml
|
|
1192
|
+
/** The LEGACY dsh settings.yaml (dsh < 0.2), kept as the fallback provider source. */
|
|
774
1193
|
function settingsPath(home = resolveDshHome()) {
|
|
775
1194
|
return join(home, "settings.yaml");
|
|
776
1195
|
}
|
|
@@ -797,26 +1216,133 @@ function readBody(req) {
|
|
|
797
1216
|
req.on("error", reject);
|
|
798
1217
|
});
|
|
799
1218
|
}
|
|
1219
|
+
/** Environment names a launcher may have used to point dsh at a proxy, in resolution order. */
|
|
1220
|
+
const LAUNCH_PROXY_NAMES = [
|
|
1221
|
+
"HTTP_PROXY",
|
|
1222
|
+
"http_proxy",
|
|
1223
|
+
"HTTPS_PROXY",
|
|
1224
|
+
"https_proxy",
|
|
1225
|
+
"ALL_PROXY",
|
|
1226
|
+
"all_proxy"
|
|
1227
|
+
];
|
|
1228
|
+
/**
|
|
1229
|
+
* Read the launch-time proxy variable, before {@link startProxy} publishes this
|
|
1230
|
+
* plugin's own values into the environment. dsh resolves its egress policy from
|
|
1231
|
+
* the launch environment BEFORE plugins mount, so this snapshot is the only
|
|
1232
|
+
* evidence of whether the harness's own requests are routed here.
|
|
1233
|
+
*/
|
|
1234
|
+
function readLaunchProxy() {
|
|
1235
|
+
for (const name of LAUNCH_PROXY_NAMES) {
|
|
1236
|
+
const value = process.env[name];
|
|
1237
|
+
if (typeof value === "string" && value.trim() !== "") return {
|
|
1238
|
+
name,
|
|
1239
|
+
value: value.trim()
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
/** Mask credentials in a proxy URL before it leaves the process (the page shows this). */
|
|
1245
|
+
function maskProxyUrl(value) {
|
|
1246
|
+
try {
|
|
1247
|
+
const url = new URL(value);
|
|
1248
|
+
if (url.username !== "" || url.password !== "") {
|
|
1249
|
+
url.username = "***";
|
|
1250
|
+
url.password = "***";
|
|
1251
|
+
}
|
|
1252
|
+
return url.toString();
|
|
1253
|
+
} catch {
|
|
1254
|
+
return value.replace(/\/\/[^@/]*@/, "//***:***@");
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* The loopback port a launch proxy names, when that proxy is this plugin.
|
|
1259
|
+
*
|
|
1260
|
+
* A launcher that declares `HTTP_PROXY=http://127.0.0.1:<port>` did so because
|
|
1261
|
+
* it expects THIS proxy to be at that address (that is the 0.1.7 contract), so an
|
|
1262
|
+
* ephemeral configuration adopts it rather than leaving the launcher pointed at
|
|
1263
|
+
* nothing. A port already serving something else simply fails to bind, and the
|
|
1264
|
+
* plugin falls back to an ephemeral one — which is also the correct outcome when
|
|
1265
|
+
* the launch proxy is a *different* local proxy, since that one keeps owning
|
|
1266
|
+
* egress.
|
|
1267
|
+
*/
|
|
1268
|
+
function launchProxyLoopbackPort(launch) {
|
|
1269
|
+
if (launch === null) return 0;
|
|
1270
|
+
try {
|
|
1271
|
+
const url = new URL(launch.value);
|
|
1272
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
1273
|
+
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") return 0;
|
|
1274
|
+
const port = Number(url.port);
|
|
1275
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 0;
|
|
1276
|
+
} catch {
|
|
1277
|
+
return 0;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/** Whether a launch proxy URL addresses `port` on loopback. */
|
|
1281
|
+
function launchProxyTargetsPort(launch, port) {
|
|
1282
|
+
if (launch === null || port === null || port <= 0) return false;
|
|
1283
|
+
try {
|
|
1284
|
+
const url = new URL(launch.value);
|
|
1285
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
1286
|
+
return (host === "127.0.0.1" || host === "localhost" || host === "::1") && url.port === String(port);
|
|
1287
|
+
} catch {
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* The live settings document, when this composition exposes one. `settings.
|
|
1293
|
+
* describe()` answers with every active entry's CURRENT values — the same call
|
|
1294
|
+
* the GUI's own settings pages read — so the provider list follows the running
|
|
1295
|
+
* configuration instead of a file whose name and shape upstream is free to
|
|
1296
|
+
* change (dsh ≥ 0.2 moved it into the profile document and deleted the legacy
|
|
1297
|
+
* `settings.yaml`, which is exactly how this list went empty).
|
|
1298
|
+
*
|
|
1299
|
+
* `undefined` when no settings service is in the composition or the read
|
|
1300
|
+
* throws: the caller then falls back to the legacy `<home>/settings.yaml`.
|
|
1301
|
+
*/
|
|
1302
|
+
function liveSettingsDocument(ctx) {
|
|
1303
|
+
try {
|
|
1304
|
+
const settings = ctx.get("settings");
|
|
1305
|
+
if (settings === void 0 || typeof settings.describe !== "function") return void 0;
|
|
1306
|
+
return settings.describe();
|
|
1307
|
+
} catch {
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
800
1311
|
/** Build the fresh settings view. */
|
|
801
|
-
function viewOf(forwardProxy) {
|
|
1312
|
+
function viewOf(forwardProxy, providers, launch, egress) {
|
|
802
1313
|
const cfg = forwardProxy.config();
|
|
1314
|
+
const port = forwardProxy.currentPort;
|
|
1315
|
+
const policy = egress();
|
|
803
1316
|
return {
|
|
804
1317
|
ok: true,
|
|
805
|
-
port
|
|
1318
|
+
port,
|
|
1319
|
+
listenPort: cfg.listenPort,
|
|
1320
|
+
portRequested: forwardProxy.requestedPort,
|
|
1321
|
+
portHonored: forwardProxy.portHonored(),
|
|
1322
|
+
launchProxy: launch === null ? null : maskProxyUrl(launch.value),
|
|
1323
|
+
inProcessRouted: policy.status === "self-installed" || launchProxyTargetsPort(launch, port),
|
|
1324
|
+
egressStatus: policy.status,
|
|
1325
|
+
egressDetail: policy.detail,
|
|
806
1326
|
upstream: cfg.upstream,
|
|
807
1327
|
proxiedHosts: cfg.proxiedHosts,
|
|
1328
|
+
routeMode: cfg.routeMode,
|
|
808
1329
|
knownHosts: cfg.knownHosts,
|
|
809
1330
|
hosts: forwardProxy.hosts(),
|
|
810
|
-
providers:
|
|
1331
|
+
providers: providers()
|
|
811
1332
|
};
|
|
812
1333
|
}
|
|
813
1334
|
/** Start the loopback forward proxy and re-point the process at it. */
|
|
814
1335
|
function startProxy(opts) {
|
|
815
|
-
const { configFile, log } = opts;
|
|
1336
|
+
const { configFile, launch, log } = opts;
|
|
816
1337
|
let currentPort = null;
|
|
817
1338
|
let persistTimer = null;
|
|
1339
|
+
const configuredPort = readProxyConfig(configFile).listenPort;
|
|
1340
|
+
const adoptedPort = configuredPort > 0 ? 0 : launchProxyLoopbackPort(launch);
|
|
1341
|
+
const requestedPort = configuredPort > 0 ? configuredPort : adoptedPort;
|
|
1342
|
+
if (adoptedPort > 0) log(`dsh-proxy: adopting the launch environment's proxy port 127.0.0.1:${adoptedPort}`);
|
|
818
1343
|
const handle = createForwardProxy({
|
|
819
1344
|
configFile,
|
|
1345
|
+
listenPort: requestedPort,
|
|
820
1346
|
onHosts: (hosts) => {
|
|
821
1347
|
if (persistTimer) clearTimeout(persistTimer);
|
|
822
1348
|
persistTimer = setTimeout(() => {
|
|
@@ -831,17 +1357,24 @@ function startProxy(opts) {
|
|
|
831
1357
|
const wrapped = {
|
|
832
1358
|
port: handle.port,
|
|
833
1359
|
currentPort,
|
|
1360
|
+
requestedPort: handle.requestedPort,
|
|
1361
|
+
portHonored: handle.portHonored,
|
|
834
1362
|
hosts: handle.hosts,
|
|
835
1363
|
config: handle.config,
|
|
836
1364
|
persistKnownHosts: handle.persistKnownHosts,
|
|
1365
|
+
forgetHosts: handle.forgetHosts,
|
|
837
1366
|
close: () => {
|
|
838
1367
|
if (persistTimer) clearTimeout(persistTimer);
|
|
839
1368
|
handle.close();
|
|
840
1369
|
}
|
|
841
1370
|
};
|
|
842
1371
|
handle.port.then((port) => {
|
|
1372
|
+
if (port <= 0) {
|
|
1373
|
+
log("dsh-proxy: forward proxy could not bind any port — leaving the environment untouched");
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
843
1376
|
wrapped.currentPort = port;
|
|
844
|
-
log(`dsh-proxy: forward proxy on 127.0.0.1:${port}`);
|
|
1377
|
+
log(`dsh-proxy: forward proxy on 127.0.0.1:${port}${handle.portHonored() ? "" : ` (requested ${handle.requestedPort}; a launcher pointing at it will not reach this proxy)`}`);
|
|
845
1378
|
process.env.NODE_USE_ENV_PROXY = "1";
|
|
846
1379
|
process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
|
|
847
1380
|
process.env.HTTPS_PROXY = `http://127.0.0.1:${port}`;
|
|
@@ -858,13 +1391,31 @@ function startProxy(opts) {
|
|
|
858
1391
|
return wrapped;
|
|
859
1392
|
}
|
|
860
1393
|
/** Register the /proxy/api route (GET view, POST save/test/persist). */
|
|
861
|
-
function registerWebApi(ctx, forwardProxy) {
|
|
1394
|
+
function registerWebApi(ctx, forwardProxy, providers, launch, egress) {
|
|
862
1395
|
const server = ctx.get("webServer");
|
|
863
1396
|
if (server === void 0) return;
|
|
1397
|
+
const admissionRejection = (req) => {
|
|
1398
|
+
const connection = ctx.get?.("connection");
|
|
1399
|
+
if (connection === void 0 || typeof connection.admit !== "function") return void 0;
|
|
1400
|
+
try {
|
|
1401
|
+
const rejection = connection.admit(req)?.rejection;
|
|
1402
|
+
return rejection === 401 || rejection === 403 ? rejection : void 0;
|
|
1403
|
+
} catch {
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
864
1407
|
server.register({
|
|
865
1408
|
kind: "prefix",
|
|
866
1409
|
path: "/proxy/api",
|
|
867
1410
|
handler: (req, res) => {
|
|
1411
|
+
const rejection = admissionRejection(req);
|
|
1412
|
+
if (rejection !== void 0) {
|
|
1413
|
+
sendJson(res, rejection, {
|
|
1414
|
+
ok: false,
|
|
1415
|
+
error: rejection === 401 ? "unauthorized" : "forbidden"
|
|
1416
|
+
});
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
868
1419
|
handle(req, res).catch((error) => {
|
|
869
1420
|
if (!res.writableEnded) sendJson(res, 500, {
|
|
870
1421
|
ok: false,
|
|
@@ -876,7 +1427,7 @@ function registerWebApi(ctx, forwardProxy) {
|
|
|
876
1427
|
async function handle(req, res) {
|
|
877
1428
|
const method = req.method ?? "GET";
|
|
878
1429
|
if (method === "GET") {
|
|
879
|
-
sendJson(res, 200, viewOf(forwardProxy));
|
|
1430
|
+
sendJson(res, 200, viewOf(forwardProxy, providers, launch, egress));
|
|
880
1431
|
return;
|
|
881
1432
|
}
|
|
882
1433
|
if (method === "POST") {
|
|
@@ -894,11 +1445,17 @@ function registerWebApi(ctx, forwardProxy) {
|
|
|
894
1445
|
switch (body.op) {
|
|
895
1446
|
case "save":
|
|
896
1447
|
try {
|
|
897
|
-
const cfg = writeProxyConfig(proxyConfigPath(),
|
|
1448
|
+
const cfg = writeProxyConfig(proxyConfigPath(), {
|
|
1449
|
+
upstream: body.upstream,
|
|
1450
|
+
...Array.isArray(body.proxiedHosts) ? { proxiedHosts: body.proxiedHosts } : {},
|
|
1451
|
+
listenPort: body.listenPort,
|
|
1452
|
+
routeMode: body.routeMode
|
|
1453
|
+
});
|
|
898
1454
|
sendJson(res, 200, {
|
|
899
|
-
...viewOf(forwardProxy),
|
|
1455
|
+
...viewOf(forwardProxy, providers, launch, egress),
|
|
900
1456
|
upstream: cfg.upstream,
|
|
901
|
-
proxiedHosts: cfg.proxiedHosts
|
|
1457
|
+
proxiedHosts: cfg.proxiedHosts,
|
|
1458
|
+
routeMode: cfg.routeMode
|
|
902
1459
|
});
|
|
903
1460
|
} catch (error) {
|
|
904
1461
|
sendJson(res, 400, {
|
|
@@ -919,7 +1476,13 @@ function registerWebApi(ctx, forwardProxy) {
|
|
|
919
1476
|
try {
|
|
920
1477
|
forwardProxy.persistKnownHosts();
|
|
921
1478
|
} catch {}
|
|
922
|
-
sendJson(res, 200, viewOf(forwardProxy));
|
|
1479
|
+
sendJson(res, 200, viewOf(forwardProxy, providers, launch, egress));
|
|
1480
|
+
return;
|
|
1481
|
+
case "forget":
|
|
1482
|
+
try {
|
|
1483
|
+
forwardProxy.forgetHosts();
|
|
1484
|
+
} catch {}
|
|
1485
|
+
sendJson(res, 200, viewOf(forwardProxy, providers, launch, egress));
|
|
923
1486
|
return;
|
|
924
1487
|
default: sendJson(res, 400, {
|
|
925
1488
|
ok: false,
|
|
@@ -945,16 +1508,72 @@ function apply(ctx) {
|
|
|
945
1508
|
ctx.logger?.info(`[dsh-proxy] ${line}`);
|
|
946
1509
|
} catch {}
|
|
947
1510
|
};
|
|
1511
|
+
const launch = readLaunchProxy();
|
|
948
1512
|
const forwardProxy = startProxy({
|
|
949
1513
|
configFile: proxyConfigPath(),
|
|
1514
|
+
launch,
|
|
950
1515
|
log
|
|
951
1516
|
});
|
|
952
|
-
|
|
953
|
-
|
|
1517
|
+
/**
|
|
1518
|
+
* Where the harness's policy package can be resolved from, most specific
|
|
1519
|
+
* first: the active profile (the harness resolved it from there, so importing
|
|
1520
|
+
* the same file shares one module instance — which is what makes
|
|
1521
|
+
* `proxyRouteFor` callers see this policy too), then the dsh entry point.
|
|
1522
|
+
*/
|
|
1523
|
+
const policyAnchors = () => {
|
|
1524
|
+
const anchors = [];
|
|
954
1525
|
try {
|
|
955
|
-
|
|
1526
|
+
const settings = ctx.get("settings");
|
|
1527
|
+
if (typeof settings?.documentPath === "string") anchors.push(join(dirname(settings.documentPath), "package.json"));
|
|
956
1528
|
} catch {}
|
|
1529
|
+
const entry = process.argv[1];
|
|
1530
|
+
if (typeof entry === "string" && isAbsolute(entry)) anchors.push(entry);
|
|
1531
|
+
anchors.push(join(process.cwd(), "package.json"));
|
|
1532
|
+
return anchors;
|
|
1533
|
+
};
|
|
1534
|
+
let outcome = {
|
|
1535
|
+
status: "unavailable",
|
|
1536
|
+
detail: "the forward proxy is not listening yet",
|
|
1537
|
+
dispose: null
|
|
1538
|
+
};
|
|
1539
|
+
forwardProxy.port.then(async (port) => {
|
|
1540
|
+
if (port <= 0) {
|
|
1541
|
+
outcome = {
|
|
1542
|
+
status: "unavailable",
|
|
1543
|
+
detail: "the forward proxy could not bind any port",
|
|
1544
|
+
dispose: null
|
|
1545
|
+
};
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
outcome = await installInProcessEgress({
|
|
1549
|
+
proxyUrl: `http://127.0.0.1:${port}`,
|
|
1550
|
+
launch,
|
|
1551
|
+
importer: createPolicyImporter(policyAnchors()),
|
|
1552
|
+
log
|
|
1553
|
+
});
|
|
1554
|
+
log(`dsh-proxy: in-process egress ${outcome.status}${outcome.detail === null ? "" : ` — ${outcome.detail}`}`);
|
|
1555
|
+
}).catch((error) => {
|
|
1556
|
+
outcome = {
|
|
1557
|
+
status: "unavailable",
|
|
1558
|
+
detail: error.message,
|
|
1559
|
+
dispose: null
|
|
1560
|
+
};
|
|
1561
|
+
});
|
|
1562
|
+
const providers = () => resolveProviderHosts({
|
|
1563
|
+
live: liveSettingsDocument(ctx),
|
|
1564
|
+
legacyPath: settingsPath()
|
|
1565
|
+
});
|
|
1566
|
+
registerWebApi(ctx, forwardProxy, providers, launch, () => outcome);
|
|
1567
|
+
ctx.effect(() => () => {
|
|
1568
|
+
(async () => {
|
|
1569
|
+
try {
|
|
1570
|
+
await outcome.dispose?.();
|
|
1571
|
+
} catch {}
|
|
1572
|
+
try {
|
|
1573
|
+
forwardProxy.close();
|
|
1574
|
+
} catch {}
|
|
1575
|
+
})();
|
|
957
1576
|
}, "dsh-proxy: teardown");
|
|
958
1577
|
}
|
|
959
1578
|
//#endregion
|
|
960
|
-
export { apply, inject, name, proxyConfigPath, resolveDshHome, settingsPath };
|
|
1579
|
+
export { apply, inject, launchProxyLoopbackPort, launchProxyTargetsPort, maskProxyUrl, name, proxyConfigPath, resolveDshHome, settingsPath };
|