dsh-multi-chat 0.4.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/LICENSE +21 -0
- package/README.md +163 -0
- package/bin/dsh-multi-chat.mjs +417 -0
- package/package.json +45 -0
- package/patches/multi-wall.yml +10 -0
- package/plugin/dsh-client-ui-multi-wall/README.md +45 -0
- package/plugin/dsh-client-ui-multi-wall/lib/client.js +755 -0
- package/plugin/dsh-client-ui-multi-wall/lib/index.js +790 -0
- package/plugin/dsh-client-ui-multi-wall/lib/invariant.js +27 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/client/WallToggle.d.ts +10 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/client/WallView.d.ts +16 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/client/index.d.ts +39 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/client/locales.d.ts +94 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/client/store.d.ts +27 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/client/wall-injected.d.ts +53 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/index.d.ts +67 -0
- package/plugin/dsh-client-ui-multi-wall/lib/types/invariant.d.ts +17 -0
- package/plugin/dsh-client-ui-multi-wall/package.json +84 -0
- package/scripts/gateway-hidden.vbs +35 -0
- package/scripts/gateway-start.ps1 +21 -0
- package/scripts/gateway-stop.ps1 +34 -0
- package/scripts/gateway.mjs +511 -0
- package/scripts/install-plugin.ps1 +65 -0
- package/scripts/start-multi.ps1 +180 -0
- package/scripts/stop-multi.ps1 +42 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
import { connect, createServer } from "node:net";
|
|
7
|
+
//#region lib/types/gateway.js
|
|
8
|
+
/**
|
|
9
|
+
* Inline authenticated gateway for the multi-wall plugin's node half.
|
|
10
|
+
*
|
|
11
|
+
* The official `dsh web` CLI forbids `--host 0.0.0.0` (it would expose remote
|
|
12
|
+
* code execution to the network), so reaching a loopback-only DSH instance
|
|
13
|
+
* from a phone/another machine needs an auth-gated gateway in front of it.
|
|
14
|
+
*
|
|
15
|
+
* This module embeds that gateway directly in the plugin (no external
|
|
16
|
+
* `gateway.mjs` file needed at runtime): a raw TCP listener that authenticates
|
|
17
|
+
* a token (HMAC-signed session cookie), then transparently proxies to
|
|
18
|
+
* `127.0.0.1:<target-port>`, rewriting Host/Origin so the official `/api`
|
|
19
|
+
* browser-trust fence treats it as a local request. WebSocket upgrades pass
|
|
20
|
+
* through untouched because the socket is piped byte-for-byte after the head.
|
|
21
|
+
*
|
|
22
|
+
* Zero dependencies: node:net + node:crypto only.
|
|
23
|
+
* @module @deepseek-ai/dsh-client-ui-multi-wall/gateway
|
|
24
|
+
*/
|
|
25
|
+
/** Constant-time hex string comparison (length-guarded). */
|
|
26
|
+
function safeEqualHex(left, right) {
|
|
27
|
+
const a = Buffer.from(left, "hex");
|
|
28
|
+
const b = Buffer.from(right, "hex");
|
|
29
|
+
return a.length !== 0 && a.length === b.length && timingSafeEqual(a, b);
|
|
30
|
+
}
|
|
31
|
+
/** Minimal HTTP/1.1 head parse; null until the head is complete. */
|
|
32
|
+
function parseHead(buffer) {
|
|
33
|
+
const idx = buffer.indexOf("\r\n\r\n");
|
|
34
|
+
if (idx === -1) return null;
|
|
35
|
+
const lines = buffer.subarray(0, idx + 4).toString("latin1").split("\r\n");
|
|
36
|
+
const requestLine = lines[0]?.match(/^(\S+)\s+(\S+)\s+(HTTP\/\d\.\d)$/);
|
|
37
|
+
const headers = [];
|
|
38
|
+
const map = {};
|
|
39
|
+
for (const line of lines.slice(1)) {
|
|
40
|
+
const ci = line.indexOf(":");
|
|
41
|
+
if (ci === -1) continue;
|
|
42
|
+
const name = line.slice(0, ci).trim().toLowerCase();
|
|
43
|
+
const value = line.slice(ci + 1).trim();
|
|
44
|
+
map[name] = value;
|
|
45
|
+
headers.push([name, value]);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
malformed: requestLine === null,
|
|
49
|
+
headBytes: idx + 4,
|
|
50
|
+
method: requestLine?.[1] ?? "",
|
|
51
|
+
target: requestLine?.[2] ?? "",
|
|
52
|
+
version: requestLine?.[3] ?? "",
|
|
53
|
+
headers,
|
|
54
|
+
map
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Pathname of a request target (defaults to '/' on parse failure). */
|
|
58
|
+
function targetPath(target) {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(target, "http://gateway.local").pathname;
|
|
61
|
+
} catch {
|
|
62
|
+
return "/";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The dark-themed login page. */
|
|
66
|
+
function loginPage(name, next) {
|
|
67
|
+
return `<!doctype html>
|
|
68
|
+
<html lang="zh-CN">
|
|
69
|
+
<head>
|
|
70
|
+
<meta charset="utf-8">
|
|
71
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
72
|
+
<title>${name} · 访问认证</title>
|
|
73
|
+
<style>
|
|
74
|
+
body { font-family: system-ui, sans-serif; background:#0d1117; color:#e6edf3;
|
|
75
|
+
display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
|
|
76
|
+
form { background:#161b22; border:1px solid #30363d; border-radius:12px;
|
|
77
|
+
padding:32px 36px; width:min(320px, 90vw); box-shadow:0 8px 30px #0006; }
|
|
78
|
+
h1 { font-size:16px; margin:0 0 18px; font-weight:600; }
|
|
79
|
+
label { font-size:13px; color:#8b949e; }
|
|
80
|
+
input { width:100%; box-sizing:border-box; margin:6px 0 16px; padding:9px 10px;
|
|
81
|
+
border-radius:8px; border:1px solid #30363d; background:#0d1117; color:#e6edf3; font-size:14px; }
|
|
82
|
+
button { width:100%; padding:10px; border:none; border-radius:8px; background:#2f81f7;
|
|
83
|
+
color:#fff; font-size:14px; font-weight:600; cursor:pointer; }
|
|
84
|
+
button:hover { background:#388bfd; }
|
|
85
|
+
</style>
|
|
86
|
+
</head>
|
|
87
|
+
<body>
|
|
88
|
+
<form method="post" action="/__gw__/login">
|
|
89
|
+
<h1>${name} · 需要访问口令</h1>
|
|
90
|
+
<label for="token">访问口令</label>
|
|
91
|
+
<input id="token" name="token" type="password" autofocus autocomplete="current-password">
|
|
92
|
+
<input type="hidden" name="next" value="${(next ?? "").replace(/"/g, """)}">
|
|
93
|
+
<button type="submit">进入</button>
|
|
94
|
+
</form>
|
|
95
|
+
</body>
|
|
96
|
+
</html>`;
|
|
97
|
+
}
|
|
98
|
+
/** Send the login page as a 401 HTML response. */
|
|
99
|
+
function respondLogin(socket, name, next) {
|
|
100
|
+
const body = Buffer.from(loginPage(name, next), "utf8");
|
|
101
|
+
socket.write(`HTTP/1.1 401 Unauthorized\r
|
|
102
|
+
content-type: text/html; charset=utf-8\r
|
|
103
|
+
content-length: ${body.length}\r\nconnection: close\r
|
|
104
|
+
cache-control: no-store\r
|
|
105
|
+
\r
|
|
106
|
+
`);
|
|
107
|
+
socket.write(body);
|
|
108
|
+
socket.end();
|
|
109
|
+
}
|
|
110
|
+
/** Send a small plain-text response and close the socket. */
|
|
111
|
+
function respondPlain(socket, status, text) {
|
|
112
|
+
const body = Buffer.from(text, "utf8");
|
|
113
|
+
socket.write(`HTTP/1.1 ${status}\r\ncontent-type: text/plain; charset=utf-8\r
|
|
114
|
+
content-length: ${body.length}\r\nconnection: close\r
|
|
115
|
+
cache-control: no-store\r
|
|
116
|
+
\r
|
|
117
|
+
`);
|
|
118
|
+
socket.write(body);
|
|
119
|
+
socket.end();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Rebuild the request head for the loopback target. Host and Origin point at
|
|
123
|
+
* `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
|
|
124
|
+
* request. Non-upgrade requests get `connection: close` (fresh connection per
|
|
125
|
+
* request keeps keep-alive from smuggling unrewritten Host headers); upgrades
|
|
126
|
+
* (WebSocket) keep `connection: Upgrade`.
|
|
127
|
+
*/
|
|
128
|
+
function rewriteHead(parsed, targetPort) {
|
|
129
|
+
const upgrade = parsed.map.upgrade !== void 0;
|
|
130
|
+
const lines = [`${parsed.method} ${parsed.target} ${parsed.version}`];
|
|
131
|
+
for (const [name, value] of parsed.headers) if (name === "host") lines.push(`host: 127.0.0.1:${targetPort}`);
|
|
132
|
+
else if (name === "origin") lines.push(`origin: http://127.0.0.1:${targetPort}`);
|
|
133
|
+
else if (name === "connection" || name === "proxy-connection") {} else lines.push(`${name}: ${value}`);
|
|
134
|
+
lines.push(upgrade ? "connection: Upgrade" : "connection: close");
|
|
135
|
+
return lines.join("\r\n") + "\r\n\r\n";
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Start an in-process authenticated gateway for one loopback DSH instance.
|
|
139
|
+
* @param options - target, listen port, token, label, and lifetime.
|
|
140
|
+
* @returns a handle with the assigned port and token, and a close().
|
|
141
|
+
*/
|
|
142
|
+
function startGateway(options) {
|
|
143
|
+
const { targetPort, port: requestedPort, token, name, maxAgeHours = 12, log = () => {} } = options;
|
|
144
|
+
const hmac = (data) => createHmac("sha256", token).update(data).digest("hex");
|
|
145
|
+
const signSession = (exp) => {
|
|
146
|
+
const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
|
|
147
|
+
return `${payload}.${hmac(payload)}`;
|
|
148
|
+
};
|
|
149
|
+
const verifySession = (value) => {
|
|
150
|
+
const dot = value.lastIndexOf(".");
|
|
151
|
+
if (dot <= 0) return false;
|
|
152
|
+
const payload = value.slice(0, dot);
|
|
153
|
+
if (!safeEqualHex(value.slice(dot + 1), hmac(payload))) return false;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
156
|
+
return typeof parsed.exp === "number" && parsed.exp > Date.now();
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const isAuthed = (parsed) => {
|
|
162
|
+
const cookie = parsed.map.cookie ?? "";
|
|
163
|
+
const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie);
|
|
164
|
+
if (session !== null && verifySession(session[1] ?? "")) return true;
|
|
165
|
+
const bearer = parsed.map.authorization ?? "";
|
|
166
|
+
if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return true;
|
|
167
|
+
try {
|
|
168
|
+
return new URL(parsed.target, "http://gateway.local").searchParams.get("token") === token;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const attempts = /* @__PURE__ */ new Map();
|
|
174
|
+
const isBlocked = (ip) => {
|
|
175
|
+
const entry = attempts.get(ip);
|
|
176
|
+
return entry !== void 0 && entry.blockedUntil !== void 0 && entry.blockedUntil > Date.now();
|
|
177
|
+
};
|
|
178
|
+
const registerFailure = (ip) => {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const entry = attempts.get(ip) ?? { fails: 0 };
|
|
181
|
+
if (entry.blockedUntil !== void 0 && entry.blockedUntil > now) return;
|
|
182
|
+
entry.fails += 1;
|
|
183
|
+
if (entry.fails >= 5) {
|
|
184
|
+
entry.fails = 0;
|
|
185
|
+
entry.blockedUntil = now + 6e4;
|
|
186
|
+
log(`rate-limited ${ip} for 60s`);
|
|
187
|
+
}
|
|
188
|
+
attempts.set(ip, entry);
|
|
189
|
+
if (attempts.size > 512) {
|
|
190
|
+
for (const [key, value] of attempts) if (value.fails === 0 && value.blockedUntil === void 0) attempts.delete(key);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const handleLogin = (socket, parsed, initialBody) => {
|
|
194
|
+
let next = "/";
|
|
195
|
+
try {
|
|
196
|
+
next = new URL(parsed.target, "http://gateway.local").searchParams.get("next") ?? "/";
|
|
197
|
+
} catch {}
|
|
198
|
+
if (!/^\/(?!\/)/.test(next)) next = "/";
|
|
199
|
+
if (parsed.method !== "POST") {
|
|
200
|
+
respondLogin(socket, name, next);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const ip = socket.remoteAddress ?? "unknown";
|
|
204
|
+
if (isBlocked(ip)) {
|
|
205
|
+
respondPlain(socket, "429", "too many login attempts, try again later");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const contentLength = Number(parsed.map["content-length"] ?? 0);
|
|
209
|
+
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > 16384) {
|
|
210
|
+
respondPlain(socket, "413", "login body too large");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
let collected = Buffer.from(initialBody);
|
|
214
|
+
const done = () => {
|
|
215
|
+
socket.off("data", onBody);
|
|
216
|
+
if ((new URLSearchParams(collected.toString("utf8")).get("token") ?? "") === token) {
|
|
217
|
+
const cookie = `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`;
|
|
218
|
+
socket.write(`HTTP/1.1 302 Found\r
|
|
219
|
+
location: ${next}\r\nset-cookie: ${cookie}\r\ncontent-length: 0\r
|
|
220
|
+
connection: close\r
|
|
221
|
+
\r
|
|
222
|
+
`);
|
|
223
|
+
socket.end();
|
|
224
|
+
log(`login ok from ${ip}`);
|
|
225
|
+
} else {
|
|
226
|
+
registerFailure(ip);
|
|
227
|
+
log(`login FAILED from ${ip}`);
|
|
228
|
+
respondLogin(socket, name, next);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const onBody = (chunk) => {
|
|
232
|
+
collected = Buffer.concat([collected, chunk]);
|
|
233
|
+
if (collected.length >= contentLength) done();
|
|
234
|
+
};
|
|
235
|
+
if (collected.length >= contentLength) done();
|
|
236
|
+
else {
|
|
237
|
+
socket.on("data", onBody);
|
|
238
|
+
socket.once("end", done);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const handleSocket = (socket) => {
|
|
242
|
+
let buffer = Buffer.alloc(0);
|
|
243
|
+
let headDone = false;
|
|
244
|
+
let proxying = false;
|
|
245
|
+
const onData = (chunk) => {
|
|
246
|
+
if (headDone) return;
|
|
247
|
+
buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
|
|
248
|
+
if (buffer.length > 65536) {
|
|
249
|
+
headDone = true;
|
|
250
|
+
respondPlain(socket, "431", "request head too large");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const parsed = parseHead(buffer);
|
|
254
|
+
if (parsed === null) return;
|
|
255
|
+
headDone = true;
|
|
256
|
+
if (parsed.malformed) {
|
|
257
|
+
respondPlain(socket, "400", "malformed request");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const path = targetPath(parsed.target);
|
|
261
|
+
if (path === "/__gw__/login") {
|
|
262
|
+
handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!isAuthed(parsed)) {
|
|
266
|
+
respondLogin(socket, name, path === "/" ? "/" : path);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const target = connect(targetPort, "127.0.0.1");
|
|
270
|
+
const rest = buffer.subarray(parsed.headBytes);
|
|
271
|
+
let connected = false;
|
|
272
|
+
target.on("connect", () => {
|
|
273
|
+
connected = true;
|
|
274
|
+
target.write(rewriteHead(parsed, targetPort));
|
|
275
|
+
if (rest.length > 0) target.write(rest);
|
|
276
|
+
proxying = true;
|
|
277
|
+
socket.pipe(target);
|
|
278
|
+
target.pipe(socket);
|
|
279
|
+
});
|
|
280
|
+
target.on("error", (error) => {
|
|
281
|
+
if (!connected) respondPlain(socket, "502", `cannot reach target 127.0.0.1:${targetPort}: ${error.message}`);
|
|
282
|
+
socket.destroy();
|
|
283
|
+
});
|
|
284
|
+
socket.on("error", () => target.destroy());
|
|
285
|
+
socket.on("close", () => {
|
|
286
|
+
if (proxying) target.destroy();
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
socket.on("data", onData);
|
|
290
|
+
socket.on("error", () => {
|
|
291
|
+
socket.destroy();
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
return new Promise((resolve, reject) => {
|
|
295
|
+
const server = createServer(handleSocket);
|
|
296
|
+
const onError = (error) => {
|
|
297
|
+
if ((error.code === "EACCES" || error.code === "EADDRINUSE") && requestedPort !== 0) {
|
|
298
|
+
log(`gateway port ${requestedPort} unavailable (${error.code}), using an OS-assigned port`);
|
|
299
|
+
server.listen(0, "0.0.0.0");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
server.removeListener("error", onError);
|
|
303
|
+
reject(error);
|
|
304
|
+
};
|
|
305
|
+
server.on("error", onError);
|
|
306
|
+
server.listen(requestedPort, "0.0.0.0", () => {
|
|
307
|
+
server.removeListener("error", onError);
|
|
308
|
+
const address = server.address();
|
|
309
|
+
const assignedPort = typeof address === "object" && address !== null ? address.port : requestedPort;
|
|
310
|
+
log(`gateway listening 0.0.0.0:${assignedPort} -> 127.0.0.1:${targetPort}`);
|
|
311
|
+
resolve({
|
|
312
|
+
port: assignedPort,
|
|
313
|
+
token,
|
|
314
|
+
close: () => {
|
|
315
|
+
server.close();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region lib/types/index.js
|
|
323
|
+
/**
|
|
324
|
+
* Multi-window wall plugin, node half: registers the `/multi/api/*` routes
|
|
325
|
+
* on the webserver. The browser half fetches these same-origin to discover
|
|
326
|
+
* which local ports are live DSH instances, to poll liveness, and to
|
|
327
|
+
* terminate a chosen instance (`/multi/api/stop`). The wall itself is pure
|
|
328
|
+
* UI — this half answers a few small JSON requests.
|
|
329
|
+
* @module @deepseek-ai/dsh-client-ui-multi-wall
|
|
330
|
+
*/
|
|
331
|
+
/** Stable Cordis plugin name. */
|
|
332
|
+
const name = "client-ui-multi-wall";
|
|
333
|
+
/** Services required before the probe routes can be registered. */
|
|
334
|
+
const inject = ["webServer"];
|
|
335
|
+
/** Schema-validated config (the Loader resolves defaults for absent keys). */
|
|
336
|
+
const Config = z.object({
|
|
337
|
+
scanFrom: z.natural().default(3070),
|
|
338
|
+
scanTo: z.natural().default(3110),
|
|
339
|
+
ports: z.array(z.natural()).default([]),
|
|
340
|
+
publicUrl: z.string().default(""),
|
|
341
|
+
gatewayPort: z.number().default(0),
|
|
342
|
+
gatewayToken: z.string().default("")
|
|
343
|
+
});
|
|
344
|
+
/** MIME for JSON probe answers. */
|
|
345
|
+
const JSON_TYPE = "application/json; charset=utf-8";
|
|
346
|
+
/** GET one local URL with a short timeout; resolve {status, body} or reject. */
|
|
347
|
+
async function request(url, timeoutMs = 600) {
|
|
348
|
+
const controller = new AbortController();
|
|
349
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
350
|
+
try {
|
|
351
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
352
|
+
return {
|
|
353
|
+
status: res.status,
|
|
354
|
+
body: await res.text()
|
|
355
|
+
};
|
|
356
|
+
} finally {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/** Is this local port a live DSH instance (index.html carries __DSH_BOOT__)? */
|
|
361
|
+
async function probePort(port) {
|
|
362
|
+
try {
|
|
363
|
+
const { status, body } = await request(`http://127.0.0.1:${port}/`);
|
|
364
|
+
return {
|
|
365
|
+
port,
|
|
366
|
+
alive: status === 200 && body.includes("__DSH_BOOT__"),
|
|
367
|
+
status
|
|
368
|
+
};
|
|
369
|
+
} catch {
|
|
370
|
+
return {
|
|
371
|
+
port,
|
|
372
|
+
alive: false,
|
|
373
|
+
status: 0
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Concurrent probe of many ports (bounded chunking). */
|
|
378
|
+
async function probePorts(ports) {
|
|
379
|
+
const CHUNK = 16;
|
|
380
|
+
const out = [];
|
|
381
|
+
for (let i = 0; i < ports.length; i += CHUNK) out.push(...await Promise.all(ports.slice(i, i + CHUNK).map((port) => probePort(port))));
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
/** Send a small JSON response. */
|
|
385
|
+
function json(res, value, status = 200) {
|
|
386
|
+
res.writeHead(status, {
|
|
387
|
+
"content-type": JSON_TYPE,
|
|
388
|
+
"cache-control": "no-store"
|
|
389
|
+
});
|
|
390
|
+
res.end(JSON.stringify(value));
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Run a command and resolve its stdout text. Rejects on non-zero exit.
|
|
394
|
+
* @param file - the executable path.
|
|
395
|
+
* @param args - CLI arguments.
|
|
396
|
+
* @returns the trimmed stdout.
|
|
397
|
+
*/
|
|
398
|
+
function execStdout(file, args) {
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
execFile(file, args, { timeout: 5e3 }, (error, stdout) => {
|
|
401
|
+
if (error !== null) {
|
|
402
|
+
reject(error);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
resolve(stdout);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Resolve the PIDs listening on a local TCP port. Windows uses `netstat`;
|
|
411
|
+
* POSIX uses `lsof` (present on macOS and most Linux installs).
|
|
412
|
+
* @param port - the listening port.
|
|
413
|
+
* @returns the listener PIDs (possibly empty).
|
|
414
|
+
*/
|
|
415
|
+
async function listeningPids(port) {
|
|
416
|
+
if (process.platform === "win32") {
|
|
417
|
+
const stdout = await execStdout("netstat", [
|
|
418
|
+
"-ano",
|
|
419
|
+
"-p",
|
|
420
|
+
"tcp"
|
|
421
|
+
]);
|
|
422
|
+
const pids = /* @__PURE__ */ new Set();
|
|
423
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
424
|
+
const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line);
|
|
425
|
+
if (m !== null && Number(m[2]) === port) pids.add(Number(m[3]));
|
|
426
|
+
}
|
|
427
|
+
return [...pids];
|
|
428
|
+
}
|
|
429
|
+
return (await execStdout("lsof", [
|
|
430
|
+
"-ti",
|
|
431
|
+
`tcp:${port}`,
|
|
432
|
+
"-sTCP:LISTEN"
|
|
433
|
+
])).split(/\s+/).map(Number).filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Terminate one PID. Windows uses `taskkill /F` (force); POSIX sends SIGTERM
|
|
437
|
+
* then SIGKILL after a grace period.
|
|
438
|
+
* @param pid - the process id to terminate.
|
|
439
|
+
*/
|
|
440
|
+
async function killPid(pid) {
|
|
441
|
+
if (process.platform === "win32") {
|
|
442
|
+
await execStdout("taskkill", [
|
|
443
|
+
"/PID",
|
|
444
|
+
String(pid),
|
|
445
|
+
"/F",
|
|
446
|
+
"/T"
|
|
447
|
+
]);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
process.kill(pid, "SIGTERM");
|
|
452
|
+
} catch {}
|
|
453
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
454
|
+
try {
|
|
455
|
+
process.kill(pid, "SIGKILL");
|
|
456
|
+
} catch {}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Terminate the DSH instance listening on one local port. The port serving
|
|
460
|
+
* this wall may also be terminated (the user may want to stop the instance
|
|
461
|
+
* they are viewing): the kill is deferred a beat so the HTTP response is
|
|
462
|
+
* written before the process dies, then the listener's PIDs are force-killed.
|
|
463
|
+
* @param port - the target port.
|
|
464
|
+
* @param selfPort - this instance's own listening port.
|
|
465
|
+
* @returns the stop result.
|
|
466
|
+
*/
|
|
467
|
+
async function stopPort(port, selfPort) {
|
|
468
|
+
try {
|
|
469
|
+
const pids = await listeningPids(port);
|
|
470
|
+
if (pids.length === 0) return {
|
|
471
|
+
port,
|
|
472
|
+
ok: false,
|
|
473
|
+
error: "no listener on this port"
|
|
474
|
+
};
|
|
475
|
+
const kill = () => Promise.all(pids.map((pid) => killPid(pid).catch(() => {})));
|
|
476
|
+
if (port === selfPort) {
|
|
477
|
+
setTimeout(() => {
|
|
478
|
+
kill();
|
|
479
|
+
}, 250);
|
|
480
|
+
return {
|
|
481
|
+
port,
|
|
482
|
+
ok: true
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
await kill();
|
|
486
|
+
return {
|
|
487
|
+
port,
|
|
488
|
+
ok: true
|
|
489
|
+
};
|
|
490
|
+
} catch (error) {
|
|
491
|
+
return {
|
|
492
|
+
port,
|
|
493
|
+
ok: false,
|
|
494
|
+
error: error instanceof Error ? error.message : String(error)
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Resolve how to launch a new DSH instance. Primary path: the current
|
|
500
|
+
* process's own entry (`node <bin> web` under `process.argv[1]`), so the new
|
|
501
|
+
* instance inherits the exact CLI/profile already running. Fallback: the
|
|
502
|
+
* `dsh` command from PATH when the entry cannot be derived (unusual host
|
|
503
|
+
* launcher, missing file).
|
|
504
|
+
* @returns the launcher description.
|
|
505
|
+
*/
|
|
506
|
+
function resolveLauncher() {
|
|
507
|
+
const first = process.argv[1];
|
|
508
|
+
if (first !== void 0 && existsSync(first)) return {
|
|
509
|
+
file: process.execPath,
|
|
510
|
+
args: [
|
|
511
|
+
first,
|
|
512
|
+
"web",
|
|
513
|
+
"--port"
|
|
514
|
+
],
|
|
515
|
+
shell: false
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
file: "dsh",
|
|
519
|
+
args: ["web", "--port"],
|
|
520
|
+
shell: process.platform === "win32"
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Probe whether a local TCP port is already listening (no HTTP needed).
|
|
525
|
+
* @param port - the port to check.
|
|
526
|
+
* @returns whether something listens on it.
|
|
527
|
+
*/
|
|
528
|
+
async function isPortBusy(port) {
|
|
529
|
+
try {
|
|
530
|
+
return (await listeningPids(port)).length > 0;
|
|
531
|
+
} catch {
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Pick the first free port in [lo, hi] that is neither the serving port nor
|
|
537
|
+
* already listening.
|
|
538
|
+
* @param lo - first port of the range.
|
|
539
|
+
* @param hi - last port of the range.
|
|
540
|
+
* @param selfPort - the port serving this wall (never chosen).
|
|
541
|
+
* @returns a free port, or undefined when the range is exhausted.
|
|
542
|
+
*/
|
|
543
|
+
async function pickFreePort(lo, hi, selfPort) {
|
|
544
|
+
for (let port = lo; port <= hi; port++) {
|
|
545
|
+
if (port === selfPort) continue;
|
|
546
|
+
if (await isPortBusy(port)) continue;
|
|
547
|
+
return port;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Spawn a new `dsh web` instance on a port and wait until it serves the DSH
|
|
552
|
+
* shell (probe). Detached so it outlives this process. The child's stderr is
|
|
553
|
+
* captured and quoted into every failure, so a crash or a bad bin surfaces a
|
|
554
|
+
* concrete reason instead of a bare timeout.
|
|
555
|
+
* @param launcher - how to spawn the dsh CLI.
|
|
556
|
+
* @param port - the port for the new instance.
|
|
557
|
+
* @param timeoutMs - how long to wait for readiness.
|
|
558
|
+
* @returns ok plus the port, or ok:false with a reason.
|
|
559
|
+
*/
|
|
560
|
+
async function startInstance(launcher, port, timeoutMs = 2e4) {
|
|
561
|
+
const child = spawn(launcher.file, [...launcher.args, String(port)], {
|
|
562
|
+
detached: true,
|
|
563
|
+
stdio: [
|
|
564
|
+
"ignore",
|
|
565
|
+
"ignore",
|
|
566
|
+
"pipe"
|
|
567
|
+
],
|
|
568
|
+
windowsHide: true,
|
|
569
|
+
shell: launcher.shell
|
|
570
|
+
});
|
|
571
|
+
child.unref();
|
|
572
|
+
let stderr = "";
|
|
573
|
+
const spawnFailure = { error: null };
|
|
574
|
+
child.stderr?.on("data", (chunk) => {
|
|
575
|
+
stderr += String(chunk);
|
|
576
|
+
if (stderr.length > 2e3) stderr = stderr.slice(-2e3);
|
|
577
|
+
});
|
|
578
|
+
child.once("error", (error) => {
|
|
579
|
+
spawnFailure.error = error;
|
|
580
|
+
});
|
|
581
|
+
const detail = () => {
|
|
582
|
+
const tail = stderr.trim().split(/\r?\n/).slice(-3).join(" | ");
|
|
583
|
+
return tail === "" ? "" : ` (${tail})`;
|
|
584
|
+
};
|
|
585
|
+
const deadline = Date.now() + timeoutMs;
|
|
586
|
+
for (;;) {
|
|
587
|
+
if (spawnFailure.error !== null) return {
|
|
588
|
+
ok: false,
|
|
589
|
+
port,
|
|
590
|
+
error: `new instance failed to start: ${spawnFailure.error.message}`
|
|
591
|
+
};
|
|
592
|
+
if (child.exitCode !== null) return {
|
|
593
|
+
ok: false,
|
|
594
|
+
port,
|
|
595
|
+
error: `new instance exited early (code ${child.exitCode})${detail()}`
|
|
596
|
+
};
|
|
597
|
+
if ((await probePort(port)).alive) return {
|
|
598
|
+
ok: true,
|
|
599
|
+
port
|
|
600
|
+
};
|
|
601
|
+
if (Date.now() > deadline) return {
|
|
602
|
+
ok: false,
|
|
603
|
+
port,
|
|
604
|
+
error: `instance did not become ready in time${detail()}`
|
|
605
|
+
};
|
|
606
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The non-loopback IPv4 addresses of this machine (the LAN reachable URLs).
|
|
611
|
+
* @returns the address list (possibly empty).
|
|
612
|
+
*/
|
|
613
|
+
function lanAddresses() {
|
|
614
|
+
const out = [];
|
|
615
|
+
for (const ifaces of Object.values(networkInterfaces())) for (const iface of ifaces ?? []) if (iface.family === "IPv4" && !iface.internal) out.push(iface.address);
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Register the probe routes. Everything lives under `/multi/api` so the
|
|
620
|
+
* plugin is purely additive: exact `ports` (auto-discovery) and `status`
|
|
621
|
+
* (liveness of a specific port list).
|
|
622
|
+
* @param ctx - plugin context carrying the webServer service.
|
|
623
|
+
* @param config - validated {@link MultiWallConfig}.
|
|
624
|
+
*/
|
|
625
|
+
function apply(ctx, config = {}) {
|
|
626
|
+
const scanFrom = config.scanFrom ?? 3070;
|
|
627
|
+
const scanTo = config.scanTo ?? 3110;
|
|
628
|
+
const fixedPorts = config.ports ?? [];
|
|
629
|
+
let gateway = null;
|
|
630
|
+
let gatewayTargetPort = -1;
|
|
631
|
+
ctx.effect(() => ctx.webServer.register({
|
|
632
|
+
kind: "exact",
|
|
633
|
+
path: "/multi/api/ports",
|
|
634
|
+
handler: (req, res) => {
|
|
635
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
636
|
+
res.writeHead(405);
|
|
637
|
+
res.end();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
641
|
+
const qFromRaw = url.searchParams.get("from");
|
|
642
|
+
const qToRaw = url.searchParams.get("to");
|
|
643
|
+
const qFrom = qFromRaw !== null ? Number(qFromRaw) : NaN;
|
|
644
|
+
const qTo = qToRaw !== null ? Number(qToRaw) : NaN;
|
|
645
|
+
const lo = Number.isInteger(qFrom) ? qFrom : scanFrom;
|
|
646
|
+
const hi = Number.isInteger(qTo) ? qTo : scanTo;
|
|
647
|
+
const ports = fixedPorts.length > 0 ? [...fixedPorts] : [];
|
|
648
|
+
if (fixedPorts.length === 0) for (let p = lo; p <= hi; p++) ports.push(p);
|
|
649
|
+
probePorts(ports).then((results) => {
|
|
650
|
+
json(res, { ports: results.filter((row) => row.alive) });
|
|
651
|
+
}).catch(() => json(res, { ports: [] }, 500));
|
|
652
|
+
}
|
|
653
|
+
}), "multi-wall: /multi/api/ports");
|
|
654
|
+
ctx.effect(() => ctx.webServer.register({
|
|
655
|
+
kind: "exact",
|
|
656
|
+
path: "/multi/api/status",
|
|
657
|
+
handler: (req, res) => {
|
|
658
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
659
|
+
res.writeHead(405);
|
|
660
|
+
res.end();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
probePorts((new URL(req.url ?? "/", "http://x").searchParams.get("ports") ?? "").split(",").map(Number).filter((p) => Number.isInteger(p) && p > 0)).then((results) => {
|
|
664
|
+
json(res, { ports: results });
|
|
665
|
+
}).catch(() => json(res, { ports: [] }, 500));
|
|
666
|
+
}
|
|
667
|
+
}), "multi-wall: /multi/api/status");
|
|
668
|
+
ctx.effect(() => ctx.webServer.register({
|
|
669
|
+
kind: "exact",
|
|
670
|
+
path: "/multi/api/stop",
|
|
671
|
+
handler: (req, res) => {
|
|
672
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
673
|
+
res.writeHead(405);
|
|
674
|
+
res.end();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
678
|
+
const ports = (url.searchParams.get("ports") ?? url.searchParams.get("port") ?? "").split(",").map(Number).filter((p) => Number.isInteger(p) && p > 0);
|
|
679
|
+
const selfPort = ctx.webServer.port;
|
|
680
|
+
Promise.all(ports.map((port) => stopPort(port, selfPort))).then((results) => {
|
|
681
|
+
json(res, { ports: results });
|
|
682
|
+
}).catch(() => json(res, { ports: [] }, 500));
|
|
683
|
+
}
|
|
684
|
+
}), "multi-wall: /multi/api/stop");
|
|
685
|
+
ctx.effect(() => ctx.webServer.register({
|
|
686
|
+
kind: "exact",
|
|
687
|
+
path: "/multi/api/create",
|
|
688
|
+
handler: (req, res) => {
|
|
689
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
690
|
+
res.writeHead(405);
|
|
691
|
+
res.end();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const launcher = resolveLauncher();
|
|
695
|
+
const selfPort = ctx.webServer.port;
|
|
696
|
+
pickFreePort(scanFrom, scanTo, selfPort).then((port) => {
|
|
697
|
+
if (port === void 0) {
|
|
698
|
+
json(res, {
|
|
699
|
+
ok: false,
|
|
700
|
+
error: `no free port in ${scanFrom}–${scanTo}`
|
|
701
|
+
}, 409);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
return startInstance(launcher, port).then((result) => {
|
|
705
|
+
json(res, result.ok ? {
|
|
706
|
+
ok: true,
|
|
707
|
+
port
|
|
708
|
+
} : {
|
|
709
|
+
ok: false,
|
|
710
|
+
error: result.error
|
|
711
|
+
}, result.ok ? 200 : 500);
|
|
712
|
+
if (!result.ok) ctx.logger.warn(`multi-wall create failed: ${result.error}`);
|
|
713
|
+
});
|
|
714
|
+
}).catch((error) => {
|
|
715
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
716
|
+
ctx.logger.warn(`multi-wall create error: ${message}`);
|
|
717
|
+
json(res, {
|
|
718
|
+
ok: false,
|
|
719
|
+
error: message
|
|
720
|
+
}, 500);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}), "multi-wall: /multi/api/create");
|
|
724
|
+
ctx.effect(() => ctx.webServer.register({
|
|
725
|
+
kind: "exact",
|
|
726
|
+
path: "/multi/api/link",
|
|
727
|
+
handler: (req, res) => {
|
|
728
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
729
|
+
res.writeHead(405);
|
|
730
|
+
res.end();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const port = ctx.webServer.port;
|
|
734
|
+
const host = ctx.webServer.host;
|
|
735
|
+
const publicUrl = (config.publicUrl ?? "").replace(/\/+$/, "");
|
|
736
|
+
if (publicUrl !== "") {
|
|
737
|
+
json(res, {
|
|
738
|
+
port,
|
|
739
|
+
host,
|
|
740
|
+
lan: [`${publicUrl}/`],
|
|
741
|
+
reachable: true
|
|
742
|
+
});
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const ensureGateway = () => {
|
|
746
|
+
if (gateway !== null && gatewayTargetPort === port) return Promise.resolve(gateway);
|
|
747
|
+
if (gateway !== null) {
|
|
748
|
+
gateway.close();
|
|
749
|
+
gateway = null;
|
|
750
|
+
}
|
|
751
|
+
const token = config.gatewayToken && config.gatewayToken !== "" ? config.gatewayToken : randomBytes(6).toString("hex");
|
|
752
|
+
const gatewayPort = config.gatewayPort && config.gatewayPort !== 0 ? config.gatewayPort : port + 5e3;
|
|
753
|
+
gatewayTargetPort = port;
|
|
754
|
+
return startGateway({
|
|
755
|
+
targetPort: port,
|
|
756
|
+
port: gatewayPort,
|
|
757
|
+
token,
|
|
758
|
+
name: "DSH",
|
|
759
|
+
log: (msg) => ctx.logger.info(`multi-wall gateway: ${msg}`)
|
|
760
|
+
}).then((handle) => {
|
|
761
|
+
gateway = handle;
|
|
762
|
+
return handle;
|
|
763
|
+
});
|
|
764
|
+
};
|
|
765
|
+
ensureGateway().then((handle) => {
|
|
766
|
+
const urls = lanAddresses().map((ip) => `http://${ip}:${handle.port}/`);
|
|
767
|
+
json(res, {
|
|
768
|
+
port,
|
|
769
|
+
host,
|
|
770
|
+
lan: urls,
|
|
771
|
+
gatewayPort: handle.port,
|
|
772
|
+
token: handle.token,
|
|
773
|
+
reachable: urls.length > 0,
|
|
774
|
+
hint: urls.length === 0 ? "no LAN address detected; connect this machine to a network first" : void 0
|
|
775
|
+
});
|
|
776
|
+
}).catch((error) => {
|
|
777
|
+
ctx.logger.warn(`multi-wall gateway start failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
778
|
+
json(res, {
|
|
779
|
+
port,
|
|
780
|
+
host,
|
|
781
|
+
lan: [],
|
|
782
|
+
reachable: false,
|
|
783
|
+
hint: error instanceof Error ? error.message : String(error)
|
|
784
|
+
}, 500);
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}), "multi-wall: /multi/api/link");
|
|
788
|
+
}
|
|
789
|
+
//#endregion
|
|
790
|
+
export { Config, apply, inject, name, stopPort };
|