@karoc/dsh-proxy 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/lib/index.js ADDED
@@ -0,0 +1,960 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { createServer, request } from "node:http";
4
+ import { connect } from "node:net";
5
+ import { connect as connect$1 } from "node:tls";
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ //#region src/proxy-core.ts
8
+ /**
9
+ * dsh-proxy core: a loopback forward proxy with per-host routing, ported
10
+ * verbatim from dsh-desktop's scripts/proxy.mjs (behavior preserved).
11
+ *
12
+ * The proxy is the SINGLE egress point for the host's outbound traffic:
13
+ * dsh model fetch (undici), web search, npm install/update, git, child CLIs.
14
+ * The host points everything at it via HTTP(S)_PROXY=http://127.0.0.1:<port>
15
+ * + NODE_USE_ENV_PROXY=1 + NO_PROXY=127.0.0.1,localhost,::1.
16
+ *
17
+ * Routing is per-host and read LIVE from <DSH_HOME>/proxy.json on every
18
+ * request, so toggling a host in the settings page takes effect immediately
19
+ * (no dsh restart needed):
20
+ * {
21
+ * "upstream": { "enabled": bool, "protocol": "http"|"https"|"socks5",
22
+ * "host": "", "port": 0, "username": "", "password": "" },
23
+ * "proxiedHosts": ["api.deepseek.com", ...], // hosts that go upstream
24
+ * "knownHosts": ["api.deepseek.com", ...] // observed, for the UI
25
+ * }
26
+ * Default: everything connects DIRECT. Only hosts in `proxiedHosts` (with
27
+ * `upstream.enabled`) are forwarded to the upstream proxy, optionally with
28
+ * Basic auth (Proxy-Authorization).
29
+ *
30
+ * Hard safety rules:
31
+ * * loopback targets are ALWAYS direct (never sent to an upstream proxy);
32
+ * * an upstream pointing back at this very proxy is treated as disabled
33
+ * (self-loop guard);
34
+ * * the proxy itself uses raw net/http (never undici's env-proxy), so it can
35
+ * never route its own outbound connections back through itself.
36
+ */
37
+ const LOOPBACK = /^(127\.0\.0\.1|localhost|::1|0\.0\.0\.0)$/i;
38
+ /** Upstream proxy protocol, defaulting to http (legacy configs have no field). */
39
+ function upstreamProtocol(cfg) {
40
+ return String(cfg?.upstream?.protocol || "http").toLowerCase();
41
+ }
42
+ /** Read exactly n bytes from a socket (SOCKS5 fixed-size frames). */
43
+ function readExactly(socket, n, timeoutMs = 1e4) {
44
+ return new Promise((resolvePromise, rejectPromise) => {
45
+ let buf = Buffer.alloc(0);
46
+ const timer = setTimeout(() => {
47
+ cleanup();
48
+ rejectPromise(/* @__PURE__ */ new Error("upstream handshake timeout"));
49
+ }, timeoutMs);
50
+ const onData = (chunk) => {
51
+ buf = Buffer.concat([buf, chunk]);
52
+ if (buf.length >= n) {
53
+ cleanup();
54
+ resolvePromise(buf);
55
+ }
56
+ };
57
+ const onErr = (e) => {
58
+ cleanup();
59
+ rejectPromise(e);
60
+ };
61
+ const cleanup = () => {
62
+ clearTimeout(timer);
63
+ socket.removeListener("data", onData);
64
+ socket.removeListener("error", onErr);
65
+ };
66
+ socket.on("data", onData);
67
+ socket.on("error", onErr);
68
+ });
69
+ }
70
+ /** Read until a byte terminator (HTTP CONNECT response headers). */
71
+ function readUntil(socket, terminator, timeoutMs = 1e4) {
72
+ return new Promise((resolvePromise, rejectPromise) => {
73
+ let buf = Buffer.alloc(0);
74
+ const timer = setTimeout(() => {
75
+ cleanup();
76
+ rejectPromise(/* @__PURE__ */ new Error("upstream handshake timeout"));
77
+ }, timeoutMs);
78
+ const onData = (chunk) => {
79
+ buf = Buffer.concat([buf, chunk]);
80
+ const idx = buf.indexOf(terminator);
81
+ if (idx >= 0) {
82
+ cleanup();
83
+ resolvePromise({
84
+ head: buf.slice(0, idx + terminator.length).toString(),
85
+ rest: buf.slice(idx + terminator.length)
86
+ });
87
+ }
88
+ };
89
+ const onErr = (e) => {
90
+ cleanup();
91
+ rejectPromise(e);
92
+ };
93
+ const cleanup = () => {
94
+ clearTimeout(timer);
95
+ socket.removeListener("data", onData);
96
+ socket.removeListener("error", onErr);
97
+ };
98
+ socket.on("data", onData);
99
+ socket.on("error", onErr);
100
+ });
101
+ }
102
+ /**
103
+ * SOCKS5 handshake against the upstream: greeting (with optional RFC1929
104
+ * user/pass auth), then CONNECT to the target. Resolves once the tunnel is
105
+ * ready (BND.ADDR consumed).
106
+ */
107
+ async function socks5Handshake(socket, upstream, targetHost, targetPort) {
108
+ const methods = Boolean(upstream.username) ? [0, 2] : [0];
109
+ socket.write(Buffer.from([
110
+ 5,
111
+ methods.length,
112
+ ...methods
113
+ ]));
114
+ const methodResp = await readExactly(socket, 2);
115
+ if (methodResp[0] !== 5) throw new Error(`socks5: bad version ${methodResp[0]}`);
116
+ const method = methodResp[1];
117
+ if (method === 255) throw new Error("socks5: no acceptable auth method");
118
+ if (method === 2) {
119
+ const user = Buffer.from(upstream.username || "", "utf8");
120
+ const pass = Buffer.from(upstream.password || "", "utf8");
121
+ socket.write(Buffer.concat([
122
+ Buffer.from([1, user.length]),
123
+ user,
124
+ Buffer.from([pass.length]),
125
+ pass
126
+ ]));
127
+ const authResp = await readExactly(socket, 2);
128
+ if (authResp[0] !== 1 || authResp[1] !== 0) throw new Error("socks5: auth failed");
129
+ } else if (method !== 0) throw new Error(`socks5: unsupported auth method ${method}`);
130
+ const isIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(targetHost);
131
+ let addrPart;
132
+ if (isIpv4) addrPart = Buffer.concat([Buffer.from([1]), ...targetHost.split(".").map((o) => Buffer.from([Number(o) & 255]))]);
133
+ else {
134
+ const name = Buffer.from(targetHost, "ascii");
135
+ addrPart = Buffer.concat([Buffer.from([3, name.length]), name]);
136
+ }
137
+ const portBuf = Buffer.from([targetPort >> 8 & 255, targetPort & 255]);
138
+ socket.write(Buffer.concat([
139
+ Buffer.from([
140
+ 5,
141
+ 1,
142
+ 0
143
+ ]),
144
+ addrPart,
145
+ portBuf
146
+ ]));
147
+ const connResp = await readExactly(socket, 4);
148
+ if (connResp[0] !== 5 || connResp[1] !== 0) throw new Error(`socks5: CONNECT failed (code ${connResp[1]})`);
149
+ const atyp = connResp[3];
150
+ if (atyp === 3) await readExactly(socket, 1 + (await readExactly(socket, 1))[0] + 2);
151
+ else if (atyp === 1) await readExactly(socket, 6);
152
+ else if (atyp === 4) await readExactly(socket, 18);
153
+ }
154
+ /**
155
+ * Open a tunnel THROUGH the configured upstream proxy to target:port.
156
+ * Returns { socket, rest } where `socket` is ready to pipe and `rest` holds
157
+ * any bytes already read past the handshake (HTTP CONNECT responses).
158
+ * Supports http / https / socks5 upstream protocols.
159
+ */
160
+ function openUpstreamTunnel(cfg, targetHost, targetPort, authHeader) {
161
+ const u = cfg.upstream;
162
+ const protocol = upstreamProtocol(cfg);
163
+ return new Promise((resolvePromise, rejectPromise) => {
164
+ let socket;
165
+ const fail = (e) => {
166
+ try {
167
+ socket?.destroy();
168
+ } catch {}
169
+ rejectPromise(e);
170
+ };
171
+ if (protocol === "https") socket = connect$1({
172
+ host: u.host,
173
+ port: Number(u.port),
174
+ servername: u.host
175
+ });
176
+ else socket = connect({
177
+ host: u.host,
178
+ port: Number(u.port)
179
+ });
180
+ socket.on("error", fail);
181
+ socket.on("connect", async () => {
182
+ try {
183
+ if (protocol === "socks5") {
184
+ await socks5Handshake(socket, u, targetHost, targetPort);
185
+ resolvePromise({
186
+ socket,
187
+ rest: Buffer.alloc(0)
188
+ });
189
+ } else {
190
+ const lines = [`CONNECT ${targetHost}:${targetPort} HTTP/1.1`, `Host: ${targetHost}:${targetPort}`];
191
+ if (authHeader) lines.push(`Proxy-Authorization: ${authHeader}`);
192
+ socket.write(lines.join("\r\n") + "\r\n\r\n");
193
+ const { head, rest } = await readUntil(socket, "\r\n\r\n");
194
+ if (!/^HTTP\/1\.[01]\s+2\d\d/.test(head)) throw new Error(`upstream CONNECT rejected: ${head.split("\r\n")[0] || "no status"}`);
195
+ resolvePromise({
196
+ socket,
197
+ rest
198
+ });
199
+ }
200
+ } catch (e) {
201
+ fail(e);
202
+ }
203
+ });
204
+ });
205
+ }
206
+ /** Fresh default proxy config (factory so callers never share a mutable copy). */
207
+ function defaultProxyConfig() {
208
+ return {
209
+ upstream: {
210
+ enabled: false,
211
+ protocol: "http",
212
+ host: "",
213
+ port: 0,
214
+ username: "",
215
+ password: ""
216
+ },
217
+ proxiedHosts: [],
218
+ knownHosts: []
219
+ };
220
+ }
221
+ /** Read <configFile> (proxy.json), tolerant of a missing/corrupt file. */
222
+ function readProxyConfig(configFile) {
223
+ try {
224
+ const raw = JSON.parse(readFileSync(configFile, "utf8"));
225
+ return {
226
+ ...defaultProxyConfig(),
227
+ ...raw ?? {},
228
+ upstream: {
229
+ ...defaultProxyConfig().upstream,
230
+ ...raw?.upstream ?? {}
231
+ }
232
+ };
233
+ } catch {
234
+ return defaultProxyConfig();
235
+ }
236
+ }
237
+ /**
238
+ * Best-effort extraction of model provider hosts from a dsh settings.yaml
239
+ * (llm-deepseek.baseURL / llm-pi-ai.providers.<n>.baseURL / any other llm-*
240
+ * namespace). Returns [{name, host, displayName?}]: `name` is the dsh
241
+ * namespace (or `ns/provider`), `displayName` the friendly name when known
242
+ * (llm-pi-ai providers carry a `displayName` field; llm-deepseek is fixed to
243
+ * "DeepSeek"). Empty when unreadable — the proxy's observed-host list still
244
+ * populates the settings UI.
245
+ */
246
+ function providerHostsFromSettings(settingsPath) {
247
+ const out = [];
248
+ let text;
249
+ try {
250
+ text = readFileSync(settingsPath, "utf8");
251
+ } catch {
252
+ return out;
253
+ }
254
+ const push = (current) => {
255
+ const name = current.provider ? `${current.ns}/${current.provider}` : current.ns;
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 {}
265
+ };
266
+ let ns = null;
267
+ let inProviders = false;
268
+ let current = null;
269
+ const flush = () => {
270
+ if (current && current.baseUrls.length) push(current);
271
+ current = null;
272
+ };
273
+ for (const raw of text.split(/\r?\n/)) {
274
+ const line = raw.replace(/#.*$/, "").replace(/\s+$/, "");
275
+ const top = line.match(/^([A-Za-z0-9_.-]+):\s*$/);
276
+ if (top) {
277
+ flush();
278
+ ns = top[1];
279
+ inProviders = false;
280
+ continue;
281
+ }
282
+ if (!ns?.startsWith("llm-")) continue;
283
+ const two = line.match(/^ {2}([A-Za-z0-9_.-]+):\s*$/);
284
+ if (two) {
285
+ flush();
286
+ inProviders = two[1] === "providers";
287
+ current = inProviders ? null : {
288
+ ns,
289
+ provider: two[1],
290
+ displayName: null,
291
+ baseUrls: []
292
+ };
293
+ continue;
294
+ }
295
+ const four = line.match(/^ {4}([A-Za-z0-9_.-]+):\s*$/);
296
+ if (four && inProviders) {
297
+ flush();
298
+ current = {
299
+ ns,
300
+ provider: four[1],
301
+ displayName: null,
302
+ baseUrls: []
303
+ };
304
+ continue;
305
+ }
306
+ if (current) {
307
+ const dn = line.match(/^ {2,}displayName:\s*(.+)$/);
308
+ if (dn) {
309
+ current.displayName = String(dn[1]).trim().replace(/^['"]|['"]$/g, "");
310
+ continue;
311
+ }
312
+ const b = line.match(/^ {2,}baseURL:\s*(.+)$/);
313
+ if (b) {
314
+ current.baseUrls.push(b[1]);
315
+ continue;
316
+ }
317
+ } else if (!inProviders) {
318
+ const b = line.match(/^ {2}baseURL:\s*(.+)$/);
319
+ if (b) current = {
320
+ ns,
321
+ provider: null,
322
+ displayName: null,
323
+ baseUrls: [b[1]]
324
+ };
325
+ }
326
+ }
327
+ flush();
328
+ return out;
329
+ }
330
+ /**
331
+ * Pure routing decision: should `host` go through the upstream proxy?
332
+ * Loopback targets are ALWAYS direct; an upstream pointing back at this very
333
+ * proxy (same loopback + same port) is treated as disabled (self-loop guard).
334
+ */
335
+ function shouldProxy(cfg, host, selfPort) {
336
+ const h = String(host ?? "").toLowerCase();
337
+ if (LOOPBACK.test(h)) return false;
338
+ const u = cfg?.upstream;
339
+ const selfRef = u?.host && LOOPBACK.test(u.host.toLowerCase()) && Number(u.port) === selfPort;
340
+ return Boolean(u?.enabled) && !selfRef && (cfg?.proxiedHosts ?? []).includes(h);
341
+ }
342
+ /**
343
+ * Start a forward proxy bound to 127.0.0.1:<random>.
344
+ */
345
+ function createForwardProxy(opts) {
346
+ const { configFile, onHosts, log = () => {} } = opts;
347
+ const seenHosts = /* @__PURE__ */ new Set();
348
+ let notifyTimer = null;
349
+ const markHost = (host) => {
350
+ const h = String(host ?? "").trim().toLowerCase();
351
+ if (!h || LOOPBACK.test(h)) return;
352
+ if (seenHosts.has(h)) return;
353
+ seenHosts.add(h);
354
+ if (notifyTimer) clearTimeout(notifyTimer);
355
+ notifyTimer = setTimeout(() => onHosts?.([...seenHosts].sort()), 400);
356
+ };
357
+ const config = () => readProxyConfig(configFile);
358
+ const upstreamAuth = (cfg) => {
359
+ const u = cfg.upstream;
360
+ if (u?.username) return "Basic " + Buffer.from(`${u.username}:${u.password ?? ""}`).toString("base64");
361
+ return null;
362
+ };
363
+ /** Decide the route for one target host. */
364
+ const routeFor = (host, selfPort) => {
365
+ const cfg = config();
366
+ return {
367
+ via: shouldProxy(cfg, host, selfPort) ? "upstream" : "direct",
368
+ cfg
369
+ };
370
+ };
371
+ /** Bidirectional relay between two sockets; errors/close tear both down. */
372
+ const pipeTunnel = (a, b) => {
373
+ a.on("error", () => {
374
+ try {
375
+ b.destroy();
376
+ } catch {}
377
+ });
378
+ b.on("error", () => {
379
+ try {
380
+ a.destroy();
381
+ } catch {}
382
+ });
383
+ a.pipe(b);
384
+ b.pipe(a);
385
+ const cleanup = () => {
386
+ try {
387
+ a.destroy();
388
+ } catch {}
389
+ try {
390
+ b.destroy();
391
+ } catch {}
392
+ };
393
+ a.on("close", cleanup);
394
+ b.on("close", cleanup);
395
+ };
396
+ const server = createServer((req, res) => {
397
+ let u;
398
+ try {
399
+ u = new URL(req.url ?? "");
400
+ } catch {
401
+ res.writeHead(400, { "content-type": "text/plain" });
402
+ res.end("proxy: bad request-target");
403
+ return;
404
+ }
405
+ const host = u.hostname.toLowerCase();
406
+ const port = Number(u.port || (u.protocol === "https:" ? 443 : 80));
407
+ if (!host || !port) {
408
+ res.writeHead(400, { "content-type": "text/plain" });
409
+ res.end("proxy: missing host");
410
+ return;
411
+ }
412
+ markHost(host);
413
+ const { via, cfg } = routeFor(host, server.address()?.port);
414
+ const headers = { ...req.headers };
415
+ delete headers["proxy-connection"];
416
+ delete headers["connection"];
417
+ let useUpstream = via === "upstream";
418
+ if (useUpstream && upstreamProtocol(cfg) === "socks5") {
419
+ log(`socks5 upstream: http target ${host} falls back to direct`);
420
+ useUpstream = false;
421
+ }
422
+ if (useUpstream) {
423
+ const auth = upstreamAuth(cfg);
424
+ if (auth) headers["proxy-authorization"] = auth;
425
+ const up = request({
426
+ host: cfg.upstream.host,
427
+ port: Number(cfg.upstream.port),
428
+ method: req.method,
429
+ path: req.url ?? "",
430
+ headers
431
+ }, (upRes) => {
432
+ res.writeHead(upRes.statusCode ?? 502, upRes.headers);
433
+ upRes.pipe(res);
434
+ });
435
+ up.on("error", (e) => {
436
+ res.writeHead(502, { "content-type": "text/plain" });
437
+ res.end(`proxy: upstream error: ${e.message}`);
438
+ });
439
+ req.pipe(up);
440
+ } else {
441
+ const up = request({
442
+ host,
443
+ port,
444
+ method: req.method,
445
+ path: u.pathname + u.search,
446
+ headers
447
+ }, (upRes) => {
448
+ res.writeHead(upRes.statusCode ?? 502, upRes.headers);
449
+ upRes.pipe(res);
450
+ });
451
+ up.on("error", (e) => {
452
+ res.writeHead(502, { "content-type": "text/plain" });
453
+ res.end(`proxy: origin error: ${e.message}`);
454
+ });
455
+ req.pipe(up);
456
+ }
457
+ });
458
+ server.on("connect", (req, clientSocket, head) => {
459
+ const [hostRaw, portRaw] = String(req.url ?? "").split(":");
460
+ const host = (hostRaw ?? "").toLowerCase();
461
+ const port = Number(portRaw) || 443;
462
+ if (!host || !port) {
463
+ clientSocket.destroy();
464
+ return;
465
+ }
466
+ markHost(host);
467
+ const { via, cfg } = routeFor(host, server.address()?.port);
468
+ const rejectClient = (reason) => {
469
+ const msg = String(typeof reason === "object" && reason !== null ? reason.message ?? reason : reason).slice(0, 200).replace(/[\r\n]+/g, " ");
470
+ log(`connect ${host}:${port} failed: ${msg}`);
471
+ try {
472
+ clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\nContent-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`);
473
+ } catch {}
474
+ try {
475
+ clientSocket.destroy();
476
+ } catch {}
477
+ };
478
+ if (via === "upstream") openUpstreamTunnel(cfg, host, port, upstreamAuth(cfg)).then(({ socket: tunnel, rest }) => {
479
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
480
+ if (head?.length) tunnel.write(head);
481
+ if (rest.length) clientSocket.write(rest);
482
+ pipeTunnel(clientSocket, tunnel);
483
+ }).catch(rejectClient);
484
+ else {
485
+ const origin = connect({
486
+ host,
487
+ port
488
+ });
489
+ origin.on("error", rejectClient);
490
+ origin.on("connect", () => {
491
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
492
+ if (head?.length) origin.write(head);
493
+ pipeTunnel(clientSocket, origin);
494
+ });
495
+ }
496
+ });
497
+ return {
498
+ port: new Promise((resolve) => {
499
+ server.listen(0, "127.0.0.1", () => resolve(server.address().port));
500
+ }),
501
+ hosts: () => [...seenHosts].sort(),
502
+ config,
503
+ /** Merge observed hosts into proxy.json's knownHosts (UI candidate list). */
504
+ persistKnownHosts: () => {
505
+ try {
506
+ const cfg = config();
507
+ const merged = [.../* @__PURE__ */ new Set([...cfg.knownHosts ?? [], ...seenHosts])].sort();
508
+ writeFileSync(configFile, JSON.stringify({
509
+ ...cfg,
510
+ knownHosts: merged
511
+ }, null, 2) + "\n");
512
+ } catch (e) {
513
+ log(`proxy: persist known hosts failed: ${e.message}`);
514
+ }
515
+ },
516
+ close: () => {
517
+ if (notifyTimer) clearTimeout(notifyTimer);
518
+ server.close();
519
+ }
520
+ };
521
+ }
522
+ /**
523
+ * Clean each host: trim, lowercase, drop a trailing comma (a historical
524
+ * "api.xxx.com," never matches the real CONNECT target and silently breaks
525
+ * routing — never let it back into proxy.json). Returns '' for unusable input.
526
+ */
527
+ function normalizeHost(h) {
528
+ return String(h ?? "").trim().toLowerCase().replace(/,\s*$/, "").trim();
529
+ }
530
+ /**
531
+ * Sanitize an upstream object coming from the settings page: only known
532
+ * fields, only valid types/ports (a hostile page must not smuggle extra keys
533
+ * into proxy.json). Ported from dsh-desktop's Rust sanitize_upstream.
534
+ */
535
+ function sanitizeUpstream(v) {
536
+ const obj = typeof v === "object" && v !== null ? v : {};
537
+ const get = (k) => typeof obj[k] === "string" ? obj[k] : "";
538
+ const protocol = obj["protocol"] === "https" ? "https" : obj["protocol"] === "socks5" ? "socks5" : "http";
539
+ const rawPort = typeof obj["port"] === "number" ? obj["port"] : typeof obj["port"] === "string" ? Number(obj["port"]) : 0;
540
+ const port = Number.isFinite(rawPort) ? Math.max(0, Math.min(rawPort, 65535)) : 0;
541
+ return {
542
+ enabled: obj["enabled"] === true,
543
+ protocol,
544
+ host: get("host").trim(),
545
+ port,
546
+ username: get("username"),
547
+ password: get("password")
548
+ };
549
+ }
550
+ /**
551
+ * Persist a proxy config atomically from the settings page. Sanitizes the
552
+ * upstream and normalizes the proxied host list (the page never writes
553
+ * arbitrary keys). Takes effect immediately: the running proxy re-reads
554
+ * proxy.json on every request.
555
+ */
556
+ function writeProxyConfig(configFile, upstream, proxiedHosts) {
557
+ const cfg = readProxyConfig(configFile);
558
+ const hosts = [];
559
+ for (const h of proxiedHosts ?? []) {
560
+ const t = normalizeHost(h);
561
+ if (t !== "") hosts.push(t);
562
+ }
563
+ const next = {
564
+ ...cfg,
565
+ upstream: sanitizeUpstream(upstream),
566
+ proxiedHosts: hosts
567
+ };
568
+ const text = JSON.stringify(next, null, 2);
569
+ try {
570
+ mkdirSync(dirname(configFile), { recursive: true });
571
+ } catch {}
572
+ writeFileSync(configFile, text + "\n");
573
+ return next;
574
+ }
575
+ /**
576
+ * Verify the configured upstream proxy is reachable and speaks its protocol.
577
+ * HTTP/HTTPS: send a CONNECT probe (1.1.1.1:443); SOCKS5: handshake + CONNECT.
578
+ * HTTPS upstreams can't be TLS-verified without a TLS crate — TCP reachability
579
+ * is the honest signal available. Ported from dsh-desktop's Rust test_proxy.
580
+ */
581
+ function testUpstreamProxy(upstream) {
582
+ const { host, port, protocol, username, password } = sanitizeUpstream(upstream);
583
+ if (host === "" || port === 0) return Promise.resolve({
584
+ ok: false,
585
+ detail: "请先填写代理主机和端口"
586
+ });
587
+ const addr = `${host}:${port}`;
588
+ return new Promise((resolvePromise) => {
589
+ const sock = connect({
590
+ host,
591
+ port
592
+ });
593
+ const timeout = setTimeout(() => {
594
+ try {
595
+ sock.destroy();
596
+ } catch {}
597
+ resolvePromise({
598
+ ok: false,
599
+ detail: `无法连接 ${addr}: timeout`
600
+ });
601
+ }, 5e3);
602
+ const fail = (e) => {
603
+ clearTimeout(timeout);
604
+ try {
605
+ sock.destroy();
606
+ } catch {}
607
+ resolvePromise({
608
+ ok: false,
609
+ detail: `无法连接 ${addr}: ${e.message}`
610
+ });
611
+ };
612
+ sock.on("error", fail);
613
+ sock.setTimeout(5e3, () => fail(/* @__PURE__ */ new Error("timeout")));
614
+ sock.on("connect", () => {
615
+ try {
616
+ if (protocol === "socks5") {
617
+ const methods = username !== "" ? [
618
+ 5,
619
+ 2,
620
+ 0,
621
+ 2
622
+ ] : [
623
+ 5,
624
+ 1,
625
+ 0
626
+ ];
627
+ sock.write(Buffer.from(methods));
628
+ sock.once("data", (greeting) => {
629
+ if (greeting[0] !== 5) {
630
+ clearTimeout(timeout);
631
+ sock.destroy();
632
+ resolvePromise({
633
+ ok: false,
634
+ detail: `SOCKS5 版本异常 (${greeting[0]})`
635
+ });
636
+ return;
637
+ }
638
+ const handleMethod = (method) => {
639
+ if (method === 255) {
640
+ clearTimeout(timeout);
641
+ sock.destroy();
642
+ resolvePromise({
643
+ ok: false,
644
+ detail: "上游无可用认证方式"
645
+ });
646
+ return;
647
+ }
648
+ if (method === 2) {
649
+ const user = Buffer.from(username, "utf8");
650
+ const pass = Buffer.from(password, "utf8");
651
+ const auth = Buffer.concat([
652
+ Buffer.from([1, user.length]),
653
+ user,
654
+ Buffer.from([pass.length]),
655
+ pass
656
+ ]);
657
+ sock.write(auth);
658
+ sock.once("data", (ar) => {
659
+ if (ar[0] !== 1 || ar[1] !== 0) {
660
+ clearTimeout(timeout);
661
+ sock.destroy();
662
+ resolvePromise({
663
+ ok: false,
664
+ detail: "SOCKS5 认证失败"
665
+ });
666
+ } else sendConnect();
667
+ });
668
+ } else if (method !== 0) {
669
+ clearTimeout(timeout);
670
+ sock.destroy();
671
+ resolvePromise({
672
+ ok: false,
673
+ detail: `不支持的认证方式 (${method})`
674
+ });
675
+ } else sendConnect();
676
+ };
677
+ const sendConnect = () => {
678
+ sock.write(Buffer.from([
679
+ 5,
680
+ 1,
681
+ 0,
682
+ 1,
683
+ 1,
684
+ 1,
685
+ 1,
686
+ 1,
687
+ 1,
688
+ 187
689
+ ]));
690
+ sock.once("data", (cr) => {
691
+ if (cr[0] !== 5 || cr[1] !== 0) {
692
+ clearTimeout(timeout);
693
+ sock.destroy();
694
+ resolvePromise({
695
+ ok: false,
696
+ detail: `SOCKS5 CONNECT 失败 (code ${cr[1]})`
697
+ });
698
+ } else if (cr.slice(4).length >= 6) {
699
+ clearTimeout(timeout);
700
+ sock.destroy();
701
+ resolvePromise({
702
+ ok: true,
703
+ detail: "SOCKS5 握手成功,可转发"
704
+ });
705
+ } else sock.once("data", () => {
706
+ clearTimeout(timeout);
707
+ sock.destroy();
708
+ resolvePromise({
709
+ ok: true,
710
+ detail: "SOCKS5 握手成功,可转发"
711
+ });
712
+ });
713
+ });
714
+ };
715
+ handleMethod(greeting[1]);
716
+ });
717
+ } else if (protocol === "https") {
718
+ clearTimeout(timeout);
719
+ sock.destroy();
720
+ resolvePromise({
721
+ ok: true,
722
+ detail: "端口已连通(HTTPS 代理的 TLS 握手未验证)"
723
+ });
724
+ } else {
725
+ const target = "1.1.1.1:443";
726
+ let req = `CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n`;
727
+ if (username !== "") req += `Proxy-Authorization: Basic ${Buffer.from(`${username}:${password}`).toString("base64")}\r\n`;
728
+ req += "\r\n";
729
+ sock.write(req);
730
+ let buf = "";
731
+ const onData = (chunk) => {
732
+ buf += chunk.toString();
733
+ if (!buf.includes("\r\n\r\n")) return;
734
+ sock.removeListener("data", onData);
735
+ const statusLine = buf.split("\r\n")[0];
736
+ clearTimeout(timeout);
737
+ sock.destroy();
738
+ if (/^HTTP\/1\.[01]\s+2/.test(statusLine)) resolvePromise({
739
+ ok: true,
740
+ detail: "上游代理可转发(CONNECT 2xx)"
741
+ });
742
+ else if (statusLine.includes("407")) resolvePromise({
743
+ ok: false,
744
+ detail: "上游要求认证(407)"
745
+ });
746
+ else resolvePromise({
747
+ ok: false,
748
+ detail: `上游响应异常: ${statusLine.trim()}`
749
+ });
750
+ };
751
+ sock.on("data", onData);
752
+ }
753
+ } catch (e) {
754
+ fail(e);
755
+ }
756
+ });
757
+ });
758
+ }
759
+ //#endregion
760
+ //#region src/index.ts
761
+ const name = "dsh-proxy";
762
+ const inject = ["webServer"];
763
+ /** Resolve the harness home: $DSH_HOME (blank = unset) else ~/.dsh. */
764
+ function resolveDshHome() {
765
+ const env = process.env.DSH_HOME;
766
+ const selected = env !== void 0 && env.trim().length > 0 ? env : join(homedir(), ".dsh");
767
+ return resolve(selected);
768
+ }
769
+ /** The proxy config file (persisted, hand-editable, like dsh-desktop). */
770
+ function proxyConfigPath(home = resolveDshHome()) {
771
+ return join(home, "proxy.json");
772
+ }
773
+ /** The dsh settings.yaml this plugin derives model-provider hosts from. */
774
+ function settingsPath(home = resolveDshHome()) {
775
+ return join(home, "settings.yaml");
776
+ }
777
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
778
+ function sendJson(res, status, value) {
779
+ res.writeHead(status, JSON_HEADERS);
780
+ res.end(JSON.stringify(value));
781
+ }
782
+ /** Collect a request body (bounded — proxy configs are tiny). */
783
+ function readBody(req) {
784
+ return new Promise((resolvePromise, reject) => {
785
+ const chunks = [];
786
+ let size = 0;
787
+ req.on("data", (chunk) => {
788
+ size += chunk.length;
789
+ if (size > 65536) {
790
+ reject(/* @__PURE__ */ new Error("dsh-proxy: request body too large"));
791
+ req.destroy();
792
+ return;
793
+ }
794
+ chunks.push(chunk);
795
+ });
796
+ req.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
797
+ req.on("error", reject);
798
+ });
799
+ }
800
+ /** Build the fresh settings view. */
801
+ function viewOf(forwardProxy) {
802
+ const cfg = forwardProxy.config();
803
+ return {
804
+ ok: true,
805
+ port: forwardProxy.currentPort,
806
+ upstream: cfg.upstream,
807
+ proxiedHosts: cfg.proxiedHosts,
808
+ knownHosts: cfg.knownHosts,
809
+ hosts: forwardProxy.hosts(),
810
+ providers: providerHostsFromSettings(settingsPath())
811
+ };
812
+ }
813
+ /** Start the loopback forward proxy and re-point the process at it. */
814
+ function startProxy(opts) {
815
+ const { configFile, log } = opts;
816
+ let currentPort = null;
817
+ let persistTimer = null;
818
+ const handle = createForwardProxy({
819
+ configFile,
820
+ onHosts: (hosts) => {
821
+ if (persistTimer) clearTimeout(persistTimer);
822
+ persistTimer = setTimeout(() => {
823
+ try {
824
+ handle.persistKnownHosts();
825
+ } catch {}
826
+ }, 2e3);
827
+ log(`dsh-proxy: observed hosts: ${hosts.join(", ")}`);
828
+ },
829
+ log
830
+ });
831
+ const wrapped = {
832
+ port: handle.port,
833
+ currentPort,
834
+ hosts: handle.hosts,
835
+ config: handle.config,
836
+ persistKnownHosts: handle.persistKnownHosts,
837
+ close: () => {
838
+ if (persistTimer) clearTimeout(persistTimer);
839
+ handle.close();
840
+ }
841
+ };
842
+ handle.port.then((port) => {
843
+ wrapped.currentPort = port;
844
+ log(`dsh-proxy: forward proxy on 127.0.0.1:${port}`);
845
+ process.env.NODE_USE_ENV_PROXY = "1";
846
+ process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
847
+ process.env.HTTPS_PROXY = `http://127.0.0.1:${port}`;
848
+ process.env.ALL_PROXY = `http://127.0.0.1:${port}`;
849
+ process.env.NO_PROXY = [
850
+ "127.0.0.1",
851
+ "localhost",
852
+ "::1",
853
+ process.env.NO_PROXY
854
+ ].filter(Boolean).join(",");
855
+ }).catch((error) => {
856
+ log(`dsh-proxy: forward proxy start failed (continuing without it): ${error.message}`);
857
+ });
858
+ return wrapped;
859
+ }
860
+ /** Register the /proxy/api route (GET view, POST save/test/persist). */
861
+ function registerWebApi(ctx, forwardProxy) {
862
+ const server = ctx.get("webServer");
863
+ if (server === void 0) return;
864
+ server.register({
865
+ kind: "prefix",
866
+ path: "/proxy/api",
867
+ handler: (req, res) => {
868
+ handle(req, res).catch((error) => {
869
+ if (!res.writableEnded) sendJson(res, 500, {
870
+ ok: false,
871
+ error: error instanceof Error ? error.message : String(error)
872
+ });
873
+ });
874
+ }
875
+ });
876
+ async function handle(req, res) {
877
+ const method = req.method ?? "GET";
878
+ if (method === "GET") {
879
+ sendJson(res, 200, viewOf(forwardProxy));
880
+ return;
881
+ }
882
+ if (method === "POST") {
883
+ const raw = await readBody(req);
884
+ let body;
885
+ try {
886
+ body = JSON.parse(raw);
887
+ } catch (error) {
888
+ sendJson(res, 400, {
889
+ ok: false,
890
+ error: `dsh-proxy: invalid JSON body: ${error.message}`
891
+ });
892
+ return;
893
+ }
894
+ switch (body.op) {
895
+ case "save":
896
+ try {
897
+ const cfg = writeProxyConfig(proxyConfigPath(), body.upstream, Array.isArray(body.proxiedHosts) ? body.proxiedHosts : []);
898
+ sendJson(res, 200, {
899
+ ...viewOf(forwardProxy),
900
+ upstream: cfg.upstream,
901
+ proxiedHosts: cfg.proxiedHosts
902
+ });
903
+ } catch (error) {
904
+ sendJson(res, 400, {
905
+ ok: false,
906
+ error: `dsh-proxy: save failed: ${error.message}`
907
+ });
908
+ }
909
+ return;
910
+ case "test": {
911
+ const result = await testUpstreamProxy(body.upstream);
912
+ sendJson(res, 200, {
913
+ ok: result.ok,
914
+ detail: result.detail
915
+ });
916
+ return;
917
+ }
918
+ case "persist":
919
+ try {
920
+ forwardProxy.persistKnownHosts();
921
+ } catch {}
922
+ sendJson(res, 200, viewOf(forwardProxy));
923
+ return;
924
+ default: sendJson(res, 400, {
925
+ ok: false,
926
+ error: `dsh-proxy: unknown op ${JSON.stringify(body.op)}`
927
+ });
928
+ }
929
+ return;
930
+ }
931
+ sendJson(res, 405, {
932
+ ok: false,
933
+ error: `dsh-proxy: method ${method} not allowed`
934
+ });
935
+ }
936
+ }
937
+ /**
938
+ * Host plugin body: start the forward proxy, point the process at it, and
939
+ * serve /proxy/api. Disposal closes the proxy (env vars are left as-is — the
940
+ * process owns them for its lifetime).
941
+ */
942
+ function apply(ctx) {
943
+ const log = (line) => {
944
+ try {
945
+ ctx.logger?.info(`[dsh-proxy] ${line}`);
946
+ } catch {}
947
+ };
948
+ const forwardProxy = startProxy({
949
+ configFile: proxyConfigPath(),
950
+ log
951
+ });
952
+ registerWebApi(ctx, forwardProxy);
953
+ ctx.effect(() => () => {
954
+ try {
955
+ forwardProxy.close();
956
+ } catch {}
957
+ }, "dsh-proxy: teardown");
958
+ }
959
+ //#endregion
960
+ export { apply, inject, name, proxyConfigPath, resolveDshHome, settingsPath };