dsh-plugin-remote-connect-beta 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1097 @@
1
+ /**
2
+ * DSH 插件(host 半):把远程连接能力挂进 Harness 的宿主进程。
3
+ *
4
+ * 与"会话内动态插件"的关键区别:这里是真正的包,代理由**宿主进程内**直接运行
5
+ * (不需要再 spawn 子进程),生命周期跟随 Cordis fiber,停止/升级时自动回收。
6
+ *
7
+ * 配置来自 cordis 组合里这一行的 config(没有导出 Config 时,Cordis 原样传入):
8
+ *
9
+ * - name: dsh-plugin-remote-connect-beta
10
+ * config:
11
+ * lan: { port: 8787 }
12
+ * public:
13
+ * domain: dsh.example.com
14
+ * port: 8788
15
+ * tunnel: ssh
16
+ * ssh: { user: dshtunnel, host: dsh.example.com, keyPath: ~/.ssh/dsh_remote_tunnel }
17
+ * tenants: # 多租户:每个租户一个独立 Harness 实例
18
+ * enabled: true
19
+ * baseDir: ~/DSH-tenants # 每个租户的 DSH_HOME 放这里
20
+ * registry: ~/.dsh-remote-connect/tenants.json
21
+ * harness: { bin: <dsh 入口>, node: /opt/homebrew/bin/node }
22
+ *
23
+ * 安全约定:面板的**开关**(启动/停止)只在宿主窗口(Host 为 loopback 且不带
24
+ * 代理标记)可用;经代理进来的访客(无论局域网还是公网)只能读取状态。
25
+ * 代理会强制覆写 `x-remote-connect-origin`,客户端无法伪造。
26
+ *
27
+ * @module dsh-plugin-remote-connect-beta
28
+ */
29
+ import os from 'node:os'
30
+ import fs from 'node:fs'
31
+ import path from 'node:path'
32
+ import crypto from 'node:crypto'
33
+ import { createProxy, qrRows, defaultLogCandidates } from './core/proxy.js'
34
+ import { createTunnel } from './core/tunnel.js'
35
+ import { runPreflight } from './core/preflight.js'
36
+ import { normalizeLocale, resolveLocale, translator } from './core/messages.js'
37
+ import { createRegistry, defaultTenantBaseDir, normalizeTenant } from './core/tenant.js'
38
+ import { createTenancy } from './core/tenancy.js'
39
+ import { generatePassphrase, setupCommands as credentialSetupCommands } from './core/credential.js'
40
+ import { defaultGateSecretPath, defaultRegistryPath, loadOrCreateGateSecret, pluginStateDir } from './core/paths.js'
41
+ import {
42
+ nginxServerBlock,
43
+ caddySite,
44
+ authorizedKeysLine,
45
+ sshTunnelCommand,
46
+ serverSetupSteps,
47
+ NGINX_UPGRADE_MAP,
48
+ } from './core/snippets.js'
49
+
50
+
51
+ /** 只记一次的日志(避免每个请求刷一行)。 */
52
+ const loggedOnce = new Set()
53
+ function logOnce(line) {
54
+ if (loggedOnce.has(line)) return
55
+ loggedOnce.add(line)
56
+ }
57
+
58
+ /** 状态目录与注册表默认位置(实现见 core/paths.js,这里重新导出便于外部引用)。 */
59
+ export { defaultGateSecretPath, defaultRegistryPath, pluginStateDir }
60
+
61
+ /**
62
+ * 访问口令的形态校验(用户自定义时用)。
63
+ * 规则来自设计文档:长度 ≥12、拒绝常见弱口令、不接受空白与冒号(冒号是 Cookie 分隔符)。
64
+ */
65
+ export function checkAccessKeyStrength(value) {
66
+ const problems = []
67
+ const text = String(value ?? '')
68
+ if (text.length < 12) problems.push('口令太短:至少 12 位(推荐直接用生成的四到六词短句)')
69
+ if (text.length > 128) problems.push('口令太长:最多 128 位')
70
+ if (/[\s:]/.test(text)) problems.push('口令不能包含空格或冒号')
71
+ const weak = ['123456', 'password', 'passw0rd', 'qwerty', 'admin', 'dsh', 'letmein', 'iloveyou']
72
+ if (weak.includes(text.toLowerCase())) problems.push('这是最常见弱口令之一,换一个')
73
+ if (/^(.)\1+$/.test(text)) problems.push('不要用重复字符')
74
+ return problems
75
+ }
76
+
77
+ /** 读访问口令状态;没有就用组合里的种子(或生成一把)。 */
78
+ export function loadAccessKeyState(file, seed) {
79
+ try {
80
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
81
+ if (typeof parsed?.accessKey === 'string' && parsed.accessKey.length >= 12) {
82
+ return {
83
+ file,
84
+ accessKey: parsed.accessKey,
85
+ epoch: Number.isFinite(Number(parsed.epoch)) ? Number(parsed.epoch) : 0,
86
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null,
87
+ createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : null,
88
+ rotations: Number.isFinite(Number(parsed.rotations)) ? Number(parsed.rotations) : 0,
89
+ generated: parsed.generated === true,
90
+ history: Array.isArray(parsed.history) ? parsed.history.filter((item) => typeof item === 'string') : [],
91
+ }
92
+ }
93
+ } catch {
94
+ /* 首次运行或文件坏了:落到种子 */
95
+ }
96
+ // 关键(服务器侧确认书 要求①):**先落盘再展示**,绝不显示一个还没持久化的值。
97
+ // 否则重启/重连后 key 变了,用户手里的链接就变成"上一代"的。
98
+ const seeded = typeof seed === 'string' && seed !== '' ? seed : generatePassphrase().password
99
+ const fresh = {
100
+ accessKey: seeded,
101
+ epoch: 0,
102
+ createdAt: new Date().toISOString(),
103
+ updatedAt: null,
104
+ rotations: 0,
105
+ generated: seed === '',
106
+ }
107
+ try {
108
+ saveAccessKeyState(file, fresh)
109
+ } catch {
110
+ /* 落盘失败也要能用(内存里仍然一致),但下次启动会重新播种 */
111
+ }
112
+ return { file, ...fresh }
113
+ }
114
+
115
+ /** 原子写访问口令状态(0600)。 */
116
+ export function saveAccessKeyState(file, state) {
117
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
118
+ const tmp = file + '.tmp-' + String(process.pid)
119
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
120
+ fs.renameSync(tmp, file)
121
+ }
122
+
123
+ /** 稳定插件名。 */
124
+ export const name = 'remote-connect'
125
+
126
+ /**
127
+ * 插件配置的**严格校验**(Cordis 在插件启动前调用 `Config['~standard'].validate`)。
128
+ *
129
+ * 这里刻意**不依赖 `@deepseek-ai/schemastery`**:本插件装在各用户的 profile 目录下,
130
+ * 那里解析不到 harness 自己的 node_modules,静态 import 会直接让插件装载失败。
131
+ * Standard Schema 只要求 `{ '~standard': { version, vendor, validate } }` 这个形状,
132
+ * loader 也只读 `'~standard'`,因此手写即可满足官方机制且零依赖。
133
+ *
134
+ * 校验边界:**格式非法**(端口越界、域名不像主机名、tunnel 取值未知)在装载时就报错——
135
+ * 对应 AGENTS.md 的 "misconfiguration fails loud";而"启用了公网但还没填域名"这类
136
+ * **语义未就绪**不算非法配置,保留为面板警告 + 启动时的明确报错(不能因此把局域网一起弄挂)。
137
+ */
138
+ export const Config = {
139
+ '~standard': {
140
+ version: 1,
141
+ vendor: 'dsh-plugin-remote-connect-beta',
142
+ /**
143
+ * @param {unknown} value 组合里这一行的 config
144
+ * @returns {{ value: object } | { issues: Array<{ message: string }> }}
145
+ */
146
+ validate(value) {
147
+ const problems = malformedConfigProblems(value)
148
+ if (problems.length > 0) return { issues: problems.map((message) => ({ message })) }
149
+ const { problems: _ignored, ...normalized } = normalizeConfig(value)
150
+ return { value: normalized }
151
+ },
152
+ },
153
+ }
154
+
155
+ /**
156
+ * 只判"格式非法",不判"还没配好"。
157
+ * @param {unknown} raw
158
+ * @returns {string[]}
159
+ */
160
+ function malformedConfigProblems(raw) {
161
+ const problems = []
162
+ const config = asRecord(raw)
163
+ const lan = asRecord(config.lan)
164
+ const publicBlock = asRecord(config.public)
165
+ const ssh = asRecord(publicBlock.ssh)
166
+ const upstream = asRecord(config.upstream)
167
+ const badPort = (value) =>
168
+ value !== undefined && (!Number.isSafeInteger(Number(value)) || Number(value) < 1 || Number(value) > 65535)
169
+ if (badPort(lan.port)) problems.push('lan.port 必须是 1–65535 的整数')
170
+ if (badPort(publicBlock.port)) problems.push('public.port 必须是 1–65535 的整数')
171
+ if (badPort(ssh.port)) problems.push('public.ssh.port 必须是 1–65535 的整数')
172
+ if (badPort(ssh.remotePort)) problems.push('public.ssh.remotePort 必须是 1–65535 的整数')
173
+ // upstream.port = 0 是"自动探测"的哨兵值,不是非法输入
174
+ if (
175
+ upstream.port !== undefined &&
176
+ (!Number.isSafeInteger(Number(upstream.port)) || Number(upstream.port) < 0 || Number(upstream.port) > 65535)
177
+ ) {
178
+ problems.push('upstream.port 必须是 0–65535 的整数(0 = 自动探测)')
179
+ }
180
+ const domain = asString(publicBlock.domain)
181
+ if (domain !== '' && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(domain)) {
182
+ problems.push('public.domain 不是合法主机名:' + JSON.stringify(domain))
183
+ }
184
+ const tunnelMode = asString(publicBlock.tunnel, 'ssh')
185
+ if (!['ssh', 'cloudflared', 'tailscale', 'none'].includes(tunnelMode)) {
186
+ problems.push('public.tunnel 只能是 ssh / cloudflared / tailscale / none,收到 ' + JSON.stringify(tunnelMode))
187
+ }
188
+ const tailscale = asRecord(publicBlock.tailscale)
189
+ if (badPort(tailscale.httpsPort)) problems.push('public.tailscale.httpsPort 必须是 1–65535 的整数')
190
+ if (
191
+ tailscale.probeMs !== undefined &&
192
+ (!Number.isSafeInteger(Number(tailscale.probeMs)) || Number(tailscale.probeMs) < 5000)
193
+ ) {
194
+ problems.push('public.tailscale.probeMs 必须是不小于 5000 的整数(毫秒)')
195
+ }
196
+ const key = asString(publicBlock.accessKey)
197
+ if (key !== '' && !/^[A-Za-z0-9_-]{16,128}$/.test(key)) {
198
+ problems.push('public.accessKey 需为 16–128 位的 URL 安全字符([A-Za-z0-9_-])')
199
+ }
200
+ if (config.mobileAdaptation !== undefined && typeof config.mobileAdaptation !== 'boolean') {
201
+ problems.push('mobileAdaptation 必须是布尔值')
202
+ }
203
+ const tenants = asRecord(config.tenants)
204
+ if (tenants.enabled !== undefined && typeof tenants.enabled !== 'boolean') {
205
+ problems.push('tenants.enabled 必须是布尔值')
206
+ }
207
+ const tenantsHarness = asRecord(tenants.harness)
208
+ for (const key of ['bin', 'node']) {
209
+ if (tenantsHarness[key] !== undefined && typeof tenantsHarness[key] !== 'string') {
210
+ problems.push('tenants.harness.' + key + ' 必须是路径字符串')
211
+ }
212
+ }
213
+ if (tenantsHarness.extraArgs !== undefined && !Array.isArray(tenantsHarness.extraArgs)) {
214
+ problems.push('tenants.harness.extraArgs 必须是字符串数组')
215
+ }
216
+ if (Array.isArray(tenants.harness?.extraArgs) && tenants.harness.extraArgs.some((item) => typeof item !== 'string')) {
217
+ problems.push('tenants.harness.extraArgs 只能包含字符串')
218
+ }
219
+ if (Array.isArray(tenants.list)) {
220
+ tenants.list.forEach((item, index) => {
221
+ const { problems: itemProblems } = normalizeTenant(item)
222
+ for (const problem of itemProblems) problems.push('tenants.list[' + String(index) + ']:' + problem)
223
+ })
224
+ }
225
+ return problems
226
+ }
227
+
228
+ /**
229
+ * 依赖 Web 服务(路由注册)。
230
+ * `connection` 只作为可选依赖在运行时用 ctx.get 读取——它提供官方取令牌方式,
231
+ * 但并非所有 profile 都有 Web 载体,因此不能声明为硬依赖。
232
+ */
233
+ export const inject = ['webServer']
234
+
235
+ /** 面板 API 前缀。 */
236
+ const API_PREFIX = '/remote-connect/api'
237
+
238
+ /** 代理注入的来源标记头(客户端无法伪造:代理始终先删除再覆写)。 */
239
+ const ORIGIN_HEADER = 'x-remote-connect-origin'
240
+
241
+ /**
242
+ * 日志/启动提示的语言:日志写在 Harness 日志文件里,没有"请求语言"可用,
243
+ * 因此按进程环境推断(DSH_REMOTE_LANG > LC_ALL > LC_MESSAGES > LANG,兜底英文)。
244
+ */
245
+ const tLog = translator(resolveLocale())
246
+
247
+ function asRecord(value) {
248
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
249
+ }
250
+
251
+ function asString(value, fallback = '') {
252
+ return typeof value === 'string' ? value : fallback
253
+ }
254
+
255
+ function asPort(value, fallback) {
256
+ const parsed = Number(value)
257
+ return Number.isSafeInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : fallback
258
+ }
259
+
260
+ /**
261
+ * 归一化组合里的 config:不引第三方 schema,手写校验并把错误说人话。
262
+ * @param {unknown} raw
263
+ */
264
+ export function normalizeConfig(raw) {
265
+ const config = asRecord(raw)
266
+ const lan = asRecord(config.lan)
267
+ const publicBlock = asRecord(config.public)
268
+ const ssh = asRecord(publicBlock.ssh)
269
+ const upstream = asRecord(config.upstream)
270
+ const problems = []
271
+
272
+ const domain = asString(publicBlock.domain)
273
+ const sshUser = asString(ssh.user)
274
+ const sshHost = asString(ssh.host, domain)
275
+ const tunnelMode = asString(publicBlock.tunnel, 'ssh')
276
+ if (publicBlock.enabled === true || domain !== '') {
277
+ // 域名只有"自建服务器"这条路需要:cloudflared 给临时域名,tailscale 用节点自带的 ts.net 域名
278
+ if (domain === '' && tunnelMode === 'ssh') problems.push('public.domain 必填(公网域名)')
279
+ if (tunnelMode === 'ssh' && (sshUser === '' || sshHost === '')) {
280
+ problems.push('public.tunnel=ssh 时需要 public.ssh.user 与 public.ssh.host')
281
+ }
282
+ }
283
+
284
+ return {
285
+ problems,
286
+ lan: {
287
+ enabled: lan.enabled === true,
288
+ port: asPort(lan.port, 8787),
289
+ },
290
+ public: {
291
+ enabled: publicBlock.enabled === true,
292
+ domain,
293
+ port: asPort(publicBlock.port, 8788),
294
+ accessKey: asString(publicBlock.accessKey),
295
+ gateTtlHours: Number.isFinite(Number(publicBlock.gateTtlHours))
296
+ ? Number(publicBlock.gateTtlHours)
297
+ : 12,
298
+ tunnel: ['cloudflared', 'tailscale', 'none'].includes(tunnelMode) ? tunnelMode : 'ssh',
299
+ tailscale: {
300
+ path: asString(asRecord(publicBlock.tailscale).path, 'tailscale'),
301
+ httpsPort: asPort(asRecord(publicBlock.tailscale).httpsPort, 443),
302
+ probeMs: Number.isFinite(Number(asRecord(publicBlock.tailscale).probeMs))
303
+ ? Number(asRecord(publicBlock.tailscale).probeMs)
304
+ : 60000,
305
+ },
306
+ ssh: {
307
+ user: sshUser,
308
+ host: sshHost,
309
+ keyPath: asString(ssh.keyPath, path.join(os.homedir(), '.ssh', 'dsh_remote_tunnel')),
310
+ remotePort: asPort(ssh.remotePort, asPort(publicBlock.port, 8788)),
311
+ // 服务器 sshd 端口:非 22 时必须显式配置,否则隧道拨不通
312
+ port: asPort(ssh.port, 22),
313
+ },
314
+ // 去重:Config 校验后的对象会再归一化一次(apply 里),重复会让白名单越滚越长
315
+ allowedHosts: [
316
+ ...new Set(
317
+ []
318
+ .concat(asString(publicBlock.domain) === '' ? [] : [asString(publicBlock.domain)])
319
+ .concat(
320
+ Array.isArray(publicBlock.allowedHosts)
321
+ ? publicBlock.allowedHosts.filter((item) => typeof item === 'string')
322
+ : [],
323
+ ),
324
+ ),
325
+ ],
326
+ },
327
+ tenants: {
328
+ enabled: asRecord(config.tenants).enabled === true,
329
+ // 注册表与租户 home 的落点:默认放在「插件状态目录」与「家目录/DSH-tenants」
330
+ registry: asString(asRecord(config.tenants).registry, defaultRegistryPath()),
331
+ baseDir: asString(asRecord(config.tenants).baseDir, defaultTenantBaseDir()),
332
+ autostart: asRecord(config.tenants).autostart !== false,
333
+ harness: {
334
+ bin: asString(asRecord(asRecord(config.tenants).harness).bin),
335
+ node: asString(asRecord(asRecord(config.tenants).harness).node),
336
+ extraArgs: Array.isArray(asRecord(asRecord(config.tenants).harness).extraArgs)
337
+ ? asRecord(asRecord(config.tenants).harness).extraArgs.filter((item) => typeof item === 'string')
338
+ : [],
339
+ },
340
+ // 组合里可以直接预置租户(面板/CLI 增删的是注册表文件)
341
+ list: Array.isArray(asRecord(config.tenants).list)
342
+ ? asRecord(config.tenants).list.filter((item) => typeof item === 'object' && item !== null)
343
+ : [],
344
+ },
345
+ upstream: {
346
+ port: asPort(upstream.port, 0),
347
+ token: asString(upstream.token),
348
+ logPaths: [
349
+ ...new Set(
350
+ []
351
+ .concat(Array.isArray(upstream.logPaths) ? upstream.logPaths.filter((item) => typeof item === 'string') : [])
352
+ .concat(defaultLogCandidates()),
353
+ ),
354
+ ],
355
+ },
356
+ mobileAdaptation: config.mobileAdaptation !== false,
357
+ }
358
+ }
359
+
360
+ /**
361
+ * 注册插件。
362
+ * @param {import('@deepseek-ai/cordis').Context} ctx
363
+ * @param {unknown} rawConfig
364
+ */
365
+ export function apply(ctx, rawConfig) {
366
+ const config = normalizeConfig(rawConfig)
367
+ const log = (message) => {
368
+ if (ctx.logger?.info !== undefined) ctx.logger.info('[remote-connect] ' + message)
369
+ else process.stdout.write('[remote-connect] ' + message + '\n')
370
+ }
371
+
372
+ /** 进程内访问密钥:未配置时随机生成(重启即变,配置里固定则长期有效)。 */
373
+ // 访问口令(用户唯一要记的那把):可在面板里一键显示/生成/重置。
374
+ // 组合里的 public.accessKey 只是"初始种子",运行时的真值存在状态文件里,
375
+ // 这样面板轮换不需要改用户的 cordis.patch.yml。
376
+ const keyState = loadAccessKeyState(path.join(pluginStateDir(), 'access-key.json'), config.public.accessKey)
377
+ /** 用过的口令指纹(sha256 前 16 位)——查重只看指纹,不存明文。 */
378
+ const keyHistory = new Set(Array.isArray(keyState.history) ? keyState.history : [])
379
+ const keyFingerprint16 = (value) => crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, 16)
380
+ let accessKey = keyState.accessKey
381
+ let keyEpoch = keyState.epoch
382
+ const generatedKey = keyState.generated
383
+
384
+ // ── 改口令前的"再认证"(规范:修改必须先验当前口令;重置是另一条降级路径)──
385
+ const verifyState = { failures: 0, blockedUntil: 0 }
386
+ const VERIFY_MAX_FAILURES = 5
387
+ const VERIFY_COOLDOWN_MS = 5 * 60 * 1000
388
+ const auditFile = path.join(pluginStateDir(), 'audit.log')
389
+
390
+ /** 本地审计:只记时间与事件,绝不记口令。 */
391
+ function audit(event) {
392
+ try {
393
+ fs.mkdirSync(path.dirname(auditFile), { recursive: true, mode: 0o700 })
394
+ fs.appendFileSync(auditFile, new Date().toISOString() + ' ' + event + '\n', { mode: 0o600 })
395
+ } catch {
396
+ /* 审计写不进去不影响主流程 */
397
+ }
398
+ }
399
+
400
+ /**
401
+ * 校验"当前口令"。用定时安全比较,带失败限流 —— 这个入口本身就是一个口令猜测点。
402
+ * @returns {{ ok: true } | { ok: false, blocked?: boolean, retryAfterMs?: number, remaining?: number }}
403
+ */
404
+ function verifyCurrent(candidate) {
405
+ const now = Date.now()
406
+ if (verifyState.blockedUntil > now) {
407
+ return { ok: false, blocked: true, retryAfterMs: verifyState.blockedUntil - now }
408
+ }
409
+ const text = typeof candidate === 'string' ? candidate : ''
410
+ const match =
411
+ text.length === accessKey.length && crypto.timingSafeEqual(Buffer.from(text), Buffer.from(accessKey))
412
+ if (match) {
413
+ verifyState.failures = 0
414
+ return { ok: true }
415
+ }
416
+ verifyState.failures += 1
417
+ audit('access-key verify failed (attempt ' + String(verifyState.failures) + ')')
418
+ if (verifyState.failures >= VERIFY_MAX_FAILURES) {
419
+ verifyState.failures = 0
420
+ verifyState.blockedUntil = now + VERIFY_COOLDOWN_MS
421
+ return { ok: false, blocked: true, retryAfterMs: VERIFY_COOLDOWN_MS }
422
+ }
423
+ return { ok: false, remaining: VERIFY_MAX_FAILURES - verifyState.failures }
424
+ }
425
+
426
+ /** 换一把访问口令:写盘 + 代次 +1(旧链接与旧 Cookie 立即失效)。 */
427
+ /** 轮换串行化:查重与写入必须在同一把锁内,两个并发请求不能双双通过。 */
428
+ let rotateChain = Promise.resolve()
429
+ function withRotateLock(fn) {
430
+ const run = rotateChain.then(fn, fn)
431
+ rotateChain = run.then(
432
+ () => undefined,
433
+ () => undefined,
434
+ )
435
+ return run
436
+ }
437
+
438
+ function rotateAccessKey(next) {
439
+ const value = typeof next === 'string' && next !== '' ? next : generatePassphrase().password
440
+ const problems = checkAccessKeyStrength(value)
441
+ if (problems.length > 0) {
442
+ const error = new Error(problems.join(';'))
443
+ error.problems = problems
444
+ error.statusCode = 400
445
+ throw error
446
+ }
447
+ // 查重(服务器侧规范 §4.1/4.3):新口令不得与"任何用过的口令"相同。
448
+ // 口令是凭据不是名字:系统内不允许两个身份共用同一凭据;本机也不允许复用旧值
449
+ //(否则"以为换了其实没换")。命中也绝不回显是谁在用。
450
+ const candidateFp = keyFingerprint16(value)
451
+ if (candidateFp === keyFingerprint16(accessKey) || keyHistory.has(candidateFp)) {
452
+ const error = new Error('这个口令已经被占用(可能是别人设过的,或你以前用过),请换一个')
453
+ error.statusCode = 409
454
+ throw error
455
+ }
456
+ keyHistory.add(keyFingerprint16(accessKey))
457
+ accessKey = value
458
+ keyEpoch += 1
459
+ keyState.rotations += 1
460
+ keyState.updatedAt = new Date().toISOString()
461
+ saveAccessKeyState(keyState.file, {
462
+ accessKey,
463
+ epoch: keyEpoch,
464
+ createdAt: keyState.createdAt ?? new Date().toISOString(),
465
+ updatedAt: keyState.updatedAt,
466
+ rotations: keyState.rotations,
467
+ generated: keyState.generated,
468
+ // 只存指纹(前 16 位),保留最近 50 个
469
+ history: [...keyHistory].slice(-50),
470
+ })
471
+ return { accessKey, epoch: keyEpoch }
472
+ }
473
+
474
+ // ── 多租户:一个网关 + 每租户一个独立 Harness 实例 ──────────
475
+ const tenancy = createTenancy({
476
+ config: config.tenants,
477
+ log,
478
+ onState: () => {
479
+ // 实例状态变化时不需要额外动作:面板每次 /state 都重新汇总
480
+ },
481
+ })
482
+ const gateSecretFile = defaultGateSecretPath()
483
+ const gateSecret = loadOrCreateGateSecret(gateSecretFile, logOnce).secret
484
+ /** 路由表:proxy 只认 findById/findByKey 两个方法。 */
485
+ const tenantRouter = config.tenants.enabled
486
+ ? { findById: (id) => tenancy.handle(id), findByKey: (key) => tenancy.findByKey(key) }
487
+ : null
488
+
489
+ let lanProxy = null
490
+ let publicProxy = null
491
+ let tunnel = null
492
+ let lastError = null
493
+ let busy = false
494
+
495
+ /**
496
+ * 上游端口:优先取配置,其次问 webServer 服务(官方 Web 载体自己知道实际监听端口),
497
+ * 都没有时交给 proxy 从日志发现。**不硬编码任何默认端口**——CLI 默认 3080、
498
+ * DSH Desktop 是 43129,写死任何一个都会在一半用户的机器上连错。
499
+ * @returns {number|undefined}
500
+ */
501
+ function resolveUpstreamPort() {
502
+ if (config.upstream.port > 0) return config.upstream.port
503
+ const port = ctx.webServer === undefined ? undefined : ctx.webServer.port
504
+ return typeof port === 'number' && port > 0 ? port : undefined
505
+ }
506
+
507
+ /**
508
+ * 浏览器会话令牌:优先用 connection 服务的 authenticatedUrl()(官方支持的取令牌方式,
509
+ * dsh-web-app 自己就是这么拿的),失败再退回日志发现。
510
+ * @param {number|undefined} port
511
+ * @returns {string}
512
+ */
513
+ function resolveToken(port) {
514
+ if (config.upstream.token !== '') return config.upstream.token
515
+ const connection = ctx.get('connection')
516
+ if (connection === undefined || typeof connection.authenticatedUrl !== 'function') return ''
517
+ if (port === undefined) return ''
518
+ try {
519
+ const url = connection.authenticatedUrl('http://127.0.0.1:' + String(port))
520
+ const token = new URL(url).searchParams.get('token')
521
+ return typeof token === 'string' ? token : ''
522
+ } catch (error) {
523
+ log('从 connection 服务取令牌失败,回退到日志发现:' + String(error && error.message ? error.message : error))
524
+ return ''
525
+ }
526
+ }
527
+
528
+ function upstreamOptions() {
529
+ const port = resolveUpstreamPort()
530
+ const options = {
531
+ // 不在这里同步取令牌:connection 服务可能还没挂载(官方也用 ctx.inject 等它),
532
+ // 交给 proxy 在真正发请求时惰性解析。
533
+ token: config.upstream.token,
534
+ tokenProvider: () => resolveToken(port),
535
+ logPaths: config.upstream.logPaths,
536
+ mobileAdaptation: config.mobileAdaptation,
537
+ }
538
+ if (port !== undefined) options.upstreamPort = port
539
+ return options
540
+ }
541
+
542
+ /**
543
+ * 拼某个租户的入口地址。
544
+ * 多租户下"入口"不是一条链接而是一人一条,所以这里按 (base, key) 组合。
545
+ */
546
+ function tenantEntry(base, key) {
547
+ if (base === null) return null
548
+ return key === '' ? base : base + '?k=' + encodeURIComponent(key)
549
+ }
550
+
551
+ /** 租户列表(含每人的局域网/公网链接),给面板与 CLI 用。 */
552
+ function tenantList() {
553
+ if (!config.tenants.enabled) return []
554
+ const lanBase = lanProxy === null ? null : lanProxy.info().lanUrl
555
+ const publicBase = config.public.domain === '' ? null : 'https://' + config.public.domain + '/'
556
+ return tenancy.list().map((tenant) => ({
557
+ ...tenant,
558
+ lanEntry: tenantEntry(lanBase, tenant.accessKey),
559
+ publicEntry: tenantEntry(publicBase, tenant.accessKey),
560
+ }))
561
+ }
562
+
563
+ function lanState() {
564
+ if (lanProxy === null) return { running: false, url: null, port: config.lan.port }
565
+ const info = lanProxy.info()
566
+ return { running: info.running, url: info.lanUrl, port: info.port, hasToken: info.hasToken }
567
+ }
568
+
569
+ /**
570
+ * 隧道状态按请求语言渲染:状态本身只带 code + params(见 core/tunnel.js),
571
+ * 语言由调用方(面板请求)决定,CLI 与日志用英文兜底。
572
+ */
573
+ function tunnelView(locale) {
574
+ if (tunnel === null) return null
575
+ const state = tunnel.state()
576
+ const t = translator(locale)
577
+ const view = { ...state, detail: t(state.code, state.params) }
578
+ if (state.hintCode !== undefined) view.hint = t(state.hintCode)
579
+ return view
580
+ }
581
+
582
+ function publicState(locale) {
583
+ const info = publicProxy === null ? null : publicProxy.info()
584
+ const base = config.public.domain === '' ? null : 'https://' + config.public.domain + '/'
585
+ const entry = base === null ? null : base + (accessKey === '' ? '' : '?k=' + accessKey)
586
+ return {
587
+ running: info !== null && info.running,
588
+ domain: config.public.domain,
589
+ port: info === null ? config.public.port : info.port,
590
+ entry,
591
+ tunnel: tunnelView(locale),
592
+ tunnelUrl: tunnel === null ? null : tunnel.state().publicUrl,
593
+ accessKeyGenerated: generatedKey,
594
+ // 面板默认只看到掩码;明文要单独调 /access-key/reveal(且只允许宿主窗口)
595
+ accessKeyMasked: accessKey === '' ? null : accessKey.slice(0, 2) + '••••••' + accessKey.slice(-2),
596
+ accessKeyUpdatedAt: keyState.updatedAt,
597
+ // 让用户"一眼确认手上这条链接是不是当前这条"(确认书 要求①/④)
598
+ accessKeyFingerprint: accessKey.slice(0, 8) + '…' + accessKey.slice(-4),
599
+ accessKeyCreatedAt: keyState.createdAt,
600
+ accessKeyRotations: keyState.rotations,
601
+ accessKeyExpiresAt: null,
602
+ hasToken: info === null ? null : info.hasToken,
603
+ }
604
+ }
605
+
606
+ /**
607
+ * 上游来源自证:端口来自 config 还是 webServer,令牌来自 config / connection / 日志。
608
+ * 面板与 doctor 都靠它证明"没有硬编码端口"。
609
+ */
610
+ function upstreamReport() {
611
+ const configured = config.upstream.port > 0
612
+ const wsPort = ctx.webServer === undefined ? undefined : ctx.webServer.port
613
+ const hasWsPort = typeof wsPort === 'number' && wsPort > 0
614
+ const port = configured ? config.upstream.port : hasWsPort ? wsPort : null
615
+ const source = configured ? 'config' : hasWsPort ? 'webServer' : 'undiscovered'
616
+ const info = lanProxy !== null ? lanProxy.info() : publicProxy !== null ? publicProxy.info() : null
617
+ if (info !== null) {
618
+ return {
619
+ port: Number(String(info.upstream).split(':').pop()),
620
+ // 代理只区分 explicit/log/default;explicit 的真实来源由这里给出
621
+ source: info.upstreamSource === 'explicit' ? source : info.upstreamSource,
622
+ hasToken: info.hasToken,
623
+ tokenSource: info.tokenSource,
624
+ }
625
+ }
626
+ const token = resolveToken(port === null ? undefined : port)
627
+ return {
628
+ port,
629
+ source,
630
+ hasToken: config.upstream.token !== '' || token !== '',
631
+ tokenSource: config.upstream.token !== '' ? 'config' : token !== '' ? 'connection' : 'idle',
632
+ }
633
+ }
634
+
635
+ function snapshot(canControl = true, locale) {
636
+ return {
637
+ ok: true,
638
+ busy,
639
+ canControl,
640
+ upstream: upstreamReport(),
641
+ config: {
642
+ lanPort: config.lan.port,
643
+ publicDomain: config.public.domain,
644
+ publicPort: config.public.port,
645
+ tunnel: config.public.tunnel,
646
+ sshUser: config.public.ssh.user,
647
+ sshHost: config.public.ssh.host,
648
+ sshKeyPath: config.public.ssh.keyPath,
649
+ sshPort: config.public.ssh.port,
650
+ remotePort: config.public.ssh.remotePort,
651
+ problems: config.problems,
652
+ },
653
+ tenants: {
654
+ enabled: config.tenants.enabled,
655
+ registry: config.tenants.registry,
656
+ baseDir: config.tenants.baseDir,
657
+ // 找不到 harness 入口时,面板要能直接说人话(而不是所有租户都起不来)
658
+ harness: config.tenants.enabled ? tenancy.harness : null,
659
+ list: tenantList(),
660
+ },
661
+ lan: lanState(),
662
+ public: publicState(locale),
663
+ qr: (() => {
664
+ const url = lanState().url ?? publicState().entry
665
+ return url === null ? null : qrRows(url)
666
+ })(),
667
+ error: lastError,
668
+ }
669
+ }
670
+
671
+ async function startLan() {
672
+ if (lanProxy !== null) return
673
+ lanProxy = createProxy({
674
+ ...upstreamOptions(),
675
+ port: config.lan.port,
676
+ listenHost: '0.0.0.0',
677
+ // 局域网入口**不设口令**(同网段打开即用,设计如此);多租户时按各自的 ?k= 路由。
678
+ // ⚠️ 这里绝对不能接 accessKeyProvider:接了就等于给局域网整站加了密码,
679
+ // 所有不带 ?k= 的请求(包括面板 API)都会变成 404。
680
+ accessKey: '',
681
+ gateSecret,
682
+ tenants: tenantRouter,
683
+ })
684
+ try {
685
+ await lanProxy.start()
686
+ lastError = null
687
+ log('局域网入口已启动:' + String(lanProxy.info().lanUrl))
688
+ // 局域网口一开,租户实例也要在:否则访客拿到的是 503
689
+ if (config.tenants.enabled) {
690
+ const started = tenancy.startAutostart()
691
+ if (started.length > 0) log('已拉起租户实例:' + started.join('、'))
692
+ }
693
+ } catch (error) {
694
+ lastError = String(error?.message ?? error)
695
+ lanProxy = null
696
+ throw error
697
+ }
698
+ }
699
+
700
+ async function stopLan() {
701
+ const current = lanProxy
702
+ lanProxy = null
703
+ if (current !== null) await current.stop()
704
+ // 入口关了就把租户实例一起收掉:否则一堆 Harness 在后台白跑
705
+ if (config.tenants.enabled) await tenancy.stopAll()
706
+ }
707
+
708
+ async function startPublic() {
709
+ if (config.public.domain === '' && config.public.tunnel === 'ssh') {
710
+ throw new Error(tLog('host.error.needDomain'))
711
+ }
712
+ if (publicProxy === null) {
713
+ publicProxy = createProxy({
714
+ ...upstreamOptions(),
715
+ port: config.public.port,
716
+ listenHost: '127.0.0.1',
717
+ accessKey: config.tenants.enabled ? '' : accessKey,
718
+ accessKeyProvider: config.tenants.enabled ? null : () => accessKey,
719
+ keyEpochProvider: () => keyEpoch,
720
+ healthProvider: () => ({
721
+ tunnel: tunnel === null ? 'down' : tunnel.state().phase,
722
+ keyFingerprint: accessKey.slice(0, 8) + '…' + accessKey.slice(-4),
723
+ keyCreatedAt: keyState.createdAt,
724
+ keyRotations: keyState.rotations,
725
+ }),
726
+ gateSecret: config.tenants.enabled ? gateSecret : undefined,
727
+ tenants: tenantRouter,
728
+ allowedHosts: config.public.allowedHosts,
729
+ gateTtlHours: config.public.gateTtlHours,
730
+ })
731
+ await publicProxy.start()
732
+ log('公网监听已就绪:127.0.0.1:' + String(publicProxy.info().port) + '(仅回环,等待隧道)')
733
+ }
734
+ if (tunnel === null && config.public.tunnel !== 'none') {
735
+ tunnel = createTunnel({
736
+ mode: config.public.tunnel,
737
+ localPort: publicProxy.info().port,
738
+ remotePort: config.public.ssh.remotePort,
739
+ port: config.public.ssh.port,
740
+ user: config.public.ssh.user,
741
+ host: config.public.ssh.host,
742
+ keyPath: config.public.ssh.keyPath,
743
+ tailscalePath: config.public.tailscale.path,
744
+ tailscaleHttpsPort: config.public.tailscale.httpsPort,
745
+ tailscaleProbeMs: config.public.tailscale.probeMs,
746
+ log,
747
+ onState: (state) => {
748
+ if (state.phase === 'up') log(tLog(state.code, state.params))
749
+ else if (state.phase === 'reconnecting' || state.phase === 'error') log(tLog(state.code, state.params))
750
+ },
751
+ })
752
+ tunnel.start()
753
+ }
754
+ lastError = null
755
+ }
756
+
757
+ async function stopPublic() {
758
+ const currentTunnel = tunnel
759
+ const currentProxy = publicProxy
760
+ tunnel = null
761
+ publicProxy = null
762
+ if (currentTunnel !== null) await currentTunnel.stop()
763
+ if (currentProxy !== null) await currentProxy.stop()
764
+ }
765
+
766
+ function requireLocalControl(req, locale) {
767
+ const origin = String(req.headers[ORIGIN_HEADER] ?? '')
768
+ if (origin === 'public' || origin === 'lan') {
769
+ const error = new Error(translator(locale)(origin === 'public' ? 'host.error.gate.public' : 'host.error.gate.lan'))
770
+ error.statusCode = 403
771
+ throw error
772
+ }
773
+ }
774
+
775
+ /** 读 JSON 请求体(面板的租户管理接口用);空体返回 {}。 */
776
+ function readJsonBody(req, limit = 64 * 1024) {
777
+ return new Promise((resolve, reject) => {
778
+ const chunks = []
779
+ let size = 0
780
+ req.on('data', (chunk) => {
781
+ size += chunk.length
782
+ if (size > limit) {
783
+ reject(Object.assign(new Error('请求体过大'), { statusCode: 413 }))
784
+ req.destroy()
785
+ return
786
+ }
787
+ chunks.push(chunk)
788
+ })
789
+ req.on('end', () => {
790
+ const text = Buffer.concat(chunks).toString('utf8').trim()
791
+ if (text === '') {
792
+ resolve({})
793
+ return
794
+ }
795
+ try {
796
+ const parsed = JSON.parse(text)
797
+ resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
798
+ } catch (error) {
799
+ reject(Object.assign(new Error('请求体不是合法 JSON:' + String(error?.message ?? error)), { statusCode: 400 }))
800
+ }
801
+ })
802
+ req.on('error', reject)
803
+ })
804
+ }
805
+
806
+ function sendJson(res, status, payload) {
807
+ const body = Buffer.from(JSON.stringify(payload), 'utf8')
808
+ res.writeHead(status, {
809
+ 'content-type': 'application/json; charset=utf-8',
810
+ 'cache-control': 'no-store',
811
+ 'content-length': String(body.length),
812
+ })
813
+ res.end(body)
814
+ }
815
+
816
+ function sendText(res, status, text, contentType = 'text/plain; charset=utf-8') {
817
+ const body = Buffer.from(text, 'utf8')
818
+ res.writeHead(status, {
819
+ 'content-type': contentType,
820
+ 'cache-control': 'no-store',
821
+ 'content-length': String(body.length),
822
+ })
823
+ res.end(body)
824
+ }
825
+
826
+ async function handle(req, res) {
827
+ const url = new URL(req.url ?? '/', 'http://placeholder')
828
+ const pathname = url.pathname
829
+ const route = pathname.slice(API_PREFIX.length).replace(/\/+$/u, '') || '/'
830
+ const method = req.method ?? 'GET'
831
+ // 文案语言由请求方给(面板会带自己当前的语言),未知取值退回英文
832
+ const locale = normalizeLocale(url.searchParams.get('locale'))
833
+ const t = translator(locale)
834
+ // 代理会强制覆写该头;宿主机窗口直连时不存在 → 允许控制
835
+ const canControl = req.headers[ORIGIN_HEADER] === undefined
836
+ try {
837
+ if (route === '/' || route === '/state') {
838
+ if (method !== 'GET') throw Object.assign(new Error(t('host.error.method', { method: 'GET' })), { statusCode: 405 })
839
+ sendJson(res, 200, snapshot(canControl, locale))
840
+ return
841
+ }
842
+ if (route === '/access-key') {
843
+ if (method !== 'GET') throw Object.assign(new Error(t('host.error.method', { method: 'GET' })), { statusCode: 405 })
844
+ sendJson(res, 200, {
845
+ ok: true,
846
+ masked: accessKey.slice(0, 2) + '••••••' + accessKey.slice(-2),
847
+ updatedAt: keyState.updatedAt,
848
+ })
849
+ return
850
+ }
851
+ if (route === '/access-key/reveal') {
852
+ // 明文只在宿主窗口给(远程访客即使猜到这个接口也拿不到)
853
+ if (method !== 'POST') throw Object.assign(new Error(t('host.error.method', { method: 'POST' })), { statusCode: 405 })
854
+ requireLocalControl(req, locale)
855
+ sendJson(res, 200, { ok: true, accessKey })
856
+ return
857
+ }
858
+ if (route === '/access-key/verify') {
859
+ if (method !== 'POST') throw Object.assign(new Error(t('host.error.method', { method: 'POST' })), { statusCode: 405 })
860
+ requireLocalControl(req, locale)
861
+ const body = await readJsonBody(req)
862
+ const verdict = verifyCurrent(body.password)
863
+ if (verdict.ok !== true) {
864
+ sendJson(res, verdict.blocked === true ? 429 : 401, {
865
+ ok: false,
866
+ blocked: verdict.blocked === true,
867
+ retryAfterMs: verdict.retryAfterMs ?? null,
868
+ remaining: verdict.remaining ?? 0,
869
+ error:
870
+ verdict.blocked === true
871
+ ? t('host.error.keyBlocked', { minutes: String(Math.ceil((verdict.retryAfterMs ?? 0) / 60000)) })
872
+ : t('host.error.keyMismatch', { remaining: String(verdict.remaining ?? 0) }),
873
+ })
874
+ return
875
+ }
876
+ sendJson(res, 200, { ok: true })
877
+ return
878
+ }
879
+ if (route === '/access-key/rotate') {
880
+ if (method !== 'POST') throw Object.assign(new Error(t('host.error.method', { method: 'POST' })), { statusCode: 405 })
881
+ requireLocalControl(req, locale)
882
+ if (busy) throw Object.assign(new Error(t('host.error.busy')), { statusCode: 409 })
883
+ busy = true
884
+ let rotated
885
+ try {
886
+ const body = await readJsonBody(req)
887
+ if (body.reset === true) {
888
+ // 重置 = 忘记口令时的降级路径:不需要旧口令,但必须显式确认(面板弹确认框),并留审计
889
+ if (body.acknowledge !== true) {
890
+ throw Object.assign(new Error(t('host.error.keyResetNeedsAck')), { statusCode: 400 })
891
+ }
892
+ rotated = await withRotateLock(() =>
893
+ rotateAccessKey(typeof body.password === 'string' ? body.password : ''),
894
+ )
895
+ audit('access-key RESET (old password not verified, host window confirmed)')
896
+ } else {
897
+ // 修改 = 先验旧口令,再写新口令;两步分开,验证失败绝不写入(规范 §0/§4)
898
+ const verdict = verifyCurrent(body.current)
899
+ if (verdict.ok !== true) {
900
+ const error = new Error(
901
+ verdict.blocked === true
902
+ ? t('host.error.keyBlocked', { minutes: String(Math.ceil((verdict.retryAfterMs ?? 0) / 60000)) })
903
+ : t('host.error.keyMismatch', { remaining: String(verdict.remaining ?? 0) }),
904
+ )
905
+ error.statusCode = verdict.blocked === true ? 429 : 401
906
+ throw error
907
+ }
908
+ const next = typeof body.password === 'string' ? body.password : ''
909
+ const confirm = typeof body.confirm === 'string' ? body.confirm : ''
910
+ if (next !== confirm) throw Object.assign(new Error(t('host.error.keyConfirmMismatch')), { statusCode: 400 })
911
+ if (next === accessKey) throw Object.assign(new Error(t('host.error.keySameAsOld')), { statusCode: 400 })
912
+ // 查重 + 写入在同一把锁内(规范 §4.2 的原子性要求,这里是进程内串行化)
913
+ rotated = await withRotateLock(() => rotateAccessKey(next))
914
+ audit('access-key changed (current password verified)')
915
+ }
916
+ } catch (error) {
917
+ lastError = String(error?.message ?? error)
918
+ throw error
919
+ } finally {
920
+ busy = false
921
+ }
922
+ log('访问口令已轮换(旧链接与旧 Cookie 立即失效)')
923
+ sendJson(res, 200, {
924
+ ok: true,
925
+ accessKey: rotated.accessKey,
926
+ entry: config.public.domain === '' ? null : 'https://' + config.public.domain + '/?k=' + rotated.accessKey,
927
+ epoch: rotated.epoch,
928
+ })
929
+ return
930
+ }
931
+ if (route === '/snippets') {
932
+ if (method !== 'GET') throw Object.assign(new Error(t('host.error.method', { method: 'GET' })), { statusCode: 405 })
933
+ const kind = url.searchParams.get('kind') ?? 'nginx'
934
+ const domain = config.public.domain === '' ? 'dsh.example.com' : config.public.domain
935
+ const targetPort = config.public.ssh.remotePort
936
+ const sections = [
937
+ serverSetupSteps({ targetPort, user: config.public.ssh.user || 'dshtunnel' }),
938
+ '',
939
+ '--- authorized_keys 限制行(公钥用 dsh-remote keygen 生成)---',
940
+ authorizedKeysLine('<你的公钥>', targetPort),
941
+ '',
942
+ kind === 'caddy'
943
+ ? caddySite({ domain, targetPort })
944
+ : '# /etc/nginx/conf.d/upgrade-map.conf\n' + NGINX_UPGRADE_MAP + '\n\n' + nginxServerBlock({ domain, targetPort }),
945
+ '',
946
+ '--- 本机隧道命令 ---',
947
+ sshTunnelCommand({
948
+ user: config.public.ssh.user || 'dshtunnel',
949
+ host: config.public.ssh.host || domain,
950
+ keyPath: config.public.ssh.keyPath,
951
+ port: config.public.ssh.port,
952
+ localPort: targetPort,
953
+ remotePort: targetPort,
954
+ }),
955
+ ]
956
+ sendText(res, 200, sections.join('\n'))
957
+ return
958
+ }
959
+ if (route === '/check') {
960
+ if (method !== 'POST') throw Object.assign(new Error(t('host.error.method', { method: 'POST' })), { statusCode: 405 })
961
+ const tailscaleMode = config.public.tunnel === 'tailscale'
962
+ if (config.public.domain === '' && !tailscaleMode) {
963
+ throw Object.assign(new Error(t('host.error.needDomainCheck')), { statusCode: 400 })
964
+ }
965
+ const results = await runPreflight({
966
+ domain: config.public.domain,
967
+ locale,
968
+ tunnelMode: config.public.tunnel,
969
+ tailscalePath: config.public.tailscale.path,
970
+ sshUser: config.public.ssh.user,
971
+ sshHost: config.public.ssh.host,
972
+ sshKeyPath: config.public.ssh.keyPath,
973
+ sshPort: config.public.ssh.port,
974
+ localPort: config.public.ssh.remotePort,
975
+ remotePort: config.public.ssh.remotePort,
976
+ })
977
+ sendJson(res, 200, { ok: true, results })
978
+ return
979
+ }
980
+ if (route.startsWith('/tenants')) {
981
+ if (config.tenants.enabled !== true) {
982
+ throw Object.assign(new Error(t('host.error.tenantsOff')), { statusCode: 400 })
983
+ }
984
+ const action = route.slice('/tenants'.length).replace(/^\//u, '')
985
+ if (action === '') {
986
+ if (method !== 'GET') throw Object.assign(new Error(t('host.error.method', { method: 'GET' })), { statusCode: 405 })
987
+ sendJson(res, 200, { ok: true, tenants: tenantList(), harness: tenancy.harness })
988
+ return
989
+ }
990
+ if (action === 'qr') {
991
+ if (method !== 'GET') throw Object.assign(new Error(t('host.error.method', { method: 'GET' })), { statusCode: 405 })
992
+ const wanted = url.searchParams.get('id') ?? ''
993
+ const tenant = tenantList().find((item) => item.id === wanted)
994
+ if (tenant === undefined) {
995
+ throw Object.assign(new Error(t('host.error.tenantUnknown', { id: wanted })), { statusCode: 404 })
996
+ }
997
+ // 二维码画的是"他拿到的那条链接"(局域网优先,因为它现在就能用)
998
+ const link = tenant.lanEntry ?? tenant.publicEntry ?? null
999
+ sendJson(res, 200, { ok: true, id: tenant.id, url: link, rows: link === null ? null : qrRows(link) })
1000
+ return
1001
+ }
1002
+ // 增删改与开关一样,只允许宿主机窗口操作:访客不能给自己或别人开租户
1003
+ if (method !== 'POST') throw Object.assign(new Error(t('host.error.method', { method: 'POST' })), { statusCode: 405 })
1004
+ requireLocalControl(req, locale)
1005
+ if (busy) throw Object.assign(new Error(t('host.error.busy')), { statusCode: 409 })
1006
+ busy = true
1007
+ try {
1008
+ const body = await readJsonBody(req)
1009
+ if (action === 'add') {
1010
+ const created = tenancy.add(body)
1011
+ sendJson(res, 200, { ok: true, tenant: tenancy.describe(created.tenant.id), tenants: tenantList() })
1012
+ return
1013
+ }
1014
+ const id = typeof body.id === 'string' ? body.id : ''
1015
+ if (id === '') throw Object.assign(new Error(t('host.error.tenantIdRequired')), { statusCode: 400 })
1016
+ if (tenancy.registry.get(id) === null) {
1017
+ throw Object.assign(new Error(t('host.error.tenantUnknown', { id })), { statusCode: 404 })
1018
+ }
1019
+ if (action === 'remove') await tenancy.remove(id)
1020
+ else if (action === 'rotate') tenancy.rotate(id)
1021
+ else if (action === 'start') tenancy.start(id)
1022
+ else if (action === 'stop') await tenancy.stop(id)
1023
+ else throw Object.assign(new Error(t('host.error.unknownRoute', { route })), { statusCode: 404 })
1024
+ } catch (error) {
1025
+ lastError = String(error?.message ?? error)
1026
+ throw error
1027
+ } finally {
1028
+ busy = false
1029
+ }
1030
+ sendJson(res, 200, { ok: true, tenants: tenantList() })
1031
+ return
1032
+ }
1033
+ if (route === '/lan/start' || route === '/lan/stop' || route === '/public/start' || route === '/public/stop') {
1034
+ if (method !== 'POST') throw Object.assign(new Error(t('host.error.method', { method: 'POST' })), { statusCode: 405 })
1035
+ requireLocalControl(req, locale)
1036
+ if (busy) throw Object.assign(new Error(t('host.error.busy')), { statusCode: 409 })
1037
+ busy = true
1038
+ try {
1039
+ if (route === '/lan/start') await startLan()
1040
+ else if (route === '/lan/stop') await stopLan()
1041
+ else if (route === '/public/start') await startPublic()
1042
+ else await stopPublic()
1043
+ } catch (error) {
1044
+ lastError = String(error?.message ?? error)
1045
+ throw error
1046
+ } finally {
1047
+ busy = false
1048
+ }
1049
+ sendJson(res, 200, snapshot())
1050
+ return
1051
+ }
1052
+ throw Object.assign(new Error(t('host.error.unknownRoute', { route })), { statusCode: 404 })
1053
+ } catch (error) {
1054
+ const status = Number.isSafeInteger(error?.statusCode) ? error.statusCode : 500
1055
+ if (status >= 500) lastError = String(error?.message ?? error)
1056
+ sendJson(res, status, { ok: false, error: String(error?.message ?? error) })
1057
+ }
1058
+ }
1059
+
1060
+ ctx.effect(
1061
+ () => ctx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler: handle }),
1062
+ 'remote-connect: panel api',
1063
+ )
1064
+
1065
+ // 生命周期:fiber 停止/升级时把监听与隧道全部回收
1066
+ ctx.effect(
1067
+ () => () => {
1068
+ void stopPublic().catch(() => {})
1069
+ void tenancy.stopAll().catch(() => {})
1070
+ void stopLan().catch(() => {})
1071
+ },
1072
+ 'remote-connect: teardown',
1073
+ )
1074
+
1075
+ if (config.tenants.enabled) {
1076
+ const loaded = tenancy.load()
1077
+ log(
1078
+ '多租户已启用:注册表 ' +
1079
+ config.tenants.registry +
1080
+ '(' +
1081
+ String(loaded.total) +
1082
+ ' 个租户)' +
1083
+ (tenancy.harness.problem === null ? '' : ';' + tenancy.harness.problem),
1084
+ )
1085
+ if (config.lan.enabled || config.public.enabled) tenancy.startAutostart()
1086
+ }
1087
+ if (config.problems.length > 0) {
1088
+ log('配置有问题(面板会显示,公网入口暂时不可用):' + config.problems.join(';'))
1089
+ }
1090
+ if (config.lan.enabled) {
1091
+ void startLan().catch((error) => log('局域网入口启动失败:' + String(error?.message ?? error)))
1092
+ }
1093
+ if (config.public.enabled) {
1094
+ void startPublic().catch((error) => log('公网入口启动失败:' + String(error?.message ?? error)))
1095
+ }
1096
+ log('插件已加载' + (config.lan.enabled ? '(局域网已自动开启)' : ''))
1097
+ }