dsh-vps 1.4.3

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/gate/server.js ADDED
@@ -0,0 +1,1718 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /*
5
+ * dsh-gate — DeepSeek Harness (DSH) VPS 部署的零依赖登录网关。
6
+ *
7
+ * 职责(详见 dsh-vps-architecture-design.md 第 3、4 节):
8
+ * 1. 登录门:scrypt 口令 + HMAC 会话 Cookie + 登录限流
9
+ * 2. 透明代理:Host 原样透传(域名已在 DSH --trusted-host 白名单),
10
+ * 剥离客户端 dsh-auth-* Cookie,注入服务端持有的 DSH 会话 Cookie
11
+ * 3. DSH 进程管理:spawn 子进程、从 stdout 捕获 launchToken、
12
+ * 服务端身份做 token 兑换取得 DSH Cookie、退出自动重启
13
+ * 4. WebSocket upgrade 透传(同样校验登录并注入 Cookie)
14
+ *
15
+ * 零依赖:仅使用 node 内置模块。
16
+ */
17
+
18
+ const http = require("node:http");
19
+ const https = require("node:https");
20
+ const net = require("node:net");
21
+ const crypto = require("node:crypto");
22
+ const fs = require("node:fs");
23
+ const path = require("node:path");
24
+ const { spawn } = require("node:child_process");
25
+ // 站点块模板与 gate 同源管理:install.sh、bin/dsh-vps 也调用它,只此一份
26
+ const { caddySiteBlock, readVpnEnv } = require("./site-block.js");
27
+
28
+ //#region 配置
29
+
30
+ const GATE_HOME = process.env.GATE_HOME || path.resolve(__dirname, "..");
31
+ const STATE_DIR = path.join(GATE_HOME, "state");
32
+ const GATE_HOST = process.env.GATE_HOST || "127.0.0.1";
33
+ const GATE_PORT = Number(process.env.GATE_PORT || 3100);
34
+ const DSH_BIN = process.env.DSH_BIN || path.join(GATE_HOME, "dsh", "current", "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js");
35
+ const DSH_HOST = "127.0.0.1"; // DSH 官方只允许绑回环
36
+ const DSH_PORT = Number(process.env.DSH_PORT || 3080);
37
+ // 向导(/setup)改域名时会在运行期更新,并同步写回 state/gate.env(供下次 systemd 启动)
38
+ let dshTrustedHost = process.env.DSH_TRUSTED_HOST || "";
39
+ const SESSION_TTL_MS = Number(process.env.SESSION_TTL_DAYS || 7) * 86_400_000;
40
+ const SESSION_COOKIE = "dshvps_session";
41
+ const DSH_COOKIE_PREFIX = "dsh-auth-";
42
+ const CONNECT_TIMEOUT_MS = 10_000;
43
+ const LOGIN_WINDOW_MS = 5 * 60_000;
44
+ const LOGIN_MAX_FAILURES = 5;
45
+ const SETUP_WINDOW_MS = 10 * 60_000;
46
+ const SETUP_MAX_FAILURES = 10;
47
+ const CADDY_ADMIN = process.env.CADDY_ADMIN || "http://127.0.0.1:2019";
48
+ const CADDY_SITE_FILE = process.env.CADDY_SITE_FILE || "/etc/caddy/dsh-site.conf";
49
+ const DEEPSEEK_KEY_REF = "DEEPSEEK_API_KEY"; // DSH 约定:deriveKeyRef("deepseek")
50
+ // /setup 向导可选的常用插件(package 名即 `dsh plugin --profile web add <pkg>` 的入参)
51
+ const PLUGIN_OPTIONS = [
52
+ { id: "dshmarket", pkg: "dshmarket", name: "插件市场 dsh-market", desc: "设置页内浏览/搜索/一键安装社区插件与主题,之后想装什么都在这里装" },
53
+ { id: "dsh-vps-manager", pkg: "dsh-vps-manager", name: "VPS 管理 dsh-vps-manager", desc: "在 DSH 里直接管理这台 VPS:不花 token 的查询命令、对话内终端、按风险分级确认的 AI 操作、运维菜谱库" },
54
+ ];
55
+ // pnpm 12 起默认带约 1 天的发布冷却期(minimumReleaseAge),`pnpm add <pkg>` 会装到一天前的旧版。
56
+ // 插件作者修 bug 后用户就该拿到修复,这里关掉冷却期:预装与插件市场安装都取真正的最新版。
57
+ const PLUGIN_ENV = { pnpm_config_minimum_release_age: "0" };
58
+ // 本产品仓库入口:放在 gate 自己的页面(登录 / 初始向导 / 启动等待),
59
+ // 不碰 DSH 原生界面,DSH 升级不受影响。
60
+ const REPO_URL = "https://github.com/AIcivilization/deepseek-harness-vps";
61
+ const REPO_LABEL = "AIcivilization/deepseek-harness-vps";
62
+ // 内联 GitHub 图标,不依赖外部 CDN,离线也能显示
63
+ const REPO_ICON =
64
+ '<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
65
+ '<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>';
66
+ const REPO_CSS =
67
+ ".repo{display:flex;align-items:center;justify-content:center;gap:7px;margin-top:18px;" +
68
+ "color:#7d8a9c;font-size:13px;text-decoration:none}" +
69
+ ".repo:hover{color:#93c5fd}";
70
+ function repoLink() {
71
+ return `<a class="repo" href="${REPO_URL}" target="_blank" rel="noreferrer">${REPO_ICON}<span>${REPO_LABEL}</span></a>`;
72
+ }
73
+ // 无 <style> 的页面(启动等待页)直接内联样式
74
+ function repoLinkInline() {
75
+ return `<a href="${REPO_URL}" target="_blank" rel="noreferrer" style="display:inline-flex;align-items:center;gap:7px;margin-top:22px;color:#7d8a9c;font-size:13px;text-decoration:none">${REPO_ICON}<span>${REPO_LABEL}</span></a>`;
76
+ }
77
+ const DOMAIN_PATTERN = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
78
+ const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
79
+ const SCRYPT_KEYLEN = 64;
80
+
81
+ // 公网访问时页面 hostname 不是回环,DSH 前端会据此判定"这不是操作者自己的浏览器",
82
+ // 进而把设置页降级为不可用:dsh-client-ui-settings 里
83
+ // persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory'
84
+ // 而 isLoopback 由浏览器端的 isLoopbackHostname(location.hostname) 决定(
85
+ // dsh-client-connection),--trusted-host 只打开了网络围栏,不改变这个判定。
86
+ // DSH 官方为"页面自己拥有 Host"的场景留了 __DSH_TRANSPORT__.ownsHost 声明,
87
+ // gate 作为已认证的本地代理注入该声明,即可让设置页/填 Key/权限策略全部恢复。
88
+ // 设 GATE_OWNS_HOST=0 可关闭注入(退回"设置页不可用"的官方默认行为)。
89
+ const OWNS_HOST_INJECT = process.env.GATE_OWNS_HOST !== "0";
90
+ // 与 bin/dsh-vps 的 ownshost 补丁共用同一标记:改前端静态文件是主机制,
91
+ // 这里的代理改写只是补丁缺失时的兜底,两者互相识别、绝不重复注入。
92
+ const OWNS_HOST_MARK = "dsh-vps:ownshost";
93
+ const OWNS_HOST_SNIPPET =
94
+ `<script data-dsh-vps="ownshost">/* ${OWNS_HOST_MARK} */` +
95
+ "window.__DSH_TRANSPORT__=Object.assign(window.__DSH_TRANSPORT__||{},{ownsHost:true});</script>";
96
+ let ownshostLogged = false;
97
+
98
+ // DSH 的若干特权端点(dsh-market 的 restart / backup 导出 / self-uninstall)要求
99
+ // "直连回环":只要出现 x-forwarded-for / x-real-ip / forwarded 任一头,
100
+ // 就认定回环对端是代理而非用户本人并 403。gate 是本机可信代理,转发前剥掉这些头。
101
+ // 设 GATE_STRIP_FORWARDING=0 可关闭剥离。
102
+ const STRIP_FORWARDING = process.env.GATE_STRIP_FORWARDING !== "0";
103
+ const FORWARDING_HEADERS = new Set(["forwarded", "x-forwarded-for", "x-real-ip"]);
104
+
105
+ // dsh-market 的"立即重启"端点。gate 必须接管它:让 dsh-market 自己重启会
106
+ // 在 gate 之外拉起一个新的 DSH 进程,抢占 3080 端口,gate 的子进程随后
107
+ // EADDRINUSE 且再也抓不到 launchToken → 永久"会话尚未就绪"。
108
+ const MARKET_RESTART_PATHS = new Set(["/dsh-market/restart", "/dsh-market/restart/"]);
109
+ // 设 GATE_TAKEOVER_RESTART=0 则放行给 DSH 自己处理(会退回到上面那个坑,仅供对照排障)
110
+ const TAKEOVER_RESTART = process.env.GATE_TAKEOVER_RESTART !== "0";
111
+
112
+ // 逐跳头:代理时重建,不透传(请求侧 transfer-encoding 由 node 自动处理)
113
+ const HOP_HEADERS = new Set([
114
+ "connection",
115
+ "keep-alive",
116
+ "proxy-connection",
117
+ "upgrade",
118
+ "transfer-encoding",
119
+ ]);
120
+
121
+ //#endregion
122
+
123
+ //#region 基础工具
124
+
125
+ function log(message) {
126
+ process.stdout.write(`[gate ${new Date().toISOString()}] ${message}\n`);
127
+ }
128
+
129
+ function esc(s) {
130
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
131
+ }
132
+
133
+ function b64u(buf) {
134
+ return Buffer.from(buf).toString("base64url");
135
+ }
136
+
137
+ function timingSafeEqualStr(a, b) {
138
+ const ab = Buffer.from(String(a), "utf8");
139
+ const bb = Buffer.from(String(b), "utf8");
140
+ if (ab.byteLength !== bb.byteLength) {
141
+ crypto.timingSafeEqual(ab, ab); // 保持常量时间特征
142
+ return false;
143
+ }
144
+ return crypto.timingSafeEqual(ab, bb);
145
+ }
146
+
147
+ function parseCookies(header) {
148
+ const out = Object.create(null);
149
+ if (!header) return out;
150
+ for (const part of String(header).split(";")) {
151
+ const at = part.indexOf("=");
152
+ if (at === -1) continue;
153
+ const key = part.slice(0, at).trim();
154
+ const value = part.slice(at + 1).trim();
155
+ if (key && !(key in out)) out[key] = value;
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /** 请求的规范 authority(与 DSH cookieName 的推导一致)。 */
161
+ function requestAuthority(headers) {
162
+ const host = headers.host;
163
+ if (typeof host !== "string" || host === "") return void 0;
164
+ try {
165
+ return new URL(`http://${host}`).host;
166
+ } catch {
167
+ return void 0;
168
+ }
169
+ }
170
+
171
+ function authorityOf(host) {
172
+ try {
173
+ return new URL(`http://${host}`).host;
174
+ } catch {
175
+ return host;
176
+ }
177
+ }
178
+
179
+ /** 剥掉 GET / 上的 token 查询参数(launchToken 不得经公网链路出现)。 */
180
+ function sanitizedPath(url) {
181
+ const qIdx = url.indexOf("?");
182
+ if (qIdx === -1) return url;
183
+ const pathname = url.slice(0, qIdx);
184
+ if (pathname !== "/") return url;
185
+ const params = new URLSearchParams(url.slice(qIdx + 1));
186
+ params.delete("token");
187
+ const rest = params.toString();
188
+ return rest ? `/?${rest}` : "/";
189
+ }
190
+
191
+ // gate 自己的页面一律带这组头。frame-ancestors 'none' 挡点击劫持,
192
+ // base-uri / form-action 限制在同源;页面用内联 style/script,故放行 unsafe-inline。
193
+ const SECURITY_HEADERS = {
194
+ "x-content-type-options": "nosniff",
195
+ "x-frame-options": "DENY",
196
+ "referrer-policy": "no-referrer",
197
+ "content-security-policy":
198
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
199
+ };
200
+
201
+ function sendHtml(res, status, html, extraHeaders) {
202
+ res.writeHead(status, {
203
+ "content-type": "text/html; charset=utf-8",
204
+ "cache-control": "no-store",
205
+ ...SECURITY_HEADERS,
206
+ ...extraHeaders,
207
+ });
208
+ res.end(html);
209
+ }
210
+
211
+ function sendText(res, status, text) {
212
+ res.writeHead(status, {
213
+ "content-type": "text/plain; charset=utf-8",
214
+ "cache-control": "no-store",
215
+ "x-content-type-options": "nosniff",
216
+ });
217
+ res.end(text);
218
+ }
219
+
220
+ //#endregion
221
+
222
+ //#region 状态文件(session.key / admin.json)
223
+
224
+ function ensureStateDir() {
225
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
226
+ }
227
+
228
+ function sessionKeyPath() {
229
+ return path.join(STATE_DIR, "session.key");
230
+ }
231
+
232
+ function adminPath() {
233
+ return path.join(STATE_DIR, "admin.json");
234
+ }
235
+
236
+ function loadOrCreateSessionKey() {
237
+ const file = sessionKeyPath();
238
+ try {
239
+ const key = fs.readFileSync(file);
240
+ if (key.byteLength === 32) return key;
241
+ } catch {
242
+ /* fallthrough:重新生成 */
243
+ }
244
+ const key = crypto.randomBytes(32);
245
+ fs.writeFileSync(file, key, { mode: 0o600 });
246
+ log("generated new session.key");
247
+ return key;
248
+ }
249
+
250
+ function loadAdmin() {
251
+ try {
252
+ const admin = JSON.parse(fs.readFileSync(adminPath(), "utf8"));
253
+ if (typeof admin.username === "string" && typeof admin.salt === "string" && typeof admin.hash === "string") return admin;
254
+ } catch {
255
+ /* 未配置 */
256
+ }
257
+ return void 0;
258
+ }
259
+
260
+ function hashPassword(password, salt) {
261
+ return crypto.scryptSync(Buffer.from(password, "utf8"), salt, SCRYPT_KEYLEN, SCRYPT_PARAMS);
262
+ }
263
+
264
+ function verifyAdmin(admin, username, password) {
265
+ if (!admin || admin.username !== username) return false;
266
+ const salt = Buffer.from(admin.salt, "hex");
267
+ const expected = Buffer.from(admin.hash, "hex");
268
+ const actual = hashPassword(password, salt);
269
+ return expected.byteLength === actual.byteLength && crypto.timingSafeEqual(actual, expected);
270
+ }
271
+
272
+ //#endregion
273
+
274
+ //#region 会话 Cookie
275
+
276
+ let sessionKey = null; // main() 中初始化
277
+
278
+ // 会话 MAC 绑定当前管理员记录(盐值在每次设置/重置密码时重新生成):
279
+ // reset-admin、改密码或删除 admin.json 后,所有旧会话立即作废,无需重启 gate。
280
+ function sessionMac(admin, body) {
281
+ return b64u(crypto.createHmac("sha256", sessionKey).update(`${admin.salt}\n${admin.username}\n${body}`).digest());
282
+ }
283
+
284
+ function signSession(admin, expiresMs) {
285
+ const body = `${b64u(Buffer.from(admin.username, "utf8"))}.${expiresMs}`;
286
+ return `${body}.${sessionMac(admin, body)}`;
287
+ }
288
+
289
+ function sessionUser(req) {
290
+ const value = parseCookies(req.headers.cookie)[SESSION_COOKIE];
291
+ if (!value) return void 0;
292
+ const parts = value.split(".");
293
+ if (parts.length !== 3) return void 0;
294
+ const [user64, expiresStr, mac] = parts;
295
+ const admin = loadAdmin();
296
+ if (!admin) return void 0;
297
+ if (!timingSafeEqualStr(mac, sessionMac(admin, `${user64}.${expiresStr}`))) return void 0;
298
+ const expiresMs = Number(expiresStr);
299
+ if (!Number.isSafeInteger(expiresMs) || expiresMs <= Date.now()) return void 0;
300
+ try {
301
+ const username = Buffer.from(user64, "base64url").toString("utf8");
302
+ return username === admin.username ? username : void 0;
303
+ } catch {
304
+ return void 0;
305
+ }
306
+ }
307
+
308
+ function sessionCookieHeader(req, admin) {
309
+ const expiresMs = Date.now() + SESSION_TTL_MS;
310
+ // 站点一律 HTTPS(Caddy 自动签发,或自签过渡),Secure 无条件加上。
311
+ // 不读 x-forwarded-proto:那是个可被伪造的请求头,值得信任的只有"这里就是 HTTPS"这件事本身。
312
+ const secure = process.env.GATE_COOKIE_SECURE !== "0";
313
+ return [
314
+ `${SESSION_COOKIE}=${signSession(admin, expiresMs)}`,
315
+ `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
316
+ "Path=/",
317
+ "HttpOnly",
318
+ "SameSite=Lax",
319
+ ...secure ? ["Secure"] : [],
320
+ ].join("; ");
321
+ }
322
+
323
+ //#endregion
324
+
325
+ //#region 登录限流(内存)
326
+
327
+ const loginFailures = new Map(); // ip -> { count, resetAt }
328
+
329
+ function loginRateLimited(ip) {
330
+ const entry = loginFailures.get(ip);
331
+ return entry !== void 0 && entry.count >= LOGIN_MAX_FAILURES && Date.now() <= entry.resetAt;
332
+ }
333
+
334
+ function recordLoginFailure(ip) {
335
+ const now = Date.now();
336
+ let entry = loginFailures.get(ip);
337
+ if (!entry || now > entry.resetAt) entry = { count: 0, resetAt: now + LOGIN_WINDOW_MS };
338
+ entry.count += 1;
339
+ loginFailures.set(ip, entry);
340
+ }
341
+
342
+ function clearLoginFailures(ip) {
343
+ loginFailures.delete(ip);
344
+ }
345
+
346
+ //#endregion
347
+
348
+ //#region DSH 进程管理:spawn / token 捕获 / 兑换 / 续期
349
+
350
+ const dsh = {
351
+ child: null,
352
+ token: null, // 当前进程的 launchToken
353
+ cookie: null, // { name, value, expiresAt, authority }
354
+ restarts: 0,
355
+ cookieAcquired: false, // 当前子进程是否兑换成功过(区分"启动即崩"与"运行后退出")
356
+ startedAt: 0,
357
+ shuttingDown: false,
358
+ lastError: null, // 最近一次阻塞性故障(人类可读,供 /gate/health 与等待页展示)
359
+ lastExchangeError: null, // 最近一次 token 兑换失败原因
360
+ lastExit: null, // { code, signal, at, uptimeMs }
361
+ crashStreak: 0, // 连续快速退出次数(用于退避)
362
+ outputTail: "", // DSH 输出尾部(排障用)
363
+ };
364
+ let exchangeTimer = null;
365
+
366
+ /** DSH 输出是否显示端口被占用(常见于上一轮 DSH 残留进程 / 被外部拉起的 DSH)。 */
367
+ function looksLikePortConflict(text) {
368
+ return /EADDRINUSE|address already in use|端口已被占用/i.test(text);
369
+ }
370
+
371
+ /** 启动前探一下 3080:已被占用说明有残留/外部 DSH,我们的子进程会拿不到端口。 */
372
+ function probePortBusy() {
373
+ return new Promise((resolve) => {
374
+ const socket = net.connect(DSH_PORT, DSH_HOST);
375
+ const done = (busy) => {
376
+ socket.destroy();
377
+ resolve(busy);
378
+ };
379
+ socket.setTimeout(1500);
380
+ socket.on("connect", () => done(true));
381
+ socket.on("timeout", () => done(false));
382
+ socket.on("error", () => done(false));
383
+ });
384
+ }
385
+
386
+ /** 统一入口:重启 DSH 子进程(token/cookie 作废 → 自动重新捕获与兑换)。 */
387
+ function restartDsh(reason) {
388
+ if (dsh.shuttingDown) return;
389
+ log(`restarting dsh${reason ? ` (${reason})` : ""}`);
390
+ dsh.token = null;
391
+ dsh.cookie = null;
392
+ dsh.lastError = null;
393
+ dsh.lastExchangeError = null;
394
+ clearTimeout(exchangeTimer);
395
+ if (dsh.child) dsh.child.kill("SIGTERM"); // exit 处理器负责重新 spawn
396
+ else spawnDsh();
397
+ }
398
+
399
+ function spawnDsh() {
400
+ if (dsh.shuttingDown) return;
401
+ const args = [DSH_BIN, "web", "--port", String(DSH_PORT), "--no-open", "--trusted-host", dshTrustedHost];
402
+ log(`spawning dsh: node ${args.join(" ")}`);
403
+ const child = spawn(process.execPath, args, {
404
+ cwd: GATE_HOME,
405
+ env: { ...process.env, ...PLUGIN_ENV },
406
+ stdio: ["ignore", "pipe", "pipe"],
407
+ });
408
+ dsh.child = child;
409
+ dsh.startedAt = Date.now();
410
+ dsh.token = null;
411
+ dsh.cookie = null;
412
+ dsh.cookieAcquired = false;
413
+
414
+ let scanBuf = "";
415
+ const scan = (chunk) => {
416
+ process.stdout.write(chunk); // DSH 输出原样转发到 journal
417
+ const text = chunk.toString();
418
+ dsh.outputTail = (dsh.outputTail + text).slice(-2048);
419
+ if (looksLikePortConflict(text)) {
420
+ dsh.lastError = `${DSH_PORT} 端口被占用(残留或其他 DSH 进程),本进程无法监听:journal 见 EADDRINUSE。处理:sudo ss -ltnp | grep ${DSH_PORT} 查到 PID 后 kill,或 systemctl restart dsh-gate`;
421
+ log(`dsh startup looks blocked: ${dsh.lastError}`);
422
+ }
423
+ scanBuf = scanForToken(scanBuf + text);
424
+ };
425
+ child.stdout.on("data", scan);
426
+ child.stderr.on("data", scan);
427
+
428
+ child.on("error", (err) => {
429
+ log(`dsh spawn error: ${err.message}`);
430
+ });
431
+ child.on("exit", (code, signal) => {
432
+ log(`dsh exited (code=${code} signal=${signal})`);
433
+ if (dsh.child === child) {
434
+ dsh.child = null;
435
+ dsh.token = null;
436
+ dsh.cookie = null;
437
+ clearTimeout(exchangeTimer);
438
+ if (!dsh.shuttingDown) {
439
+ const uptimeMs = Date.now() - dsh.startedAt;
440
+ dsh.lastExit = { code, signal, at: Date.now(), uptimeMs };
441
+ dsh.restarts += 1;
442
+ // 快速退出,或还没兑换到会话就退出:多半是端口/依赖/配置类硬故障,退避重启,避免空转打满 journal。
443
+ // 不能只看存活时长——DSH 可能先打印 launchToken 再崩(如依赖不兼容),存活时间会超过 5s。
444
+ const crashed = uptimeMs < 5000 || !dsh.cookieAcquired;
445
+ dsh.crashStreak = crashed ? dsh.crashStreak + 1 : 0;
446
+ const delay = dsh.crashStreak > 1 ? Math.min(2000 * 2 ** (dsh.crashStreak - 1), 30_000) : 2000;
447
+ if (dsh.crashStreak >= 3) {
448
+ const errLine = (dsh.outputTail.match(/^\s*(?:[A-Za-z]*Error|error):.*$/gm) || []).pop();
449
+ if (errLine && !dsh.lastError) dsh.lastError = `DSH 连续 ${dsh.crashStreak} 次启动失败:${errLine.trim()}。详见 journalctl -u dsh-gate -n 100`;
450
+ dsh.lastError = dsh.lastError || `DSH 连续 ${dsh.crashStreak} 次快速退出(最近一次 code=${code} signal=${signal},存活 ${uptimeMs}ms)。常见原因:3080 端口被占用、DSH_BIN 路径失效、DSH_HOME 权限问题。详见 journalctl -u dsh-gate -n 100`;
451
+ }
452
+ setTimeout(spawnDsh, delay);
453
+ }
454
+ }
455
+ });
456
+ }
457
+
458
+ /** 首次启动前先探端口,命中则把结论直接写进 lastError,等待页与 health 都能看到。 */
459
+ async function spawnDshWithPreflight() {
460
+ const busy = await probePortBusy();
461
+ if (busy) {
462
+ dsh.lastError = `${DSH_HOST}:${DSH_PORT} 启动前已被占用:可能有残留的 DSH 进程(或 dsh-market 自行拉起的实例)。gate 只能从自己的子进程 stdout 捕获 launchToken,端口被别人占着就永远兑换不到会话。处理:sudo ss -ltnp | grep ${DSH_PORT} → kill 对应 PID,再 systemctl restart dsh-gate`;
463
+ log(dsh.lastError);
464
+ }
465
+ spawnDsh();
466
+ }
467
+
468
+ /** 在 DSH 输出中捕获 `?token=<launchToken>`;命中即触发兑换。 */
469
+ function scanForToken(buf) {
470
+ const match = buf.match(/[?&]token=([A-Za-z0-9_-]{16,})/);
471
+ if (match) {
472
+ if (dsh.token !== match[1]) {
473
+ dsh.token = match[1];
474
+ log("captured dsh launchToken from stdout");
475
+ exchangeToken(0);
476
+ }
477
+ return "";
478
+ }
479
+ return buf.length > 8192 ? buf.slice(-1024) : buf;
480
+ }
481
+
482
+ /**
483
+ * 服务端身份执行官方的 token 兑换:
484
+ * GET /?token=<launchToken>,Host 头设为受信域名 → 303 + Set-Cookie。
485
+ */
486
+ function exchangeToken(attempt) {
487
+ clearTimeout(exchangeTimer);
488
+ const token = dsh.token;
489
+ if (!token || !dsh.child || dsh.shuttingDown) return;
490
+ const req = http.request(
491
+ {
492
+ host: DSH_HOST,
493
+ port: DSH_PORT,
494
+ method: "GET",
495
+ path: `/?token=${token}`,
496
+ headers: { host: dshTrustedHost },
497
+ timeout: 5000,
498
+ },
499
+ (res) => {
500
+ res.resume();
501
+ if (res.statusCode === 303 && dsh.token === token) {
502
+ const line = (res.headers["set-cookie"] || []).find((c) => c.startsWith(DSH_COOKIE_PREFIX));
503
+ if (line) {
504
+ applyDshCookie(line);
505
+ return;
506
+ }
507
+ }
508
+ retryExchange(attempt, `unexpected status ${res.statusCode}`);
509
+ },
510
+ );
511
+ req.on("timeout", () => req.destroy(new Error("exchange timeout")));
512
+ req.on("error", (err) => retryExchange(attempt, err.message));
513
+ req.end();
514
+ }
515
+
516
+ function retryExchange(attempt, why) {
517
+ dsh.lastExchangeError = why;
518
+ if (!dsh.token || !dsh.child || dsh.shuttingDown) return;
519
+ const delay = Math.min(1000 * 2 ** attempt, 15_000);
520
+ log(`token exchange failed (${why}); retry in ${delay}ms`);
521
+ exchangeTimer = setTimeout(() => exchangeToken(attempt + 1), delay);
522
+ }
523
+
524
+ function applyDshCookie(setCookieLine) {
525
+ const [pair, ...attrs] = setCookieLine.split(";");
526
+ const eq = pair.indexOf("=");
527
+ if (eq === -1) return;
528
+ const name = pair.slice(0, eq).trim();
529
+ const value = pair.slice(eq + 1).trim();
530
+ let expiresAt = Date.now() + 30 * 86_400_000;
531
+ for (const attr of attrs) {
532
+ const at = attr.indexOf("=");
533
+ if (at === -1) continue;
534
+ const key = attr.slice(0, at).trim().toLowerCase();
535
+ const val = attr.slice(at + 1).trim();
536
+ if (key === "expires") {
537
+ const t = Date.parse(val);
538
+ if (Number.isFinite(t)) expiresAt = t;
539
+ } else if (key === "max-age") {
540
+ const n = Number(val);
541
+ if (Number.isFinite(n)) expiresAt = Date.now() + n * 1000;
542
+ }
543
+ }
544
+ dsh.cookie = { name, value, expiresAt, authority: authorityOf(dshTrustedHost) };
545
+ dsh.cookieAcquired = true;
546
+ dsh.lastExchangeError = null;
547
+ dsh.lastError = null;
548
+ dsh.crashStreak = 0;
549
+ log(`dsh session cookie acquired (authority=${dsh.cookie.authority}, expires=${new Date(expiresAt).toISOString()})`);
550
+ }
551
+
552
+ /** 构造发往 DSH 的 Cookie 头:剥离客户端的 dsh-auth-*(防伪)与 gate 自身会话,注入服务端 DSH Cookie(仅 authority 匹配时)。 */
553
+ function upstreamCookieHeader(clientCookieHeader, authority) {
554
+ const parts = [];
555
+ for (const [key, value] of Object.entries(parseCookies(clientCookieHeader))) {
556
+ if (key === SESSION_COOKIE || key.startsWith(DSH_COOKIE_PREFIX)) continue;
557
+ parts.push(`${key}=${value}`);
558
+ }
559
+ if (dsh.cookie && authority !== void 0 && dsh.cookie.authority === authority && dsh.cookie.expiresAt > Date.now()) {
560
+ parts.push(`${dsh.cookie.name}=${dsh.cookie.value}`);
561
+ }
562
+ return parts.length ? parts.join("; ") : void 0;
563
+ }
564
+
565
+ //#endregion
566
+
567
+ //#region 登录页
568
+
569
+ function loginPage({ error, notice, next }) {
570
+ return `<!doctype html>
571
+ <html lang="zh">
572
+ <head>
573
+ <meta charset="utf-8">
574
+ <meta name="viewport" content="width=device-width,initial-scale=1">
575
+ <meta name="robots" content="noindex">
576
+ <title>dsh-vps · 登录</title>
577
+ <style>
578
+ :root{color-scheme:dark}
579
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
580
+ background:#0b0e14;color:#dbe2ea;font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
581
+ main{width:min(360px,92vw);padding:32px 28px;border:1px solid #1f2733;border-radius:12px;background:#11161f}
582
+ h1{margin:0 0 4px;font-size:20px;letter-spacing:.5px}
583
+ .sub{margin:0 0 20px;color:#7d8a9c;font-size:13px}
584
+ label{display:block;margin:12px 0 4px;font-size:13px;color:#9aa7b8}
585
+ input{width:100%;box-sizing:border-box;padding:9px 10px;border:1px solid #2a3547;border-radius:8px;
586
+ background:#0d1219;color:#e6edf5;font-size:15px}
587
+ input:focus{outline:none;border-color:#3b82f6}
588
+ button{margin-top:20px;width:100%;padding:10px;border:0;border-radius:8px;background:#2563eb;
589
+ color:#fff;font-size:15px;cursor:pointer}
590
+ button:hover{background:#1d4fd8}
591
+ .err{margin:0 0 12px;padding:8px 10px;border-radius:8px;background:#2a1215;color:#f87171;font-size:13px}
592
+ .notice{margin:0 0 12px;padding:8px 10px;border-radius:8px;background:#101c2e;color:#93c5fd;font-size:13px}
593
+ ${REPO_CSS}
594
+ </style>
595
+ </head>
596
+ <body>
597
+ <main>
598
+ <h1>dsh-vps</h1>
599
+ <p class="sub">DeepSeek Harness 登录门</p>
600
+ ${error ? `<p class="err">${esc(error)}</p>` : ""}
601
+ ${notice ? `<p class="notice">${esc(notice)}</p>` : ""}
602
+ <form method="post" action="/login">
603
+ <input type="hidden" name="next" value="${esc(next || "/")}">
604
+ <label for="u">用户名</label>
605
+ <input id="u" name="username" autocomplete="username" required>
606
+ <label for="p">密码</label>
607
+ <input id="p" name="password" type="password" autocomplete="current-password" required>
608
+ <button type="submit">登录</button>
609
+ </form>
610
+ ${repoLink()}
611
+ </main>
612
+ </body>
613
+ </html>`;
614
+ }
615
+
616
+ function safeNext(value) {
617
+ return typeof value === "string" && /^\/(?!\/)/.test(value) && value.length <= 2048 ? value : "/";
618
+ }
619
+
620
+ function readBody(req, limit) {
621
+ return new Promise((resolve, reject) => {
622
+ const chunks = [];
623
+ let size = 0;
624
+ req.on("data", (chunk) => {
625
+ size += chunk.byteLength;
626
+ if (size > limit) {
627
+ reject(new Error("body too large"));
628
+ req.destroy();
629
+ return;
630
+ }
631
+ chunks.push(chunk);
632
+ });
633
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
634
+ req.on("error", reject);
635
+ });
636
+ }
637
+
638
+ //#endregion
639
+
640
+ //#region HTTP 处理
641
+
642
+ function clientIp(req) {
643
+ const fwd = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
644
+ return fwd || req.socket.remoteAddress || "unknown";
645
+ }
646
+
647
+ async function handleLogin(req, res) {
648
+ const admin = loadAdmin();
649
+ if (req.method === "GET" || req.method === "HEAD") {
650
+ const next = safeNext(new URL(req.url, "http://x").searchParams.get("next"));
651
+ const notice = admin ? void 0 : "尚未配置管理员:请先完成初始设置向导,或运行 `dsh-vps reset-admin`。";
652
+ sendHtml(res, 200, loginPage({ notice, next }));
653
+ return;
654
+ }
655
+ if (req.method !== "POST") {
656
+ sendText(res, 405, "method not allowed");
657
+ return;
658
+ }
659
+ const ip = clientIp(req);
660
+ if (loginRateLimited(ip)) {
661
+ sendHtml(res, 429, loginPage({ error: "尝试次数过多,请稍后再试。", next: "/" }));
662
+ return;
663
+ }
664
+ let form;
665
+ try {
666
+ form = new URLSearchParams(await readBody(req, 64 * 1024));
667
+ } catch {
668
+ sendText(res, 400, "bad request");
669
+ return;
670
+ }
671
+ const username = String(form.get("username") || "");
672
+ const password = String(form.get("password") || "");
673
+ const next = safeNext(form.get("next"));
674
+ if (!admin || !verifyAdmin(admin, username, password)) {
675
+ recordLoginFailure(ip);
676
+ log(`login failure from ${ip} for ${JSON.stringify(username)}`);
677
+ sendHtml(res, 200, loginPage({ error: "用户名或密码错误。", next }));
678
+ return;
679
+ }
680
+ clearLoginFailures(ip);
681
+ log(`login ok from ${ip} (${username})`);
682
+ res.writeHead(303, {
683
+ location: next,
684
+ "set-cookie": sessionCookieHeader(req, admin),
685
+ "cache-control": "no-store",
686
+ });
687
+ res.end();
688
+ }
689
+
690
+ function handleLogout(req, res) {
691
+ res.writeHead(303, {
692
+ location: "/login",
693
+ "set-cookie": `${SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax; Secure`,
694
+ "cache-control": "no-store",
695
+ });
696
+ res.end();
697
+ }
698
+
699
+ /** 直连回环的请求:dsh-vps CLI、install.sh 的健康检查都走这条路。 */
700
+ function isLoopbackRequest(req) {
701
+ const ip = req.socket.remoteAddress || "";
702
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
703
+ }
704
+
705
+ function handleSelfcheckGuarded(req, res) {
706
+ if (!isLoopbackRequest(req)) {
707
+ sendText(res, 403, "forbidden");
708
+ return;
709
+ }
710
+ return handleSelfcheck(req, res);
711
+ }
712
+
713
+ /**
714
+ * /gate/health 会暴露域名、DSH 端口、pid、会话到期时间与崩溃诊断,不能对公网开放。
715
+ * 放行两类:回环(CLI / install.sh 探活)与已登录会话(启动等待页轮询)。
716
+ * 等待页只在登录后才会出现——未登录请求一律被 303 到登录页,不受影响。
717
+ */
718
+ function handleHealthGuarded(req, res) {
719
+ if (!isLoopbackRequest(req) && !sessionUser(req)) {
720
+ sendText(res, 403, "forbidden");
721
+ return;
722
+ }
723
+ return handleHealth(req, res);
724
+ }
725
+
726
+ function handleHealth(req, res) {
727
+ const cookie = dsh.cookie;
728
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
729
+ res.end(
730
+ JSON.stringify({
731
+ gate: "ok",
732
+ uptimeSec: Math.round(process.uptime()),
733
+ adminConfigured: loadAdmin() !== void 0,
734
+ dsh: {
735
+ alive: dsh.child !== null,
736
+ pid: dsh.child ? dsh.child.pid : null,
737
+ restarts: dsh.restarts,
738
+ trustedHost: dshTrustedHost,
739
+ port: DSH_PORT,
740
+ },
741
+ launchTokenCaptured: dsh.token !== null,
742
+ dshCookie: cookie
743
+ ? { authority: cookie.authority, expiresAt: cookie.expiresAt, expiresInHours: Math.round((cookie.expiresAt - Date.now()) / 3_600_000) }
744
+ : null,
745
+ lastError: dsh.lastError,
746
+ lastExchangeError: dsh.lastExchangeError,
747
+ lastExit: dsh.lastExit,
748
+ crashStreak: dsh.crashStreak,
749
+ }),
750
+ );
751
+ }
752
+
753
+ /**
754
+ * DSH 会话尚未就绪(DSH 还在启动,或 token 兑换/端口出了问题)。
755
+ * 页面自己轮询 /gate/health,一旦会话就绪立即刷新;同时把阻塞原因直接摆在页面上,
756
+ * 免得用户只能去翻 journalctl。
757
+ */
758
+ function sendDshNotReady(res) {
759
+ const state = notReadyState();
760
+ sendHtml(
761
+ res,
762
+ 503,
763
+ `<!doctype html><meta charset="utf-8"><title>503 DeepSeek Harness 启动中</title>
764
+ <meta name="robots" content="noindex">
765
+ <body style="background:#0b0e14;color:#dbe2ea;font:15px/1.7 system-ui,-apple-system,'Segoe UI',sans-serif;padding:40px;max-width:760px">
766
+ <h2 style="margin:0 0 4px">DeepSeek Harness 正在启动</h2>
767
+ <p style="color:#7d8a9c;margin:0 0 20px">gate 还没拿到 DSH 会话;本页每 3 秒自动检查一次,就绪后会自动刷新。</p>
768
+ <table style="border-collapse:collapse;font-size:14px">
769
+ <tr><td style="padding:3px 16px 3px 0;color:#9aa7b8">DSH 子进程</td><td id="s-alive">${state.childAlive ? "运行中" : "未运行"}</td></tr>
770
+ <tr><td style="padding:3px 16px 3px 0;color:#9aa7b8">launchToken</td><td id="s-token">${state.tokenCaptured ? "已捕获" : "未捕获"}</td></tr>
771
+ <tr><td style="padding:3px 16px 3px 0;color:#9aa7b8">会话 Cookie</td><td id="s-cookie">${state.cookieReady ? "已就绪" : "等待兑换"}</td></tr>
772
+ </table>
773
+ <p id="s-err" style="margin:16px 0 0;padding:10px 12px;border-radius:8px;background:#2a2112;color:#fbbf24;font-size:13px;${state.error ? "" : "display:none"}">${esc(state.error || "")}</p>
774
+ <p id="s-wait" style="color:#7d8a9c;font-size:13px;margin:16px 0 0">已等待 <span id="s-sec">0</span> 秒… <button onclick="location.reload()" style="margin-left:8px;padding:4px 10px;border:1px solid #2a3547;border-radius:6px;background:#0d1219;color:#dbe2ea;cursor:pointer">立即刷新</button></p>
775
+ <p style="color:#5c6b7e;font-size:12px;margin:20px 0 0">超过 2 分钟仍未就绪,多半是 3080 端口被残留进程占用或 DSH 启动失败:<code>journalctl -u dsh-gate -n 100</code>,然后 <code>systemctl restart dsh-gate</code>。</p>
776
+ ${repoLinkInline()}
777
+ <script>
778
+ var t0=Date.now();
779
+ setInterval(function(){document.getElementById('s-sec').textContent=Math.round((Date.now()-t0)/1000)},1000);
780
+ setInterval(function(){
781
+ fetch('/gate/health',{cache:'no-store'}).then(function(r){return r.json()}).then(function(h){
782
+ var alive=h.dsh&&h.dsh.alive, tok=h.launchTokenCaptured, ck=!!h.dshCookie;
783
+ document.getElementById('s-alive').textContent=alive?'运行中':'未运行';
784
+ document.getElementById('s-token').textContent=tok?'已捕获':'未捕获';
785
+ document.getElementById('s-cookie').textContent=ck?'已就绪':'等待兑换';
786
+ var err=(h.dsh&&h.dsh.lastError)||h.lastExchangeError||'';
787
+ var box=document.getElementById('s-err');
788
+ if(err){box.style.display='';box.textContent=err}else{box.style.display='none'}
789
+ if(ck) location.reload();
790
+ }).catch(function(){});
791
+ },3000);
792
+ </script></body>`,
793
+ { "retry-after": "3" },
794
+ );
795
+ }
796
+
797
+ function notReadyState() {
798
+ const error = dsh.lastError || dsh.lastExchangeError || (dsh.lastExit ? `DSH 已退出 (code=${dsh.lastExit.code} signal=${dsh.lastExit.signal}),即将自动重启` : null);
799
+ return {
800
+ childAlive: dsh.child !== null,
801
+ tokenCaptured: dsh.token !== null,
802
+ cookieReady: dsh.cookie !== null,
803
+ error,
804
+ };
805
+ }
806
+
807
+ function sendBadGateway(res) {
808
+ sendHtml(
809
+ res,
810
+ 502,
811
+ `<!doctype html><meta charset="utf-8"><title>502</title>
812
+ <body style="background:#0b0e14;color:#dbe2ea;font:15px system-ui;padding:40px">
813
+ <h2>无法连接 DeepSeek Harness</h2>
814
+ <p>DSH 后端(127.0.0.1:${DSH_PORT})不可达。</p>
815
+ <p style="color:#7d8a9c">排障:journalctl -u dsh-gate -n 50</p></body>`,
816
+ );
817
+ }
818
+
819
+ /**
820
+ * 收下上游 HTML(限 8MiB),在 <head> 之后插入 ownsHost 声明后整体下发。
821
+ * 只有文档型 HTML 需要改写;其它响应仍是 pipe 直通,不损失流式能力。
822
+ */
823
+ function collectAndInject(upRes, res, status, respHeaders) {
824
+ const MAX_BYTES = 32 * 1024 * 1024;
825
+ let body = "";
826
+ let size = 0;
827
+ let overflow = false;
828
+ upRes.setEncoding("utf8"); // 按字符边界收,避免多字节字符被 chunk 切断
829
+ upRes.on("data", (chunk) => {
830
+ if (overflow) {
831
+ res.write(chunk); // 已进入直通:剩余部分原样流式回传
832
+ return;
833
+ }
834
+ size += Buffer.byteLength(chunk, "utf8");
835
+ if (size > MAX_BYTES) {
836
+ // 超限:放弃改写,已收集部分 + 后续全部原样转发(不截断)
837
+ overflow = true;
838
+ res.writeHead(status, respHeaders); // 无 content-length → 走 chunked
839
+ res.write(body);
840
+ res.write(chunk);
841
+ body = "";
842
+ return;
843
+ }
844
+ body += chunk;
845
+ });
846
+ upRes.on("end", () => {
847
+ if (overflow) {
848
+ res.end();
849
+ return;
850
+ }
851
+ // 静态文件补丁(bin/dsh-vps ownshost on)已经在页面里时不再重复注入
852
+ if (!body.includes(OWNS_HOST_MARK)) {
853
+ // 只有完整的 HTML 文档才改写;找不到 <head> 说明不是文档(片段/JSON 误标),原样转发
854
+ const at = body.search(/<head\b[^>]*>/i);
855
+ if (at !== -1) {
856
+ const end = body.indexOf(">", at) + 1;
857
+ body = body.slice(0, end) + OWNS_HOST_SNIPPET + body.slice(end);
858
+ if (!ownshostLogged) {
859
+ ownshostLogged = true;
860
+ log("injected ownsHost by proxy (静态文件补丁未生效,建议 sudo dsh-vps ownshost on)");
861
+ }
862
+ }
863
+ }
864
+ const payload = Buffer.from(body, "utf8");
865
+ res.writeHead(status, { ...respHeaders, "content-length": payload.byteLength });
866
+ res.end(payload);
867
+ });
868
+ upRes.on("error", () => res.end());
869
+ }
870
+
871
+ /** 透明代理:Host 原样透传,重建 Cookie(剥离 dsh-auth-* → 注入服务端 DSH Cookie),剥掉 /?token=。 */
872
+ function proxyHttp(req, res) {
873
+ if (!dsh.cookie) {
874
+ sendDshNotReady(res);
875
+ return;
876
+ }
877
+ const authority = requestAuthority(req.headers);
878
+ // 导航请求(浏览器要 HTML):必须拿到完整 200 才能改写,否则浏览器会用本地
879
+ // 缓存的那份没有 ownsHost 的旧 HTML,设置页就会一直报"在此浏览器中不可用"。
880
+ const wantsHtml = OWNS_HOST_INJECT && String(req.headers.accept || "").includes("text/html");
881
+ const headers = {};
882
+ for (const [key, value] of Object.entries(req.headers)) {
883
+ if (HOP_HEADERS.has(key) || key === "cookie") continue;
884
+ // 转发头会让 DSH 判定"回环对端是代理"而拒绝特权端点(如 dsh-market 重启)
885
+ if (STRIP_FORWARDING && FORWARDING_HEADERS.has(key)) continue;
886
+ // 条件请求会让上游回 304(无 body 可改写),导航请求一律取全量
887
+ if (wantsHtml && (key === "if-none-match" || key === "if-modified-since")) continue;
888
+ headers[key] = value;
889
+ }
890
+ const cookie = upstreamCookieHeader(req.headers.cookie, authority);
891
+ if (cookie !== void 0) headers.cookie = cookie;
892
+
893
+ let responded = false;
894
+ const upstreamReq = http.request(
895
+ {
896
+ host: DSH_HOST,
897
+ port: DSH_PORT,
898
+ method: req.method,
899
+ path: sanitizedPath(req.url || "/"),
900
+ headers,
901
+ timeout: CONNECT_TIMEOUT_MS,
902
+ },
903
+ (upRes) => {
904
+ responded = true;
905
+ const respHeaders = {};
906
+ let isHtml = false;
907
+ let encoded = false; // 上游已压缩:无法改写,原样直通
908
+ for (const [key, value] of Object.entries(upRes.headers)) {
909
+ if (HOP_HEADERS.has(key)) continue;
910
+ if (key === "content-length") continue; // 长度按最终响应体重算
911
+ if (key === "content-type") {
912
+ isHtml = String(value).includes("text/html");
913
+ }
914
+ if (key === "content-encoding") encoded = true;
915
+ if (key === "set-cookie") {
916
+ // DSH Cookie 绝不下发到用户浏览器
917
+ const filtered = value.filter((c) => !c.startsWith(DSH_COOKIE_PREFIX));
918
+ if (filtered.length) respHeaders[key] = filtered;
919
+ continue;
920
+ }
921
+ respHeaders[key] = value;
922
+ }
923
+ // HTML 文档:注入 ownsHost 声明,让设置页在公网域名下同样可用(见文件头说明)。
924
+ // 只对导航型 GET/HEAD 的 200 响应改写;其余(含任何流式响应)一律 pipe 直通。
925
+ const navigational = req.method === "GET" || req.method === "HEAD";
926
+ if (OWNS_HOST_INJECT && isHtml && navigational && !encoded && upRes.statusCode === 200) {
927
+ // 改写过的 HTML 不能再被缓存复用,也不该再带校验器(否则下次又走 304)
928
+ const injected = { ...respHeaders, "cache-control": "no-store" };
929
+ delete injected.etag;
930
+ delete injected["last-modified"];
931
+ collectAndInject(upRes, res, upRes.statusCode, injected);
932
+ return;
933
+ }
934
+ res.writeHead(upRes.statusCode || 502, respHeaders);
935
+ upRes.pipe(res);
936
+ },
937
+ );
938
+ // CONNECT_TIMEOUT_MS 只约束"连上 DSH"这一步。连上后清掉超时:DSH 的非流式 API
939
+ // (如一次完整的模型调用)可能很久才回响应头,不能被当成连接超时切成 502。
940
+ upstreamReq.on("socket", (socket) => {
941
+ if (socket.connecting) socket.once("connect", () => upstreamReq.setTimeout(0));
942
+ else upstreamReq.setTimeout(0); // keep-alive 复用的连接早已连上
943
+ });
944
+ upstreamReq.on("timeout", () => {
945
+ if (!responded) upstreamReq.destroy(new Error("upstream connect timeout"));
946
+ });
947
+ upstreamReq.on("error", (err) => {
948
+ log(`proxy error: ${err.message}`);
949
+ if (!res.headersSent) sendBadGateway(res);
950
+ else res.end();
951
+ });
952
+ req.pipe(upstreamReq);
953
+ }
954
+
955
+ function denyUnauthenticated(req, res, pathname) {
956
+ if (pathname.startsWith("/api")) {
957
+ sendText(res, 401, "gate authentication required");
958
+ return;
959
+ }
960
+ const next = encodeURIComponent((req.url || "/").slice(0, 2048));
961
+ res.writeHead(303, { location: `/login?next=${next}`, "cache-control": "no-store" });
962
+ res.end();
963
+ }
964
+
965
+ /**
966
+ * 接管 dsh-market 的"立即重启":官方实现会自行拉起一个新的 dsh 进程,
967
+ * 在 systemd 下会脱离 gate 的父子关系——新进程抢走 3080,gate 的子进程随后
968
+ * EADDRINUSE,且 gate 永远读不到新进程的 launchToken(页面卡在"会话尚未就绪")。
969
+ * 这里按官方客户端的协议回 202 + ok,然后由 gate 自己重启 DSH 子进程:
970
+ * 子进程是全新的,/dsh-market/status 的 boot id 随之变化,前端会自动 reload。
971
+ */
972
+ function handleMarketRestart(req, res) {
973
+ if (req.method !== "POST") {
974
+ res.writeHead(405, { allow: "POST", "content-length": "0" });
975
+ res.end();
976
+ return;
977
+ }
978
+ log("market restart requested; gate takes over");
979
+ res.writeHead(202, { "content-type": "application/json", "cache-control": "no-store" });
980
+ res.end(JSON.stringify({ ok: true, managedBy: "dsh-gate", note: "由 gate 重启 DSH 子进程" }));
981
+ // 让 202 先落地,再动手(客户端随后轮询 /dsh-market/status 等 boot id 变化)
982
+ setTimeout(() => restartDsh("market restart"), 300);
983
+ }
984
+
985
+ //#endregion
986
+
987
+ //#region WebSocket upgrade 透传
988
+
989
+ function handleUpgrade(req, socket, head) {
990
+ const user = sessionUser(req);
991
+ if (!user) {
992
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
993
+ socket.destroy();
994
+ return;
995
+ }
996
+ if (!dsh.cookie) {
997
+ socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
998
+ socket.destroy();
999
+ return;
1000
+ }
1001
+ const authority = requestAuthority(req.headers);
1002
+ const upstream = net.connect(DSH_PORT, DSH_HOST, () => {
1003
+ const cookie = upstreamCookieHeader(req.headers.cookie, authority);
1004
+ const lines = [`${req.method} ${sanitizedPath(req.url || "/")} HTTP/1.1`];
1005
+ for (const [key, value] of Object.entries(req.headers)) {
1006
+ if (key === "cookie") continue;
1007
+ if (STRIP_FORWARDING && FORWARDING_HEADERS.has(key)) continue;
1008
+ if (Array.isArray(value)) for (const v of value) lines.push(`${key}: ${v}`);
1009
+ else lines.push(`${key}: ${value}`);
1010
+ }
1011
+ if (cookie !== void 0) lines.push(`cookie: ${cookie}`);
1012
+ upstream.write(lines.join("\r\n") + "\r\n\r\n");
1013
+ if (head && head.length) upstream.write(head);
1014
+ socket.pipe(upstream);
1015
+ upstream.pipe(socket);
1016
+ });
1017
+ const kill = () => {
1018
+ socket.destroy();
1019
+ upstream.destroy();
1020
+ };
1021
+ socket.on("error", kill);
1022
+ upstream.on("error", kill);
1023
+ }
1024
+
1025
+ //#endregion
1026
+
1027
+ //#region M3:服务端 RPC / setup 向导 / 域名变更 / 自检
1028
+
1029
+ function setupLockPath() {
1030
+ return path.join(STATE_DIR, "setup.lock");
1031
+ }
1032
+
1033
+ function setupOpen() {
1034
+ return !fs.existsSync(setupLockPath());
1035
+ }
1036
+
1037
+ // ---- 启动令牌 ----
1038
+ // 从 install.sh 跑完到用户第一次打开浏览器之间,/setup 对全网开放:谁先提交谁就是
1039
+ // 管理员。install.sh 因此生成一个一次性令牌写进 state/setup.token,并打印带令牌的
1040
+ // URL;向导提交成功后立刻删除令牌文件,令牌永久失效。
1041
+ // 没有令牌文件时(手工部署、令牌被清),行为退回旧版的开放向导,不锁死用户。
1042
+ function setupTokenPath() {
1043
+ return path.join(STATE_DIR, "setup.token");
1044
+ }
1045
+
1046
+ function loadSetupToken() {
1047
+ try {
1048
+ const token = fs.readFileSync(setupTokenPath(), "utf8").trim();
1049
+ return /^[A-Za-z0-9]{16,64}$/.test(token) ? token : null;
1050
+ } catch {
1051
+ return null;
1052
+ }
1053
+ }
1054
+
1055
+ function clearSetupToken() {
1056
+ try {
1057
+ fs.rmSync(setupTokenPath());
1058
+ log("setup token consumed");
1059
+ } catch {
1060
+ /* 已不存在 */
1061
+ }
1062
+ }
1063
+
1064
+ function setupTokenValid(provided) {
1065
+ const expected = loadSetupToken();
1066
+ if (!expected) return true; // 未启用令牌
1067
+ if (typeof provided !== "string" || provided.length !== expected.length) return false;
1068
+ return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
1069
+ }
1070
+
1071
+ /** 令牌可来自查询串(GET / 重定向)或表单字段(POST)。 */
1072
+ function providedSetupToken(req, form) {
1073
+ const fromForm = form ? String(form.get("token") || "") : "";
1074
+ if (fromForm) return fromForm;
1075
+ const q = (req.url || "").split("?")[1];
1076
+ return q ? String(new URLSearchParams(q).get("token") || "") : "";
1077
+ }
1078
+
1079
+ /** 服务端身份调用 DSH 特权 RPC(走已注入的会话 Cookie,等价于"登录态探测")。 */
1080
+ function dshRpc(method, args) {
1081
+ return new Promise((resolve, reject) => {
1082
+ if (!dsh.cookie || dsh.cookie.expiresAt <= Date.now()) {
1083
+ reject(new Error("dsh session not ready"));
1084
+ return;
1085
+ }
1086
+ const rpcId = crypto.randomUUID();
1087
+ const body = JSON.stringify({ type: "client-request", rpcId, method, payload: { args } });
1088
+ const req = http.request(
1089
+ {
1090
+ host: DSH_HOST,
1091
+ port: DSH_PORT,
1092
+ method: "POST",
1093
+ path: `/api/${method}`,
1094
+ headers: {
1095
+ host: dshTrustedHost,
1096
+ "content-type": "application/json",
1097
+ "content-length": Buffer.byteLength(body),
1098
+ cookie: `${dsh.cookie.name}=${dsh.cookie.value}`,
1099
+ },
1100
+ timeout: 10_000,
1101
+ },
1102
+ (res) => {
1103
+ const chunks = [];
1104
+ res.on("data", (c) => chunks.push(c));
1105
+ res.on("end", () => {
1106
+ try {
1107
+ if (res.statusCode !== 200) {
1108
+ reject(new Error(`rpc http ${res.statusCode}`));
1109
+ return;
1110
+ }
1111
+ const json = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1112
+ if (json.type !== "server-response" || json.rpcId !== rpcId) {
1113
+ reject(new Error("rpc envelope mismatch"));
1114
+ return;
1115
+ }
1116
+ if (json.result && json.result.ok === true) resolve(json.result.value);
1117
+ else reject(new Error((json.result && json.result.error && (json.result.error.message || json.result.error.code)) || "rpc failed"));
1118
+ } catch (err) {
1119
+ reject(err);
1120
+ }
1121
+ });
1122
+ },
1123
+ );
1124
+ req.on("timeout", () => req.destroy(new Error("rpc timeout")));
1125
+ req.on("error", reject);
1126
+ req.end(body);
1127
+ });
1128
+ }
1129
+
1130
+ function waitForDshCookie(timeoutMs) {
1131
+ return new Promise((resolve, reject) => {
1132
+ const start = Date.now();
1133
+ const tick = () => {
1134
+ if (dsh.cookie) return resolve();
1135
+ if (Date.now() - start > timeoutMs) return reject(new Error("等待 DSH 会话就绪超时"));
1136
+ setTimeout(tick, 500);
1137
+ };
1138
+ tick();
1139
+ });
1140
+ }
1141
+
1142
+ /**
1143
+ * 经 Caddy admin API(127.0.0.1:2019)热加载:把完整 Caddyfile 以 caddyfile 适配器
1144
+ * POST 到 /load。gate 以 dsh 用户运行,无权执行 `caddy reload`,admin API 是官方途径。
1145
+ */
1146
+ function caddyReload() {
1147
+ return new Promise((resolve, reject) => {
1148
+ let caddyfile;
1149
+ try {
1150
+ caddyfile = fs.readFileSync("/etc/caddy/Caddyfile", "utf8");
1151
+ } catch (err) {
1152
+ reject(new Error(`读取 /etc/caddy/Caddyfile 失败: ${err.message}`));
1153
+ return;
1154
+ }
1155
+ const body = JSON.stringify({ config: caddyfile, adapter: "caddyfile" });
1156
+ const url = new URL(`${CADDY_ADMIN}/load`);
1157
+ const req = http.request(
1158
+ {
1159
+ host: url.hostname,
1160
+ port: url.port || 80,
1161
+ method: "POST",
1162
+ path: url.pathname,
1163
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) },
1164
+ timeout: 20_000,
1165
+ },
1166
+ (res) => {
1167
+ const chunks = [];
1168
+ res.on("data", (c) => chunks.push(c));
1169
+ res.on("end", () => {
1170
+ if (res.statusCode === 200) resolve();
1171
+ else reject(new Error(`caddy admin ${res.statusCode}: ${Buffer.concat(chunks).toString("utf8").slice(0, 300)}`));
1172
+ });
1173
+ },
1174
+ );
1175
+ req.on("timeout", () => req.destroy(new Error("caddy admin timeout")));
1176
+ req.on("error", reject);
1177
+ req.end(body);
1178
+ });
1179
+ }
1180
+
1181
+ /** 把新的 trusted host 持久化到 state/gate.env 与 state/config.json(供下次 systemd 启动与 status 展示)。 */
1182
+ function persistTrustedHost(domain) {
1183
+ try {
1184
+ const lines = [`GATE_HOME=${process.env.GATE_HOME || GATE_HOME}`, `DSH_BIN=${process.env.DSH_BIN || DSH_BIN}`];
1185
+ if (process.env.DSH_HOME) lines.push(`DSH_HOME=${process.env.DSH_HOME}`);
1186
+ lines.push(`DSH_TRUSTED_HOST=${domain}`);
1187
+ fs.writeFileSync(path.join(STATE_DIR, "gate.env"), lines.join("\n") + "\n", { mode: 0o600 });
1188
+ } catch (err) {
1189
+ log(`warn: persist gate.env failed: ${err.message}`);
1190
+ }
1191
+ try {
1192
+ const cfgFile = path.join(STATE_DIR, "config.json");
1193
+ const cfg = JSON.parse(fs.readFileSync(cfgFile, "utf8"));
1194
+ cfg.domain = domain;
1195
+ cfg.trustedHost = domain;
1196
+ fs.writeFileSync(cfgFile, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
1197
+ } catch {
1198
+ /* config.json 可选 */
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * 向导改域名:写站点配置文件(主 Caddyfile 已 import 它)→ 经 Caddy admin API 热加载 →
1204
+ * 更新运行期 trustedHost 并持久化 → 重启 DSH 子进程(自动重新捕获 token + 兑换)。
1205
+ * 站点文件由 install.sh 预创建并属主 dsh(组 caddy 可读),gate 无需 root。
1206
+ */
1207
+ // 站点块模板在 gate/site-block.js(install.sh 与 dsh-vps vpn 共用)。
1208
+ // 改域名时必须带上当前隧道策略,否则「仅隧道可访问」会被改域名动作悄悄抹掉。
1209
+ function siteBlock(host) {
1210
+ return caddySiteBlock(host, { gatePort: GATE_PORT, vpn: readVpnEnv(STATE_DIR) });
1211
+ }
1212
+
1213
+ async function applyDomainChange(domain) {
1214
+ fs.writeFileSync(CADDY_SITE_FILE, siteBlock(domain));
1215
+ try {
1216
+ await caddyReload();
1217
+ log(`caddy reloaded with site ${domain}`);
1218
+ } catch (err) {
1219
+ log(`warn: caddy reload failed: ${err.message}(站点配置已写入,Caddy 重启后生效)`);
1220
+ }
1221
+ dshTrustedHost = domain;
1222
+ persistTrustedHost(domain);
1223
+ restartDsh(`trusted host changed to ${domain}`);
1224
+ }
1225
+
1226
+ function writeAdminRecord(username, password) {
1227
+ ensureStateDir();
1228
+ const salt = crypto.randomBytes(16);
1229
+ const record = {
1230
+ username,
1231
+ salt: salt.toString("hex"),
1232
+ hash: hashPassword(password, salt).toString("hex"),
1233
+ scrypt: SCRYPT_PARAMS,
1234
+ createdAt: Date.now(),
1235
+ };
1236
+ fs.writeFileSync(adminPath(), JSON.stringify(record, null, 2), { mode: 0o600 });
1237
+ }
1238
+
1239
+ /** 以 DSH 子命令运行(如 `plugin --profile web add <pkg>`),返回 { code, stdout, stderr }。 */
1240
+ function runDshCli(cmdArgs, timeoutMs = 5 * 60_000) {
1241
+ return new Promise((resolve) => {
1242
+ const child = spawn(process.execPath, [DSH_BIN, ...cmdArgs], {
1243
+ cwd: GATE_HOME,
1244
+ env: { ...process.env, ...PLUGIN_ENV },
1245
+ stdio: ["ignore", "pipe", "pipe"],
1246
+ });
1247
+ let stdout = "";
1248
+ let stderr = "";
1249
+ let done = false;
1250
+ const finish = (code) => {
1251
+ if (done) return;
1252
+ done = true;
1253
+ resolve({ code, stdout, stderr });
1254
+ };
1255
+ child.stdout.on("data", (c) => { stdout += c; });
1256
+ child.stderr.on("data", (c) => { stderr += c; });
1257
+ child.on("error", (err) => finish(128));
1258
+ child.on("exit", (code) => finish(code === null ? 128 : code));
1259
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
1260
+ timer.unref();
1261
+ });
1262
+ }
1263
+
1264
+ /** 安装时用的 registry:install.sh --mirror cn 记在 config.json 里,与 DSH 本体同源。 */
1265
+ function npmRegistry() {
1266
+ try {
1267
+ const cfg = JSON.parse(fs.readFileSync(path.join(STATE_DIR, "config.json"), "utf8"));
1268
+ if (cfg.mirror === "cn") return "https://registry.npmmirror.com";
1269
+ } catch {
1270
+ /* 默认源 */
1271
+ }
1272
+ return "https://registry.npmjs.org";
1273
+ }
1274
+
1275
+ /** 查询包在 registry 上的 latest 版本;失败返回 null(调用方退回不带版本号安装)。 */
1276
+ function latestVersion(pkg) {
1277
+ return new Promise((resolve) => {
1278
+ const url = `${npmRegistry()}/${encodeURIComponent(pkg).replace(/^%40/, "@")}/latest`;
1279
+ const req = https.get(url, { headers: { accept: "application/json" }, timeout: 10_000 }, (res) => {
1280
+ const chunks = [];
1281
+ res.on("data", (c) => chunks.push(c));
1282
+ res.on("end", () => {
1283
+ try {
1284
+ const v = res.statusCode === 200 ? JSON.parse(Buffer.concat(chunks).toString("utf8")).version : null;
1285
+ resolve(typeof v === "string" && /^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(v) ? v : null);
1286
+ } catch {
1287
+ resolve(null);
1288
+ }
1289
+ });
1290
+ });
1291
+ req.on("timeout", () => req.destroy());
1292
+ req.on("error", () => resolve(null));
1293
+ });
1294
+ }
1295
+
1296
+ /**
1297
+ * 后台安装一批常用插件(不阻塞向导响应)。
1298
+ * `dsh plugin` 内部转发给 pnpm;装完置空 token 触发一次 DSH 重启,让新 bundle 进入 profile 生效。
1299
+ */
1300
+ async function installPlugins(options) {
1301
+ let installed = 0;
1302
+ for (const opt of options) {
1303
+ // 下限钉在 registry 上的 latest:即使 pnpm 冷却期配置被别处覆盖,也至少装到最新版;
1304
+ // 用 ^ 而非精确版本,profile 里记成范围,之后插件市场的"更新"照常能升级。
1305
+ const version = await latestVersion(opt.pkg);
1306
+ const pkg = version ? `${opt.pkg}@^${version}` : opt.pkg;
1307
+ log(`installing plugin: ${pkg}${version ? "" : "(未查到 latest,交给 pnpm 解析)"}`);
1308
+ // -w 等附加参数由插件作者的安装命令指定,dsh 原样透传给 pnpm
1309
+ const res = await runDshCli(["plugin", "--profile", "web", "add", ...(opt.args || []), pkg]);
1310
+ if (res.code === 0) {
1311
+ installed += 1;
1312
+ log(`plugin installed: ${pkg}`);
1313
+ } else {
1314
+ const tail = (res.stderr || res.stdout || "").trim().split("\n").slice(-4).join(" | ");
1315
+ log(`plugin install failed for ${pkg}: code=${res.code}${tail ? ` | ${tail}` : ""}`);
1316
+ if (/pnpm not found/i.test(res.stderr || "")) {
1317
+ log("hint: install pnpm first (`npm install -g pnpm`), or re-run install.sh");
1318
+ break; // 后续插件同样会因缺 pnpm 失败,不再逐个重试
1319
+ }
1320
+ }
1321
+ }
1322
+ if (installed > 0) restartDsh("plugins installed");
1323
+ }
1324
+
1325
+ // 向导限流(内存):同 IP 10 次 / 10 分钟
1326
+ const setupFailures = new Map();
1327
+
1328
+ function setupRateLimited(ip) {
1329
+ const entry = setupFailures.get(ip);
1330
+ return entry !== void 0 && entry.count >= SETUP_MAX_FAILURES && Date.now() <= entry.resetAt;
1331
+ }
1332
+
1333
+ function recordSetupFailure(ip) {
1334
+ const now = Date.now();
1335
+ let entry = setupFailures.get(ip);
1336
+ if (!entry || now > entry.resetAt) entry = { count: 0, resetAt: now + SETUP_WINDOW_MS };
1337
+ entry.count += 1;
1338
+ setupFailures.set(ip, entry);
1339
+ }
1340
+
1341
+ function currentDomainHint(req) {
1342
+ try {
1343
+ const cfg = JSON.parse(fs.readFileSync(path.join(STATE_DIR, "config.json"), "utf8"));
1344
+ if (typeof cfg.domain === "string" && cfg.domain && DOMAIN_PATTERN.test(cfg.domain)) return cfg.domain;
1345
+ } catch {
1346
+ /* fallthrough */
1347
+ }
1348
+ const authority = requestAuthority(req.headers);
1349
+ if (authority && DOMAIN_PATTERN.test(authorityOf(authority).split(":")[0])) return authorityOf(authority).split(":")[0];
1350
+ return "";
1351
+ }
1352
+
1353
+ function setupPage({ error, username, domain, warnings, token }) {
1354
+ return `<!doctype html>
1355
+ <html lang="zh">
1356
+ <head>
1357
+ <meta charset="utf-8">
1358
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1359
+ <meta name="robots" content="noindex">
1360
+ <title>dsh-vps · 初始设置</title>
1361
+ <style>
1362
+ :root{color-scheme:dark}
1363
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
1364
+ background:#0b0e14;color:#dbe2ea;font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
1365
+ main{width:min(420px,92vw);padding:32px 28px;border:1px solid #1f2733;border-radius:12px;background:#11161f}
1366
+ h1{margin:0 0 4px;font-size:20px;letter-spacing:.5px}
1367
+ .sub{margin:0 0 20px;color:#7d8a9c;font-size:13px}
1368
+ label{display:block;margin:12px 0 4px;font-size:13px;color:#9aa7b8}
1369
+ input{width:100%;box-sizing:border-box;padding:9px 10px;border:1px solid #2a3547;border-radius:8px;
1370
+ background:#0d1219;color:#e6edf5;font-size:15px}
1371
+ input:focus{outline:none;border-color:#3b82f6}
1372
+ button{margin-top:20px;width:100%;padding:10px;border:0;border-radius:8px;background:#2563eb;
1373
+ color:#fff;font-size:15px;cursor:pointer}
1374
+ button:hover{background:#1d4fd8}
1375
+ .err{margin:0 0 12px;padding:8px 10px;border-radius:8px;background:#2a1215;color:#f87171;font-size:13px}
1376
+ .warn{margin:0 0 12px;padding:8px 10px;border-radius:8px;background:#2a2112;color:#fbbf24;font-size:13px}
1377
+ .hint{margin:2px 0 0;font-size:12px;color:#5c6b7e}
1378
+ hr{border:0;border-top:1px solid #1f2733;margin:20px 0 4px}
1379
+ .chk{display:flex;align-items:flex-start;gap:8px;margin:14px 0 2px;cursor:pointer;font-size:14px;color:#dbe2ea}
1380
+ .chk input{width:auto;margin:2px 0 0;accent-color:#2563eb}
1381
+ .chk .tip{margin:2px 0 0;font-size:12px;color:#5c6b7e}
1382
+ ${REPO_CSS}
1383
+ </style>
1384
+ </head>
1385
+ <body>
1386
+ <main>
1387
+ <h1>dsh-vps 初始设置</h1>
1388
+ <p class="sub">DeepSeek Harness · 仅需填写以下几项</p>
1389
+ ${error ? `<p class="err">${esc(error)}</p>` : ""}
1390
+ ${(warnings || []).map((w) => `<p class="warn">${esc(w)}</p>`).join("")}
1391
+ <form method="post" action="/setup">
1392
+ ${token ? `<input type="hidden" name="token" value="${esc(token)}">` : ""}
1393
+ <label for="u">管理员用户名</label>
1394
+ <input id="u" name="username" value="${esc(username || "")}" autocomplete="username" required>
1395
+ <p class="hint">3-32 位字母、数字或下划线</p>
1396
+ <label for="p">管理员密码</label>
1397
+ <input id="p" name="password" type="password" autocomplete="new-password" required>
1398
+ <label for="p2">确认密码</label>
1399
+ <input id="p2" name="password2" type="password" autocomplete="new-password" required>
1400
+ <p class="hint">至少 12 位</p>
1401
+ <hr>
1402
+ <label for="d">域名(可选)</label>
1403
+ <input id="d" name="domain" value="${esc(domain || "")}" placeholder="dsh.example.com">
1404
+ <p class="hint">需已将 A 记录解析到本服务器;留空则沿用当前访问方式。Caddy 自动签发证书。</p>
1405
+ <label for="k">DeepSeek API Key(可选)</label>
1406
+ <input id="k" name="apiKey" type="password" autocomplete="off" placeholder="sk-...">
1407
+ <p class="hint">现在填写最省事;跳过也可稍后在登录后的「添加 API Key」引导,或设置 → 模型 → DeepSeek 中填写。</p>
1408
+ <hr>
1409
+ <p class="hint" style="margin:2px 0 0">预置插件(默认全选,可取消;其余插件装好后随时在插件市场里自行安装)</p>
1410
+ ${PLUGIN_OPTIONS.map((o) => `
1411
+ <label class="chk"><input type="checkbox" name="plugin" value="${o.id}" checked> <span>${esc(o.name)}<br><span class="tip">${esc(o.desc)}</span></span></label>`).join("")}
1412
+ <button type="submit">完成设置</button>
1413
+ </form>
1414
+ ${repoLink()}
1415
+ </main>
1416
+ </body>
1417
+ </html>`;
1418
+ }
1419
+
1420
+ /** 缺少/错误的启动令牌:不给向导表单,只告诉用户去哪里拿链接。 */
1421
+ function setupTokenPage() {
1422
+ return `<!doctype html>
1423
+ <html lang="zh">
1424
+ <head>
1425
+ <meta charset="utf-8">
1426
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1427
+ <meta name="robots" content="noindex">
1428
+ <title>dsh-vps · 需要启动令牌</title>
1429
+ <style>
1430
+ :root{color-scheme:dark}
1431
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
1432
+ background:#0b0e14;color:#dbe2ea;font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
1433
+ main{width:min(420px,92vw);padding:32px 28px;border:1px solid #1f2733;border-radius:12px;background:#11161f}
1434
+ h1{margin:0 0 4px;font-size:20px;letter-spacing:.5px}
1435
+ .sub{margin:0 0 20px;color:#7d8a9c;font-size:13px}
1436
+ .err{margin:0 0 12px;padding:8px 10px;border-radius:8px;background:#2a1215;color:#f87171;font-size:13px}
1437
+ .hint{margin:2px 0 0;font-size:12px;color:#5c6b7e}
1438
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
1439
+ ${REPO_CSS}
1440
+ </style>
1441
+ </head>
1442
+ <body>
1443
+ <main>
1444
+ <h1>dsh-vps 初始设置</h1>
1445
+ <p class="sub">初始设置向导需要启动令牌</p>
1446
+ <p class="err">当前链接缺少启动令牌或令牌不正确。向导只对持有令牌的人开放。</p>
1447
+ <p style="font-size:14px;color:#9aa7b8;margin:0 0 4px">在服务器上执行下面的命令获取带令牌的链接:</p>
1448
+ <p style="margin:6px 0 0"><code style="display:block;padding:9px 10px;border-radius:8px;background:#0d1219;color:#93c5fd;font-size:13px;overflow-x:auto">sudo dsh-vps setup-url</code></p>
1449
+ <p class="hint" style="margin:16px 0 0">安装结束时该链接已打印在终端里。</p>
1450
+ ${repoLink()}
1451
+ </main>
1452
+ </body>
1453
+ </html>`;
1454
+ }
1455
+
1456
+ async function handleSetup(req, res) {
1457
+ if (!setupOpen()) {
1458
+ sendText(res, 404, "not found");
1459
+ return;
1460
+ }
1461
+ if (req.method === "GET" || req.method === "HEAD") {
1462
+ const token = providedSetupToken(req, null);
1463
+ if (!setupTokenValid(token)) {
1464
+ log("setup page rejected: missing or wrong token");
1465
+ sendHtml(res, 403, setupTokenPage());
1466
+ return;
1467
+ }
1468
+ sendHtml(res, 200, setupPage({ domain: currentDomainHint(req), token }));
1469
+ return;
1470
+ }
1471
+ if (req.method !== "POST") {
1472
+ sendText(res, 405, "method not allowed");
1473
+ return;
1474
+ }
1475
+ let form;
1476
+ try {
1477
+ form = new URLSearchParams(await readBody(req, 64 * 1024));
1478
+ } catch {
1479
+ sendText(res, 400, "bad request");
1480
+ return;
1481
+ }
1482
+ // 令牌校验放在限流之前:拿不到令牌的人不该消耗掉真正的密码尝试额度,
1483
+ // 反之只带错误令牌的请求也刷不掉限流窗口。
1484
+ const token = providedSetupToken(req, form);
1485
+ if (!setupTokenValid(token)) {
1486
+ log("setup submit rejected: missing or wrong token");
1487
+ sendHtml(res, 403, setupTokenPage());
1488
+ return;
1489
+ }
1490
+ const ip = clientIp(req);
1491
+ if (setupRateLimited(ip)) {
1492
+ sendHtml(res, 429, setupPage({ error: "尝试次数过多,请稍后再试。", token }));
1493
+ return;
1494
+ }
1495
+ const username = String(form.get("username") || "").trim();
1496
+ const password = String(form.get("password") || "");
1497
+ const password2 = String(form.get("password2") || "");
1498
+ const domain = String(form.get("domain") || "").trim().toLowerCase();
1499
+ const apiKey = String(form.get("apiKey") || "").trim();
1500
+ const plugins = (form.getAll("plugin") || [])
1501
+ .map((v) => PLUGIN_OPTIONS.find((o) => o.id === v || o.pkg === v))
1502
+ .filter(Boolean)
1503
+ .filter((o, i, arr) => arr.findIndex((x) => x.pkg === o.pkg) === i);
1504
+
1505
+ const redisplay = (error) => {
1506
+ recordSetupFailure(ip);
1507
+ sendHtml(res, 200, setupPage({ error, username, domain, token }));
1508
+ };
1509
+ if (!/^[A-Za-z0-9_]{3,32}$/.test(username)) return redisplay("用户名须为 3-32 位字母、数字或下划线。");
1510
+ if (password.length < 12) return redisplay("密码至少 12 位。");
1511
+ if (password !== password2) return redisplay("两次输入的密码不一致。");
1512
+ if (domain && !DOMAIN_PATTERN.test(domain)) return redisplay("域名格式不合法。");
1513
+
1514
+ log(`setup submitted from ${ip} (username=${username}, domain=${domain || "(unchanged)"}, apiKey=${apiKey ? "yes" : "no"})`);
1515
+ writeAdminRecord(username, password);
1516
+
1517
+ const warnings = [];
1518
+ if (domain && domain !== dshTrustedHost) {
1519
+ try {
1520
+ await applyDomainChange(domain);
1521
+ } catch (err) {
1522
+ log(`setup: domain change failed: ${err.message}`);
1523
+ sendHtml(res, 200, setupPage({
1524
+ error: `域名配置失败:${err.message}。管理员账号已保存,请修正后重新提交。`,
1525
+ username,
1526
+ domain,
1527
+ token,
1528
+ }));
1529
+ return;
1530
+ }
1531
+ }
1532
+ if (apiKey) {
1533
+ try {
1534
+ await waitForDshCookie(30_000);
1535
+ await dshRpc("credentials/set", { ref: DEEPSEEK_KEY_REF, value: apiKey });
1536
+ log("setup: api key written via credentials/set");
1537
+ } catch (err) {
1538
+ log(`setup: api key write failed: ${err.message}`);
1539
+ warnings.push(`API Key 写入失败:${err.message}。不影响登录,可稍后在原生设置页填写。`);
1540
+ }
1541
+ }
1542
+
1543
+ fs.writeFileSync(setupLockPath(), JSON.stringify({ completedAt: Date.now(), username }, null, 2), { mode: 0o600 });
1544
+ clearSetupToken();
1545
+ log("setup completed; wizard locked");
1546
+
1547
+ if (plugins.length) {
1548
+ log(`setup: installing plugins in background: ${plugins.map((o) => o.pkg).join(", ")}`);
1549
+ installPlugins(plugins).catch((err) => log(`plugin install aborted: ${err && err.message}`));
1550
+ }
1551
+
1552
+ if (warnings.length) {
1553
+ sendHtml(res, 200, setupPage({ warnings, username, domain, token }));
1554
+ return;
1555
+ }
1556
+ if (domain && domain !== requestAuthority(req.headers)) {
1557
+ // 域名已切换:引导用户到新地址登录(旧 host 的会话不再适用)
1558
+ sendHtml(
1559
+ res,
1560
+ 200,
1561
+ `<!doctype html><meta charset="utf-8"><title>dsh-vps · 设置完成</title>
1562
+ <body style="background:#0b0e14;color:#dbe2ea;font:15px system-ui;display:flex;min-height:100vh;align-items:center;justify-content:center">
1563
+ <div style="max-width:420px;padding:32px;border:1px solid #1f2733;border-radius:12px;background:#11161f">
1564
+ <h2 style="margin-top:0">设置完成</h2>
1565
+ <p>请在新地址打开并登录:</p>
1566
+ <p><a href="https://${esc(domain)}/" style="color:#60a5fa">https://${esc(domain)}/</a></p>
1567
+ <p style="color:#7d8a9c;font-size:13px">证书签发需要几十秒;若暂不可访问请稍候重试。</p>
1568
+ </div></body>`,
1569
+ );
1570
+ return;
1571
+ }
1572
+ res.writeHead(303, { location: "/login", "cache-control": "no-store" });
1573
+ res.end();
1574
+ }
1575
+
1576
+ /** 升级回归自检(dsh-vps upgrade 调用):健康 + token 兑换 + 登录态 credentials/describe 探针。 */
1577
+ async function handleSelfcheck(req, res) {
1578
+ const report = {
1579
+ gate: "ok",
1580
+ dshAlive: dsh.child !== null,
1581
+ launchTokenCaptured: dsh.token !== null,
1582
+ dshCookieValid: dsh.cookie !== null && dsh.cookie.expiresAt > Date.now(),
1583
+ rpcProbe: null,
1584
+ };
1585
+ let pass = report.dshAlive && report.launchTokenCaptured && report.dshCookieValid;
1586
+ if (pass) {
1587
+ try {
1588
+ await dshRpc("credentials/describe", { refs: [DEEPSEEK_KEY_REF] });
1589
+ report.rpcProbe = "ok";
1590
+ } catch (err) {
1591
+ report.rpcProbe = `failed: ${err.message}`;
1592
+ pass = false;
1593
+ }
1594
+ }
1595
+ report.pass = Boolean(pass);
1596
+ res.writeHead(pass ? 200 : 503, { "content-type": "application/json", "cache-control": "no-store" });
1597
+ res.end(JSON.stringify(report));
1598
+ }
1599
+
1600
+ /** selfcheck 仅限回环调用(dsh-vps upgrade 在本机执行),防止公网探测内部状态。 */
1601
+ //#endregion
1602
+
1603
+ //#region CLI:--set-admin(安装/应急重置用)
1604
+
1605
+ function setAdminCli(argv) {
1606
+ const [username, password] = argv;
1607
+ if (!/^[A-Za-z0-9_]{3,32}$/.test(username || "")) {
1608
+ console.error("username must match ^[A-Za-z0-9_]{3,32}$");
1609
+ process.exit(2);
1610
+ }
1611
+ if (typeof password !== "string" || password.length < 12) {
1612
+ console.error("password must be at least 12 characters");
1613
+ process.exit(2);
1614
+ }
1615
+ ensureStateDir();
1616
+ const salt = crypto.randomBytes(16);
1617
+ const record = {
1618
+ username,
1619
+ salt: salt.toString("hex"),
1620
+ hash: hashPassword(password, salt).toString("hex"),
1621
+ scrypt: SCRYPT_PARAMS,
1622
+ createdAt: Date.now(),
1623
+ };
1624
+ fs.writeFileSync(adminPath(), JSON.stringify(record, null, 2), { mode: 0o600 });
1625
+ console.log(`admin "${username}" written to ${adminPath()}`);
1626
+ }
1627
+
1628
+ //#endregion
1629
+
1630
+ //#region main
1631
+
1632
+ function main() {
1633
+ if (!dshTrustedHost) {
1634
+ console.error("error: DSH_TRUSTED_HOST is required (domain or IP literal passed to dsh --trusted-host)");
1635
+ process.exit(2);
1636
+ }
1637
+ ensureStateDir();
1638
+ sessionKey = loadOrCreateSessionKey();
1639
+
1640
+ const server = http.createServer((req, res) => {
1641
+ const pathname = (req.url || "/").split("?")[0];
1642
+ Promise.resolve()
1643
+ .then(async () => {
1644
+ if (pathname === "/login") return handleLogin(req, res);
1645
+ if (pathname === "/logout") return handleLogout(req, res);
1646
+ if (pathname === "/gate/health") return handleHealthGuarded(req, res);
1647
+ if (pathname === "/gate/selfcheck") return handleSelfcheckGuarded(req, res);
1648
+ if (pathname === "/setup") return handleSetup(req, res);
1649
+ if (!loadAdmin()) {
1650
+ // 尚未完成初始设置:浏览器导航导向导,/api 保持 401
1651
+ if (pathname.startsWith("/api")) {
1652
+ sendText(res, 401, "gate not configured");
1653
+ return;
1654
+ }
1655
+ if (setupOpen()) {
1656
+ // 绝不能在这里把令牌拼进跳转地址:此刻来访者尚未证明任何身份,
1657
+ // 带上令牌等于把管理员注册权发给整个公网。无令牌时 /setup 会显示「需要令牌」页。
1658
+ res.writeHead(303, { location: "/setup", "cache-control": "no-store" });
1659
+ res.end();
1660
+ return;
1661
+ }
1662
+ sendText(res, 503, "gate not configured: admin account missing (run `dsh-vps reset-admin`)");
1663
+ return;
1664
+ }
1665
+ if (!sessionUser(req)) {
1666
+ denyUnauthenticated(req, res, pathname);
1667
+ return;
1668
+ }
1669
+ if (TAKEOVER_RESTART && MARKET_RESTART_PATHS.has(pathname)) return handleMarketRestart(req, res);
1670
+ proxyHttp(req, res);
1671
+ })
1672
+ .catch((err) => {
1673
+ log(`request error: ${err && err.message}`);
1674
+ if (!res.headersSent) sendText(res, 500, "internal error");
1675
+ else res.end();
1676
+ });
1677
+ });
1678
+ server.on("upgrade", handleUpgrade);
1679
+ server.listen(GATE_PORT, GATE_HOST, () => {
1680
+ log(`listening on http://${GATE_HOST}:${GATE_PORT} (trusted host: ${dshTrustedHost})`);
1681
+ });
1682
+
1683
+ spawnDshWithPreflight();
1684
+
1685
+ // DSH Cookie 续期:剩余有效期 < 24h 时用同一 launchToken 重新兑换
1686
+ setInterval(() => {
1687
+ if (dsh.cookie && dsh.cookie.expiresAt <= Date.now()) {
1688
+ // 续期一直没成功(例如将来 DSH 改为一次性 launchToken):重启子进程拿新令牌,
1689
+ // 否则页面会永远停在"正在启动"。
1690
+ restartDsh("dsh session cookie expired");
1691
+ } else if (dsh.cookie && dsh.cookie.expiresAt - Date.now() < 24 * 3_600_000) {
1692
+ log("renewing dsh session cookie");
1693
+ exchangeToken(0);
1694
+ }
1695
+ // 顺手清理过期的限流窗口
1696
+ const now = Date.now();
1697
+ for (const [ip, entry] of loginFailures) if (now > entry.resetAt) loginFailures.delete(ip);
1698
+ for (const [ip, entry] of setupFailures) if (now > entry.resetAt) setupFailures.delete(ip);
1699
+ }, 3_600_000);
1700
+
1701
+ const shutdown = (signal) => {
1702
+ log(`received ${signal}; shutting down`);
1703
+ dsh.shuttingDown = true;
1704
+ clearTimeout(exchangeTimer);
1705
+ if (dsh.child) dsh.child.kill("SIGTERM");
1706
+ server.close(() => process.exit(0));
1707
+ setTimeout(() => process.exit(0), 3000).unref();
1708
+ };
1709
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1710
+ process.on("SIGINT", () => shutdown("SIGINT"));
1711
+ }
1712
+
1713
+ if (require.main === module) {
1714
+ if (process.argv[2] === "--set-admin") setAdminCli(process.argv.slice(3));
1715
+ else main();
1716
+ }
1717
+
1718
+ //#endregion