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.
@@ -0,0 +1,924 @@
1
+ /**
2
+ * 反向代理核心:把一个只监听 loopback 的 DSH Harness Web 服务,暴露到
3
+ * 局域网(0.0.0.0)或公网入口(只绑 loopback,供 ssh -R / 隧道回注)。
4
+ *
5
+ * 三件事必须由它来做,缺一个就不能用:
6
+ * 1. Host/Origin 改写为 127.0.0.1:<上游端口> —— Harness 的 /api 有防
7
+ * DNS-rebinding 围栏,只信 loopback 或受信 authority,直连公网域名会 403。
8
+ * 2. 首次访问补 ?token=<本次进程令牌> —— Harness 的浏览器鉴权靠"启动令牌"
9
+ * 换签名 Cookie;令牌只在插件/进程内使用,不进 URL 历史、不进日志。
10
+ * 3. 首页注入同源样式表与极小 shim —— 让窄屏可用(侧栏改浮层抽屉、点遮罩关闭)。
11
+ *
12
+ * 公网模式下额外加一道访问密钥门(?k=),不通过一律 404(不返回 401,避免暴露存在性)。
13
+ *
14
+ * @module dsh-plugin-remote-connect-beta/core/proxy
15
+ */
16
+ import http from 'node:http'
17
+ import net from 'node:net'
18
+ import os from 'node:os'
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import crypto from 'node:crypto'
22
+ import { createRequire } from 'node:module'
23
+ import { translator } from './messages.js'
24
+
25
+ /** 本地资源路径:手机适配样式表。 */
26
+ export const MOBILE_CSS_PATH = '/__rc/mobile.css'
27
+
28
+ const MOBILE_CSS = [
29
+ '.dshRcScrim{position:fixed;inset:0;z-index:39;background:rgba(0,0,0,.42);opacity:0;pointer-events:none;transition:opacity .18s ease}',
30
+ '@media (max-width: 820px){',
31
+ '[class*="_frame"]:has(button[aria-label="收起侧边栏"]){grid-template-columns:minmax(0,1fr) 0px 0px !important}',
32
+ '[class*="_frame"]:has(button[aria-label="收起侧边栏"]) [class*="_sidebarCol"]{position:fixed !important;left:0;top:0;bottom:0;width:min(88vw,340px) !important;z-index:40;box-shadow:0 0 30px rgba(0,0,0,.32)}',
33
+ 'body:has(button[aria-label="收起侧边栏"]) .dshRcScrim{opacity:1;pointer-events:auto}',
34
+ '[class*="_rightbarCol"]{display:none !important}',
35
+ '[class*="_heroWorkspaceRow"]{flex-wrap:wrap !important}',
36
+ '[class*="_composerSeat"],[class*="_scrollBody"]{padding-bottom:env(safe-area-inset-bottom)}',
37
+ '}',
38
+ ].join('\n')
39
+
40
+ /**
41
+ * 只做一件事:造一个遮罩 div,点它就转发点击给应用自己的「收起侧边栏」。
42
+ * 遮罩显隐完全由 CSS(body:has(...))决定,因此不存在状态不同步。
43
+ * 注意:这段代码会被拼进 <script> 里,保持零反斜杠、零引号嵌套。
44
+ */
45
+ const SHIM_SOURCE = [
46
+ '(function(){',
47
+ 'function start(){',
48
+ 'var s=document.createElement("div");',
49
+ 's.className="dshRcScrim";',
50
+ 's.addEventListener("click",function(){',
51
+ 'var list=document.getElementsByTagName("button");',
52
+ 'for(var i=0;i<list.length;i+=1){',
53
+ 'if(list[i].getAttribute("aria-label")==="收起侧边栏"){list[i].click();return}',
54
+ '}',
55
+ '});',
56
+ 'document.body.appendChild(s);',
57
+ '}',
58
+ 'if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",start)}else{start()}',
59
+ '})()',
60
+ ].join('')
61
+
62
+ const GATE_COOKIE = 'dsh-rc-gate'
63
+
64
+ /** 桌面版默认日志位置(用于自动发现启动令牌与上游端口)。 */
65
+ export function defaultLogCandidates() {
66
+ const home = os.homedir()
67
+ return [
68
+ path.join(home, 'Library/Logs/DSH Desktop/harness.log'),
69
+ path.join(home, 'Library/Logs/dsh-desktop/harness.log'),
70
+ path.join(home, 'Library/Application Support/dsh-desktop/logs/harness.log'),
71
+ path.join(home, '.dsh', 'harness.log'),
72
+ path.join(home, '.local', 'state', 'dsh', 'harness.log'),
73
+ ]
74
+ }
75
+
76
+ /** 本机非回环 IPv4,优先 192.168/10 网段。 */
77
+ export function lanAddresses() {
78
+ const result = []
79
+ const interfaces = os.networkInterfaces()
80
+ for (const name of Object.keys(interfaces)) {
81
+ for (const item of interfaces[name] || []) {
82
+ if (item.family !== 'IPv4' || item.internal) continue
83
+ result.push({ name, address: item.address })
84
+ }
85
+ }
86
+ result.sort((a, b) => {
87
+ const rank = (item) =>
88
+ item.address.startsWith('192.168.') ? 0 : item.address.startsWith('10.') ? 1 : 2
89
+ return rank(a) - rank(b)
90
+ })
91
+ return result
92
+ }
93
+
94
+ /**
95
+ * 从日志里发现令牌与上游端口。日志是跨启动追加的,所以取最后一个匹配行。
96
+ *
97
+ * `expectPort` 很关键:DSH Desktop 的日志里留着**另一个进程**的令牌,若无条件取用,
98
+ * 就会把别的进程的令牌注入到本进程的请求上(表现为 401)。给出 expectPort 时只接受
99
+ * 端口一致的行,即"同进程自证"。
100
+ *
101
+ * @param {string[]} files 候选日志文件
102
+ * @param {number} [expectPort] 只接受该端口的启动行
103
+ * @returns {{ token: string, upstreamPort: number|undefined, logPath: string|undefined }}
104
+ */
105
+ export function discoverToken(files, expectPort) {
106
+ let token = ''
107
+ let upstreamPort
108
+ let logPath
109
+ for (const file of files) {
110
+ let text = ''
111
+ try {
112
+ text = fs.readFileSync(file, 'utf8')
113
+ } catch {
114
+ continue
115
+ }
116
+ for (const line of text.split('\n')) {
117
+ const at = line.indexOf('dsh web: ')
118
+ if (at === -1) continue
119
+ const target = line.slice(at + 'dsh web: '.length).trim().split(/\s+/)[0]
120
+ const portMatch = /:(\d+)/.exec(target)
121
+ const port = portMatch === null ? undefined : Number(portMatch[1])
122
+ if (expectPort !== undefined && port !== expectPort) continue
123
+ const tokenMatch = /[?&]token=([A-Za-z0-9_-]+)/.exec(target)
124
+ if (tokenMatch !== null) token = tokenMatch[1]
125
+ if (port !== undefined) upstreamPort = port
126
+ logPath = file
127
+ }
128
+ }
129
+ return { token, upstreamPort, logPath }
130
+ }
131
+
132
+ /** 把 URL 转成二维码矩阵(0/1 字符串数组);未安装 qrcode 时返回 null。 */
133
+ export function qrRows(text) {
134
+ let factory = null
135
+ try {
136
+ factory = createRequire(import.meta.url)('qrcode')
137
+ } catch {
138
+ return null
139
+ }
140
+ try {
141
+ const code = factory.create(text, { errorCorrectionLevel: 'M' })
142
+ const size = code.modules.size
143
+ if (size > 57) return null
144
+ const rows = []
145
+ for (let row = 0; row < size; row += 1) {
146
+ let line = ''
147
+ for (let column = 0; column < size; column += 1) line += code.modules.get(row, column) ? '1' : '0'
148
+ rows.push(line)
149
+ }
150
+ return rows
151
+ } catch {
152
+ return null
153
+ }
154
+ }
155
+
156
+ function sign(value, key) {
157
+ return crypto.createHmac('sha256', key).update(value).digest('base64url')
158
+ }
159
+
160
+ function safeEqual(a, b) {
161
+ const left = Buffer.from(String(a))
162
+ const right = Buffer.from(String(b))
163
+ if (left.length !== right.length) return false
164
+ return crypto.timingSafeEqual(left, right)
165
+ }
166
+
167
+ function readCookie(header, name) {
168
+ if (typeof header !== 'string') return undefined
169
+ for (const segment of header.split(';')) {
170
+ const at = segment.indexOf('=')
171
+ if (at === -1) continue
172
+ if (segment.slice(0, at).trim() === name) return segment.slice(at + 1).trim()
173
+ }
174
+ return undefined
175
+ }
176
+
177
+ function injectMobileMarkup(html) {
178
+ let out = html
179
+ if (out.includes('name="viewport"')) {
180
+ out = out.replace(
181
+ /<meta\s+name="viewport"[^>]*>/i,
182
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />',
183
+ )
184
+ }
185
+ const extra =
186
+ '<link rel="stylesheet" href="' +
187
+ MOBILE_CSS_PATH +
188
+ '"><script>' +
189
+ SHIM_SOURCE +
190
+ '</script><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes">'
191
+ return out.includes('</head>') ? out.replace('</head>', extra + '</head>') : extra + out
192
+ }
193
+
194
+ /**
195
+ * 建一个代理实例。
196
+ *
197
+ * @param {object} options
198
+ * @param {number} [options.upstreamPort=43129] 上游 Harness Web 端口
199
+ * @param {string} [options.upstreamHost='127.0.0.1']
200
+ * @param {string} [options.listenHost='0.0.0.0'] 绑定地址;公网入口应传 127.0.0.1
201
+ * @param {number} [options.port=8787] 首选端口(0 = 让系统分配,用于 doctor 自测),被占用依次 +1
202
+ * @param {number} [options.portAttempts=20]
203
+ * @param {string} [options.token=''] 显式令牌(最高优先级)
204
+ * @param {() => string} [options.tokenProvider] 惰性令牌提供者(如从 connection 服务取);优先于日志发现
205
+ * @param {string[]} [options.logPaths] 令牌候选日志
206
+ * @param {string} [options.accessKey=''] 非空即启用访问密钥门(公网入口必填)
207
+ * @param {string} [options.gateSecret] Cookie 签名密钥;默认与 accessKey 相同。
208
+ * 多租户时必须给一个**独立**密钥:否则谁换了自己的 accessKey 都会顺带把别人的 Cookie 弄失效。
209
+ * @param {object} [options.tenants] 多租户路由表(见 core/tenant.js + core/server.js):
210
+ * `{ findByKey(key) -> {id, upstreamPort(), token()} | null,
211
+ * findById(id) -> {id, upstreamPort(), token()} | null }`。
212
+ * 给了它以后:`?k=` 决定进哪个租户,Cookie 里带租户 id,请求只发往该租户自己的上游。
213
+ * @param {string[]} [options.allowedHosts=[]] 非空即只接受这些 Host
214
+ * @param {number} [options.gateTtlHours=12] 密钥门 Cookie 有效小时数
215
+ * @param {boolean} [options.mobileAdaptation=true] 是否注入手机适配样式
216
+ * @param {(line: string) => void} [options.log]
217
+ */
218
+ export function createProxy(options = {}) {
219
+ const config = {
220
+ upstreamHost: options.upstreamHost ?? '127.0.0.1',
221
+ upstreamPort: options.upstreamPort ?? 43129,
222
+ listenHost: options.listenHost ?? '0.0.0.0',
223
+ port: options.port ?? 8787,
224
+ portAttempts: options.portAttempts ?? 20,
225
+ token: options.token ?? '',
226
+ logPaths: options.logPaths ?? defaultLogCandidates(),
227
+ accessKey: options.accessKey ?? '',
228
+ // 允许运行中替换访问密钥(面板"生成新的"):给了 provider 就以它为准
229
+ accessKeyProvider: typeof options.accessKeyProvider === 'function' ? options.accessKeyProvider : null,
230
+ keyEpochProvider: typeof options.keyEpochProvider === 'function' ? options.keyEpochProvider : null,
231
+ // 宿主半提供 tunnel 状态与 key 元数据(指纹/创建时间/轮换次数),供 /_dsh/health 使用
232
+ healthProvider: typeof options.healthProvider === 'function' ? options.healthProvider : null,
233
+ // 密钥"代次":轮换时 +1,Cookie 里带着它 → 旧 Cookie 立刻失效
234
+ keyEpoch: Number.isFinite(Number(options.keyEpoch)) ? Number(options.keyEpoch) : 0,
235
+ gateSecret: options.gateSecret ?? options.accessKey ?? '',
236
+ tenants: options.tenants ?? null,
237
+ allowedHosts: options.allowedHosts ?? [],
238
+ gateTtlHours: options.gateTtlHours ?? 12,
239
+ mobileAdaptation: options.mobileAdaptation !== false,
240
+ log: options.log ?? (() => {}),
241
+ }
242
+
243
+ const explicitUpstreamPort = options.upstreamPort !== undefined && options.upstreamPort !== null
244
+ const tokenProvider = typeof options.tokenProvider === 'function' ? options.tokenProvider : null
245
+ let token = config.token
246
+ let tokenProviderCached = false
247
+ let upstreamPort = config.upstreamPort
248
+ let tokenSource = token === '' ? 'none' : 'config'
249
+ let portSource = explicitUpstreamPort ? 'explicit' : 'default'
250
+ if (!explicitUpstreamPort) {
251
+ // 端口本身也可以从日志发现(此时按端口自证取最后一行)
252
+ const found = discoverToken(config.logPaths)
253
+ if (found.upstreamPort !== undefined) {
254
+ upstreamPort = found.upstreamPort
255
+ portSource = 'log:' + String(found.logPath)
256
+ }
257
+ }
258
+ const effectiveAuthorityPort = explicitUpstreamPort ? config.upstreamPort : upstreamPort
259
+
260
+ /**
261
+ * 惰性取令牌。`connection` 服务是异步挂载的(官方也是用 ctx.inject(['connection']) 等它),
262
+ * 启动时就读会读到 undefined,所以放到"要发请求时"再解析,成功后缓存。
263
+ * @returns {string}
264
+ */
265
+ function currentToken() {
266
+ if (token !== '') return token
267
+ if (tokenProvider !== null) {
268
+ try {
269
+ const value = tokenProvider()
270
+ if (typeof value === 'string' && value !== '') {
271
+ token = value
272
+ tokenSource = 'connection'
273
+ tokenProviderCached = true
274
+ return token
275
+ }
276
+ } catch (error) {
277
+ config.log('取令牌失败:' + String(error && error.message ? error.message : error))
278
+ }
279
+ }
280
+ // 兜底:只认端口与本进程一致的日志行,避免拿到别的进程的令牌
281
+ const found = discoverToken(config.logPaths, effectiveAuthorityPort)
282
+ if (found.token !== '') {
283
+ token = found.token
284
+ tokenSource = found.logPath === undefined ? 'none' : 'log:' + found.logPath
285
+ }
286
+ return token
287
+ }
288
+
289
+ const authority = config.upstreamHost + ':' + String(upstreamPort)
290
+ const origin = 'http://' + authority
291
+ const agent = new http.Agent({ keepAlive: true, maxSockets: 64 })
292
+ let server = null
293
+ let boundPort = null
294
+
295
+ /**
296
+ * 失败页:**三种原因各一张**(未带密钥 / 密钥不正确 / 密钥已失效),
297
+ * 对外状态码仍然是 404(不暴露入口存在性),原因只放在 `X-DSH-Reason` 响应头里。
298
+ * 页面里不回显用户发来的 key(否则就成了密钥校验器)。
299
+ */
300
+ function gatePage(res, req, reason) {
301
+ const locale = /zh/i.test(String(req.headers['accept-language'] ?? '')) ? 'zh' : 'en'
302
+ const t = translator(locale)
303
+ const key = reason === 'bad-key' ? 'gate.bad' : reason === 'key-unusable' ? 'gate.unusable' : 'gate.nokey'
304
+ const body = Buffer.from(
305
+ '<!doctype html><meta charset="utf-8"><title>' +
306
+ t(key + '.title') +
307
+ '</title><style>body{font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;padding:48px 20px;background:#f6f7f9;color:#18191c}main{max-width:520px;margin:0 auto;background:#fff;border-radius:12px;padding:24px 28px}h1{font-size:17px;margin:0 0 12px}</style><main><h1>' +
308
+ t(key + '.title') +
309
+ '</h1><p>' +
310
+ t(key + '.body') +
311
+ '</p><p>' +
312
+ t('gate.longLived') +
313
+ '</p></main>\n',
314
+ 'utf8',
315
+ )
316
+ res.writeHead(404, {
317
+ 'content-type': 'text/html; charset=utf-8',
318
+ 'cache-control': 'no-store',
319
+ 'x-dsh-reason': reason,
320
+ 'content-length': String(body.length),
321
+ })
322
+ res.end(body)
323
+ }
324
+
325
+ /**
326
+ * `GET /_dsh/health` —— 不需要密钥,只回指纹与计数,绝不回 key 本身。
327
+ * 手机打不开时访问它,一眼分辨"隧道掉线"还是"key 不匹配"。
328
+ */
329
+ function sendHealth(res) {
330
+ const meta = typeof config.healthProvider === 'function' ? config.healthProvider() || {} : {}
331
+ const cutoff = Date.now() - 24 * 3600 * 1000
332
+ const failures = { 'no-key': 0, 'bad-key': 0, 'key-unusable': 0, 'tunnel-down': 0 }
333
+ for (const item of observations.failures) {
334
+ if (item.at >= cutoff && failures[item.reason] !== undefined) failures[item.reason] += 1
335
+ }
336
+ if (meta.tunnel !== 'up') failures['tunnel-down'] += 1
337
+ const body = Buffer.from(
338
+ JSON.stringify(
339
+ {
340
+ tunnel: meta.tunnel ?? 'unknown',
341
+ key_fp8: typeof meta.keyFingerprint === 'string' ? meta.keyFingerprint : fingerprint(currentAccessKey()),
342
+ key_created_at: meta.keyCreatedAt ?? null,
343
+ key_rotations: Number.isFinite(Number(meta.keyRotations)) ? Number(meta.keyRotations) : 0,
344
+ key_expires_at: null,
345
+ last_ok_at: observations.lastOkAt === null ? null : new Date(observations.lastOkAt).toISOString(),
346
+ failures_last_24h: failures,
347
+ },
348
+ null,
349
+ 2,
350
+ ) + '\n',
351
+ 'utf8',
352
+ )
353
+ res.writeHead(200, {
354
+ 'content-type': 'application/json; charset=utf-8',
355
+ 'cache-control': 'no-store',
356
+ 'content-length': String(body.length),
357
+ })
358
+ res.end(body)
359
+ }
360
+
361
+ /** 隧道/上游不可达时的友好页(原因头 tunnel-down,供 nginx 之外的自诊断)。 */
362
+ function upstreamDownPage(res, req) {
363
+ const locale = /zh/i.test(String(req.headers['accept-language'] ?? '')) ? 'zh' : 'en'
364
+ const t = translator(locale)
365
+ const body = Buffer.from(
366
+ '<!doctype html><meta charset="utf-8"><title>' +
367
+ t('gate.down.title') +
368
+ '</title><style>body{font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;padding:48px 20px;background:#f6f7f9;color:#18191c}main{max-width:520px;margin:0 auto;background:#fff;border-radius:12px;padding:24px 28px}h1{font-size:17px;margin:0 0 12px}</style><main><h1>' +
369
+ t('gate.down.title') +
370
+ '</h1><p>' +
371
+ t('gate.down.body') +
372
+ '</p></main>\n',
373
+ 'utf8',
374
+ )
375
+ res.writeHead(503, {
376
+ 'content-type': 'text/html; charset=utf-8',
377
+ 'cache-control': 'no-store',
378
+ 'x-dsh-reason': 'tunnel-down',
379
+ 'content-length': String(body.length),
380
+ })
381
+ res.end(body)
382
+ }
383
+
384
+ function notFound(res, reason = 'no-key') {
385
+ const body = Buffer.from('not found\n', 'utf8')
386
+ res.writeHead(404, {
387
+ 'content-type': 'text/plain; charset=utf-8',
388
+ 'cache-control': 'no-store',
389
+ 'x-dsh-reason': reason,
390
+ 'content-length': String(body.length),
391
+ })
392
+ res.end(body)
393
+ }
394
+
395
+ function hostAllowed(req) {
396
+ if (config.allowedHosts.length === 0) return true
397
+ const host = String(req.headers.host ?? '').split(':')[0].toLowerCase()
398
+ return config.allowedHosts.some((entry) => entry.toLowerCase() === host)
399
+ }
400
+
401
+ const multiTenant = config.tenants !== null
402
+ /** 观察数据:给 /_dsh/health 用,也是"失败态可诊断"的数据来源。 */
403
+ const observations = { lastOkAt: null, failures: [], maxFailures: 500 }
404
+
405
+ /** 我们发出去的 key 长什么样(32 位十六进制)。用来区分"旧链接"与"写错了"。 */
406
+ function looksLikeKey(value) {
407
+ return /^[0-9a-f]{32}$/i.test(String(value ?? ''))
408
+ }
409
+
410
+ /** key 指纹:哈希前 8 位,**绝不记录明文**。 */
411
+ function fingerprint(value) {
412
+ if (typeof value !== 'string' || value === '') return null
413
+ return crypto.createHash('sha256').update(value).digest('hex').slice(0, 8)
414
+ }
415
+
416
+ function noteFailure(req, reason, presented) {
417
+ const entry = { reason, at: Date.now() }
418
+ observations.failures.push(entry)
419
+ if (observations.failures.length > observations.maxFailures) observations.failures.shift()
420
+ // 插件自己的日志:原因 + 指纹 + 长度;不含明文 key
421
+ config.log(
422
+ 'gate denied reason=' +
423
+ reason +
424
+ ' key_len=' +
425
+ String(typeof presented === 'string' ? presented.length : 0) +
426
+ ' key_fp8=' +
427
+ String(fingerprint(presented) ?? '-') +
428
+ ' ip=' +
429
+ String(req.socket?.remoteAddress ?? '-') +
430
+ ' ua=' +
431
+ String(req.headers['user-agent'] ?? '-').slice(0, 60),
432
+ )
433
+ }
434
+ /** 当前访问密钥(可能是运行中被轮换过的新值)。 */
435
+ function currentAccessKey() {
436
+ if (config.accessKeyProvider !== null) {
437
+ const value = config.accessKeyProvider()
438
+ if (typeof value === 'string') return value
439
+ }
440
+ return config.accessKey
441
+ }
442
+ /** 当前密钥代次(轮换后旧 Cookie 立即失效)。 */
443
+ function currentEpoch() {
444
+ if (config.accessKeyProvider !== null && typeof config.keyEpochProvider === 'function') {
445
+ return Number(config.keyEpochProvider()) || 0
446
+ }
447
+ return config.keyEpoch
448
+ }
449
+
450
+ /**
451
+ * 密钥门。单租户与多租户共用一套 Cookie 机制,区别只在 Cookie 里有没有租户 id:
452
+ * payload = `<过期时间戳>`(单租户)或 `<过期时间戳>:<租户 id>`(多租户)
453
+ * 用 `:` 而不是 `.`,因为 `.` 已经是 payload 与签名的分隔符。
454
+ * @returns {{ status: 'off'|'ok'|'grant'|'deny', tenantId?: string }}
455
+ */
456
+ function gate(req) {
457
+ if (!multiTenant && currentAccessKey() === '') return { status: 'off' }
458
+ const raw = readCookie(req.headers.cookie, GATE_COOKIE)
459
+ if (typeof raw === 'string' && raw.includes('.')) {
460
+ const at = raw.lastIndexOf('.')
461
+ const payload = raw.slice(0, at)
462
+ const mac = raw.slice(at + 1)
463
+ if (safeEqual(mac, sign(payload, config.gateSecret))) {
464
+ // payload: `<expiresAt>:<epoch>`(单租户)或 `<expiresAt>:<epoch>:<tenantId>`(多租户)
465
+ const parts = payload.split(':')
466
+ const expiresAt = Number(parts[0])
467
+ const epoch = Number(parts[1])
468
+ const tenantId = parts.length > 2 ? parts.slice(2).join(':') : undefined
469
+ // 代次对不上 = 密钥轮换过 → 旧 Cookie 失配(用户重新用新口令进一次即可)
470
+ if (epoch !== currentEpoch()) return denyAfterCookie()
471
+ if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
472
+ if (tenantId === undefined) {
473
+ if (!multiTenant) return { status: 'ok' }
474
+ } else if (multiTenant && config.tenants.findById(tenantId) !== null) {
475
+ return { status: 'ok', tenantId }
476
+ }
477
+ }
478
+ }
479
+ }
480
+ const query = new URL(req.url ?? '/', 'http://placeholder').searchParams.get('k')
481
+ if (typeof query === 'string' && query !== '') {
482
+ if (multiTenant) {
483
+ const tenant = config.tenants.findByKey(query)
484
+ if (tenant !== null) return { status: 'grant', tenantId: tenant.id }
485
+ return { status: 'deny', reason: looksLikeKey(query) ? 'key-unusable' : 'bad-key', presented: query }
486
+ }
487
+ if (currentAccessKey() !== '' && safeEqual(query, currentAccessKey())) {
488
+ return { status: 'grant' }
489
+ }
490
+ // 形如"我们发出去的 key"(32 位十六进制)但不是当前值 → 多半是轮换过的旧链接;
491
+ // 其它形态(短串、手输错)→ 直接算"密钥不正确"。两者要能区分,现场才不用猜。
492
+ return { status: 'deny', reason: looksLikeKey(query) ? 'key-unusable' : 'bad-key', presented: query }
493
+ }
494
+ return { status: 'deny', reason: 'no-key' }
495
+ }
496
+
497
+ /** Cookie 里的代次已过期(密钥被轮换过)→ 当作没带凭据处理。 */
498
+ function denyAfterCookie() {
499
+ return { status: 'deny' }
500
+ }
501
+
502
+ /** 本次请求要发往哪个上游(含该租户自己的令牌解析)。 */
503
+ function resolveRoute(tenantId) {
504
+ if (multiTenant) {
505
+ if (tenantId === undefined) return null
506
+ const instance = config.tenants.findById(tenantId)
507
+ if (instance === null) return null
508
+ return { tenantId, upstreamPort: instance.upstreamPort(), currentToken: () => instance.token() }
509
+ }
510
+ return { tenantId: undefined, upstreamPort, currentToken }
511
+ }
512
+
513
+ /** 租户实例还没起来时给一句人话,而不是一个莫名其妙的 404。 */
514
+ function notReady(res, tenantId) {
515
+ const body = Buffer.from(
516
+ tEn('gateway.tenantNotReady', { id: String(tenantId) }) + '\n',
517
+ 'utf8',
518
+ )
519
+ res.writeHead(503, {
520
+ 'content-type': 'text/plain; charset=utf-8',
521
+ 'cache-control': 'no-store',
522
+ 'retry-after': '5',
523
+ 'content-length': String(body.length),
524
+ })
525
+ res.end(body)
526
+ }
527
+
528
+ function grantCookie(req, res, tenantId) {
529
+ const payload =
530
+ String(Date.now() + config.gateTtlHours * 3600 * 1000) +
531
+ ':' + String(currentEpoch()) +
532
+ (tenantId === undefined ? '' : ':' + tenantId)
533
+ const value = payload + '.' + sign(payload, config.gateSecret)
534
+ const clean = (req.url ?? '/').split('?')[0] || '/'
535
+ res.writeHead(303, {
536
+ location: clean,
537
+ 'cache-control': 'no-store',
538
+ 'set-cookie':
539
+ GATE_COOKIE +
540
+ '=' +
541
+ value +
542
+ '; Path=/; Max-Age=' +
543
+ String(Math.floor(config.gateTtlHours * 3600)) +
544
+ '; HttpOnly; SameSite=Lax',
545
+ })
546
+ res.end()
547
+ }
548
+
549
+ function rewriteHeaders(headers, forIndex, route) {
550
+ const next = { ...headers }
551
+ next.host = config.upstreamHost + ':' + String(route.upstreamPort)
552
+ if (next.origin !== undefined && next.origin !== null) {
553
+ next.origin = 'http://' + next.host
554
+ }
555
+ if (forIndex) {
556
+ // 首页要注入,必须拿到未压缩的明文;条件请求会让 304 无法注入
557
+ delete next['accept-encoding']
558
+ delete next['if-none-match']
559
+ delete next['if-modified-since']
560
+ }
561
+ return next
562
+ }
563
+
564
+ /**
565
+ * 是否给这次请求注入启动令牌。
566
+ *
567
+ * 这里踩过两个方向相反的坑,最终规则必须同时满足:
568
+ * - 只有首页 GET 才注入(其余路径注入没意义);
569
+ * - 请求里**已经带 token** 时原样转发:Harness 对 `/?token=…` 一律回 303 → `/`,
570
+ * 再注入就会变成 303 死循环(浏览器报"不能正确地重定向");
571
+ * - 浏览器已经有 `dsh-auth-*` Cookie 时不注入:否则每次访问首页都会被再重定向一次,
572
+ * 同样是死循环。**但**这个 Cookie 可能已经失效(Harness 重启过)→ 那种情况由
573
+ * 上面的 401 分支补一次令牌重定向,而不是每回合都注入。
574
+ */
575
+ function wantsToken(req, route) {
576
+ if (route.currentToken() === '') return false
577
+ if (req.method !== 'GET') return false
578
+ const url = new URL(req.url ?? '/', 'http://placeholder')
579
+ if (url.pathname !== '/') return false
580
+ if (url.searchParams.has('token')) return false
581
+ return !String(req.headers.cookie ?? '').includes('dsh-auth-')
582
+ }
583
+
584
+ /**
585
+ * 令牌可能过期(Harness 重启后会换一把):丢掉缓存,下次 `currentToken()` 重新解析。
586
+ *
587
+ * 这里踩过大坑:原实现"有 tokenProvider 就直接 return",等于永不刷新 ——
588
+ * Harness 重启后插件一直注旧令牌,浏览器拿到旧 Cookie → 401 → 又被重定向回令牌页 →
589
+ * 死循环(用户看到"不能正确地重定向")。显式配置的 `--token` 才不允许刷新。
590
+ */
591
+ function refreshToken() {
592
+ if (config.token !== '') return
593
+ token = ''
594
+ tokenSource = 'none'
595
+ if (tokenProviderCached === true) tokenProviderCached = false
596
+ }
597
+
598
+ function handleRequest(req, res) {
599
+ const raw = req.url ?? '/'
600
+ const pathname = raw.split('?')[0]
601
+ let retried = false
602
+ /**
603
+ * 用启动令牌在**服务端内部**完成一次登录,再把结果(含 Set-Cookie)转给浏览器。
604
+ * 这样令牌不会出现在浏览器地址栏、历史或 Referer 里(规范 §5 要求)。
605
+ */
606
+ function relayTokenLogin(res, route, token) {
607
+ const upstream = http.request(
608
+ {
609
+ host: config.upstreamHost,
610
+ port: route.upstreamPort,
611
+ method: 'GET',
612
+ path: '/?token=' + encodeURIComponent(token),
613
+ headers: rewriteHeaders({ host: config.upstreamHost + ':' + String(route.upstreamPort) }, true, route),
614
+ agent,
615
+ },
616
+ (up) => {
617
+ const chunks = []
618
+ up.on('data', (chunk) => chunks.push(chunk))
619
+ up.on('end', () => {
620
+ const received = Buffer.concat(chunks)
621
+ const isHtml = String(up.headers['content-type'] ?? '').includes('text/html')
622
+ const body =
623
+ isHtml && config.mobileAdaptation
624
+ ? Buffer.from(injectMobileMarkup(received.toString('utf8')), 'utf8')
625
+ : received
626
+ const headers = { ...up.headers }
627
+ delete headers['transfer-encoding']
628
+ delete headers['content-encoding']
629
+ // 上游的 Location 指向 /?token=…:改回 /,否则浏览器又拿到令牌
630
+ if (typeof headers.location === 'string' && headers.location.includes('token=')) headers.location = '/'
631
+ headers['content-length'] = String(body.length)
632
+ headers['cache-control'] = 'no-store'
633
+ res.writeHead(up.statusCode ?? 502, headers)
634
+ res.end(body)
635
+ })
636
+ up.on('error', () => {
637
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
638
+ res.end('remote-connect upstream error\n')
639
+ })
640
+ },
641
+ )
642
+ upstream.on('error', () => {
643
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
644
+ res.end('remote-connect upstream error\n')
645
+ })
646
+ upstream.end()
647
+ }
648
+
649
+ /** 令牌刷新后的重试:方法/请求体/头部都按原样再来一次(只重试首页 GET)。 */
650
+ function retryRequest(freshToken) {
651
+ const upstream = http.request(
652
+ {
653
+ host: config.upstreamHost,
654
+ port: route.upstreamPort,
655
+ method: req.method,
656
+ path: '/?token=' + encodeURIComponent(freshToken),
657
+ headers: rewriteHeaders(req.headers, true, route),
658
+ agent,
659
+ },
660
+ (up) => {
661
+ const chunks = []
662
+ up.on('data', (chunk) => chunks.push(chunk))
663
+ up.on('end', () => {
664
+ const received = Buffer.concat(chunks)
665
+ const isHtml = String(up.headers['content-type'] ?? '').includes('text/html')
666
+ const body =
667
+ isHtml && config.mobileAdaptation
668
+ ? Buffer.from(injectMobileMarkup(received.toString('utf8')), 'utf8')
669
+ : received
670
+ const headers = { ...up.headers }
671
+ delete headers['transfer-encoding']
672
+ delete headers['content-encoding']
673
+ headers['content-length'] = String(body.length)
674
+ headers['cache-control'] = 'no-store'
675
+ res.writeHead(up.statusCode ?? 502, headers)
676
+ res.end(body)
677
+ })
678
+ up.on('error', () => res.destroy())
679
+ },
680
+ )
681
+ upstream.on('error', () => {
682
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
683
+ res.end('remote-connect upstream error\n')
684
+ })
685
+ upstream.end()
686
+ }
687
+
688
+ if (pathname === MOBILE_CSS_PATH) {
689
+ res.writeHead(200, {
690
+ 'content-type': 'text/css; charset=utf-8',
691
+ 'cache-control': 'no-store',
692
+ 'content-length': String(Buffer.byteLength(MOBILE_CSS)),
693
+ })
694
+ res.end(req.method === 'HEAD' ? undefined : MOBILE_CSS)
695
+ return
696
+ }
697
+ // 自诊断端点:不需要密钥,也不泄漏密钥(只回指纹与计数)
698
+ if (pathname === '/_dsh/health') {
699
+ sendHealth(res)
700
+ return
701
+ }
702
+ if (!hostAllowed(req)) {
703
+ notFound(res, 'host-not-allowed')
704
+ return
705
+ }
706
+ const decision = gate(req)
707
+ if (decision.status === 'deny') {
708
+ const reason = decision.reason ?? 'bad-key'
709
+ noteFailure(req, reason, decision.presented)
710
+ // 完全没带凭据的请求只给裸 404(不暴露入口是否存在);带了的才给有文案的失败页
711
+ if (reason === 'no-key' && !raw.includes('k=')) {
712
+ notFound(res, 'no-key')
713
+ return
714
+ }
715
+ gatePage(res, req, reason)
716
+ return
717
+ }
718
+ if (decision.status === 'grant') {
719
+ observations.lastOkAt = Date.now()
720
+ grantCookie(req, res, decision.tenantId)
721
+ return
722
+ }
723
+ const route = resolveRoute(decision.tenantId)
724
+ if (route === null) {
725
+ notFound(res)
726
+ return
727
+ }
728
+ if (!(route.upstreamPort > 0)) {
729
+ notReady(res, route.tenantId)
730
+ return
731
+ }
732
+
733
+ const isIndex = pathname === '/' && req.method === 'GET'
734
+ const injected = wantsToken(req, route)
735
+ const target = injected ? '/?token=' + encodeURIComponent(route.currentToken()) : raw
736
+ const upstream = http.request(
737
+ {
738
+ host: config.upstreamHost,
739
+ port: route.upstreamPort,
740
+ method: req.method,
741
+ path: target,
742
+ headers: rewriteHeaders(req.headers, isIndex, route),
743
+ agent,
744
+ },
745
+ (up) => {
746
+ // 注入了令牌却拿到 401:说明缓存的令牌已过期(Harness 重启过)。
747
+ // 丢掉缓存、重读日志里的新令牌,原地重试一次,避免"页面能开但没会话"。
748
+ // 浏览器 Cookie 失效(Harness 重启过):用当前令牌重定向一次,
749
+ // 让浏览器重新拿到 Cookie。只做一次 —— 目标 URL 自带 token,不会再来一轮。
750
+ const alreadyHasToken = new URL(req.url ?? '/', 'http://placeholder').searchParams.has('token')
751
+ if (up.statusCode === 401 && isIndex && retried === false && !alreadyHasToken) {
752
+ retried = true
753
+ up.resume()
754
+ const previous = route.currentToken()
755
+ refreshToken()
756
+ const fresh = route.currentToken() === '' ? previous : route.currentToken()
757
+ if (fresh !== '') {
758
+ // 内部中继:我们替浏览器带着令牌请求一次,把 Set-Cookie 与页面转给它。
759
+ // 这样启动令牌**不会出现在浏览器地址栏/历史里**(规范 §5)。
760
+ config.log('浏览器会话已失效,用启动令牌在服务端内部重新登录')
761
+ relayTokenLogin(res, route, fresh)
762
+ return
763
+ }
764
+ }
765
+ if (injected && up.statusCode === 401 && retried === false) {
766
+ retried = true
767
+ up.resume()
768
+ const previous = route.currentToken()
769
+ refreshToken()
770
+ const fresh = route.currentToken()
771
+ if (fresh !== '' && fresh !== previous) {
772
+ config.log('注入的启动令牌已过期,已重新发现并重试')
773
+ retryRequest(fresh)
774
+ return
775
+ }
776
+ }
777
+ if (isIndex) {
778
+ const chunks = []
779
+ up.on('data', (chunk) => chunks.push(chunk))
780
+ up.on('end', () => {
781
+ const received = Buffer.concat(chunks)
782
+ const contentType = String(up.headers['content-type'] ?? '')
783
+ const isHtml = contentType.includes('text/html')
784
+ const body =
785
+ isHtml && config.mobileAdaptation
786
+ ? Buffer.from(injectMobileMarkup(received.toString('utf8')), 'utf8')
787
+ : received
788
+ const headers = { ...up.headers }
789
+ delete headers['transfer-encoding']
790
+ delete headers['content-encoding']
791
+ headers['content-length'] = String(body.length)
792
+ headers['cache-control'] = 'no-store'
793
+ res.writeHead(up.statusCode ?? 502, headers)
794
+ res.end(body)
795
+ })
796
+ up.on('error', () => res.destroy())
797
+ return
798
+ }
799
+ res.writeHead(up.statusCode ?? 502, up.headers)
800
+ up.pipe(res)
801
+ },
802
+ )
803
+ upstream.on('error', (error) => {
804
+ // 上游连不上 = 隧道掉了或 Harness 没起来。给一页人话 + 原因头,别丢裸 502。
805
+ if (!res.headersSent) upstreamDownPage(res, req)
806
+ else res.end()
807
+ void error
808
+ })
809
+ req.pipe(upstream)
810
+ res.on('close', () => upstream.destroy())
811
+ }
812
+
813
+ function handleUpgrade(req, socket, head) {
814
+ // WebSocket 也要过密钥门与租户路由:否则升级请求会绕过门直达某个上游
815
+ const decision = gate(req)
816
+ // 'off' = 没有密钥门(局域网入口就是这样)→ 必须放行;
817
+ // 'ok' = Cookie 有效 → 放行;'grant'(还没换 Cookie)与 'deny' 不放行。
818
+ if (decision.status === 'deny' || decision.status === 'grant') {
819
+ socket.destroy()
820
+ return
821
+ }
822
+ const route = resolveRoute(decision.tenantId)
823
+ if (route === null || !(route.upstreamPort > 0)) {
824
+ socket.destroy()
825
+ return
826
+ }
827
+ const headers = rewriteHeaders(req.headers, false, route)
828
+ const upstream = net.connect(route.upstreamPort, config.upstreamHost, () => {
829
+ let raw = req.method + ' ' + (req.url ?? '/') + ' HTTP/1.1\r\n'
830
+ for (const key of Object.keys(headers)) {
831
+ const value = headers[key]
832
+ if (Array.isArray(value)) {
833
+ for (const item of value) raw += key + ': ' + item + '\r\n'
834
+ } else if (value !== undefined) {
835
+ raw += key + ': ' + value + '\r\n'
836
+ }
837
+ }
838
+ raw += '\r\n'
839
+ upstream.write(raw)
840
+ if (head && head.length > 0) upstream.write(head)
841
+ socket.pipe(upstream)
842
+ upstream.pipe(socket)
843
+ })
844
+ upstream.on('error', () => socket.destroy())
845
+ socket.on('error', () => upstream.destroy())
846
+ }
847
+
848
+ /** 当前状态快照(可 JSON 序列化)。 */
849
+ function info() {
850
+ const loopbackOnly = config.listenHost === '127.0.0.1' || config.listenHost === '::1'
851
+ const addresses = loopbackOnly ? [] : lanAddresses()
852
+ const lanIp = addresses.length > 0 ? addresses[0].address : null
853
+ const port = boundPort ?? config.port
854
+ const lanUrl = lanIp === null ? null : 'http://' + lanIp + ':' + String(port) + '/'
855
+ return {
856
+ running: server !== null,
857
+ port,
858
+ listenHost: config.listenHost,
859
+ loopbackOnly,
860
+ addresses,
861
+ upstream: authority,
862
+ upstreamSource: portSource,
863
+ tokenSource: token !== '' ? tokenSource : currentToken() === '' ? tokenSource : tokenSource,
864
+ hasToken: currentToken() !== '',
865
+ lanUrl,
866
+ gate: multiTenant || currentAccessKey() !== '',
867
+ tenants: multiTenant ? 'registry' : null,
868
+ }
869
+ }
870
+
871
+ /** 启动并返回就绪信息;端口被占用时依次尝试后续端口。 */
872
+ async function start() {
873
+ if (server !== null) return info()
874
+ const created = http.createServer(handleRequest)
875
+ created.on('upgrade', handleUpgrade)
876
+ let attempt = 0
877
+ let port = config.port
878
+ for (;;) {
879
+ try {
880
+ await new Promise((resolve, reject) => {
881
+ const onError = (error) => {
882
+ created.removeListener('listening', onListening)
883
+ reject(error)
884
+ }
885
+ const onListening = () => {
886
+ created.removeListener('error', onError)
887
+ resolve()
888
+ }
889
+ created.once('error', onError)
890
+ created.once('listening', onListening)
891
+ created.listen(port, config.listenHost)
892
+ })
893
+ break
894
+ } catch (error) {
895
+ if (error?.code === 'EADDRINUSE' && attempt < config.portAttempts) {
896
+ attempt += 1
897
+ port += 1
898
+ continue
899
+ }
900
+ throw error
901
+ }
902
+ }
903
+ server = created
904
+ const address = created.address()
905
+ boundPort = typeof address === 'object' && address !== null ? address.port : port
906
+ if (token === '') config.log('未发现 Harness 启动令牌:远程浏览器可能无法自动登录(可用 --token 指定)')
907
+ return info()
908
+ }
909
+
910
+ /** 停止监听。 */
911
+ async function stop() {
912
+ const current = server
913
+ server = null
914
+ boundPort = null
915
+ agent.destroy()
916
+ if (current === null) return
917
+ await new Promise((resolve) => {
918
+ current.closeAllConnections?.()
919
+ current.close(() => resolve())
920
+ })
921
+ }
922
+
923
+ return { start, stop, info, config: { ...config, upstreamPort, token } }
924
+ }