@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/CHANGELOG.md +31 -0
- package/CONTRIBUTING.md +97 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/README.zh.md +160 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +645 -0
- package/lib/index.js +960 -0
- package/package.json +62 -0
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 };
|