@yezack/dsh-remote-mobile 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,568 @@
1
+ /**
2
+ * DSH 移动端远程独立安全网关服务 (GatewayServer)
3
+ *
4
+ * 架构定位:
5
+ * 解决 DSH 0.2.1+ 底层强行禁止 0.0.0.0 监听的问题。
6
+ * DSH 官方 WebServer 继续保持纯粹、安全的 127.0.0.1:3080 本地回环监听(零外部暴露风险);
7
+ * 由本服务在 0.0.0.0:3081 启动专用安全反代网关,接纳局域网与 Tailscale 流量,
8
+ * 执行端到端 RSA 加密、扫码配对、慢哈希密码认证与防暴力破解 IP 熔断,
9
+ * 并将合法的流量全双工代理至底座核心服务,彻底解决换 Wi-Fi 崩溃、无法兼顾多网卡与本机回环失效的痛点。
10
+ */
11
+ import http from 'node:http';
12
+ import { isIP } from 'node:net';
13
+ import { getClientIp } from '../auth/tailscale.js';
14
+ import { isRequestAuthorizedByGate } from '../bridge/compat.js';
15
+ import { isSameOriginOrLocalEquivalent, isTrustedHost, getRequestProtocol, isLoopbackRequest, } from '../auth/gate.js';
16
+ import { getGatewaySecret } from './secret.js';
17
+ export class GatewayServer {
18
+ server = null;
19
+ activeSockets = new Set();
20
+ store;
21
+ gateMiddleware;
22
+ configuredPort;
23
+ boundPort;
24
+ host;
25
+ corePort;
26
+ coreHost;
27
+ coreTargetAuthority;
28
+ autoIncrementPort;
29
+ upgradeTimeoutMs;
30
+ status = 'stopped';
31
+ lastError = null;
32
+ logger;
33
+ constructor(options, store, gateMiddleware, logger) {
34
+ this.configuredPort = options.port ?? 3081;
35
+ this.boundPort = this.configuredPort;
36
+ this.host = options.host ?? '0.0.0.0';
37
+ this.corePort = options.corePort ?? 3080;
38
+ this.coreHost = options.coreHost ?? '127.0.0.1';
39
+ this.coreTargetAuthority =
40
+ this.coreHost.includes(':') && !this.coreHost.startsWith('[')
41
+ ? `[${this.coreHost}]:${this.corePort}`
42
+ : `${this.coreHost}:${this.corePort}`;
43
+ this.autoIncrementPort = options.autoIncrementPort ?? true;
44
+ this.upgradeTimeoutMs = options.upgradeTimeoutMs ?? 10000;
45
+ this.store = store;
46
+ this.gateMiddleware = gateMiddleware;
47
+ this.logger = logger;
48
+ }
49
+ /** 获取网关当前运行状态契约 */
50
+ getStatus() {
51
+ return this.status;
52
+ }
53
+ /** 获取网关最近发生的异常信息 */
54
+ getLastError() {
55
+ return this.lastError;
56
+ }
57
+ /** 获取网关实际生效绑定的端口 */
58
+ getBoundPort() {
59
+ return this.boundPort;
60
+ }
61
+ /** 获取网关监听的 host */
62
+ getHost() {
63
+ return this.host;
64
+ }
65
+ /** 获取内部转发的目标核心端口 */
66
+ getCorePort() {
67
+ return this.corePort;
68
+ }
69
+ /** 获取内部底层 Server 实例(供测试或状态检测) */
70
+ getServer() {
71
+ return this.server;
72
+ }
73
+ /**
74
+ * 检验请求的 Host 头是否符合动态白名单(防 DNS-Rebinding 与非法主机注入)
75
+ */
76
+ isHostAllowed(hostHeader) {
77
+ // 只校验主机名,不强制端口等于 boundPort:
78
+ // 反向代理、TLS 终结、路由器端口映射场景的外部端口通常不是 3081,
79
+ // 端口不应作为 DNS-Rebinding 的信任边界。网关配置的特定监听 Host 作为额外白名单。
80
+ const extraHosts = this.host && this.host !== '0.0.0.0' && this.host !== '::' ? [this.host] : [];
81
+ return isTrustedHost(hostHeader, this.store, extraHosts);
82
+ }
83
+ /**
84
+ * 获取外部访问协议(兼容 Nginx / Caddy / Tailscale Serve 等反代的 X-Forwarded-Proto)
85
+ */
86
+ getExternalProtocol(req) {
87
+ return getRequestProtocol(req);
88
+ }
89
+ /**
90
+ * 计算转发到底座的真实客户端 IP。
91
+ *
92
+ * 正常 LAN/Tailscale 客户端直接连网关,socket IP 即真实 IP;
93
+ * 同机反向代理从 127.0.0.1 连入时,若门禁已判定为非回环(Host/Forwarded 表明是代理),
94
+ * 则采用 X-Forwarded-For 首个合法 IP,恢复防暴破/审计的真实来源。
95
+ */
96
+ getEffectiveClientIp(req, useForwardedFor) {
97
+ if (useForwardedFor) {
98
+ const raw = req.headers['x-forwarded-for'];
99
+ const value = Array.isArray(raw) ? raw[0] : raw;
100
+ if (typeof value === 'string') {
101
+ const first = value.split(',')[0].trim().replace(/^::ffff:/, '');
102
+ if (isIP(first))
103
+ return first;
104
+ }
105
+ }
106
+ return getClientIp(req);
107
+ }
108
+ /** 请求是否携带反向代理透传头 */
109
+ hasForwardedHeaders(req) {
110
+ return Boolean(req.headers['x-forwarded-for'] ||
111
+ req.headers['x-forwarded-host'] ||
112
+ req.headers['x-forwarded-proto'] ||
113
+ req.headers['x-real-ip']);
114
+ }
115
+ /**
116
+ * 启动网关服务(带端口冲突自愈探测与状态机落定)
117
+ */
118
+ async start() {
119
+ if (this.server && this.status === 'running') {
120
+ return { port: this.boundPort, host: this.host };
121
+ }
122
+ this.status = 'starting';
123
+ this.lastError = null;
124
+ const server = http.createServer((req, res) => {
125
+ this.handleHttpRequest(req, res);
126
+ });
127
+ server.on('upgrade', (req, socket, head) => {
128
+ this.handleUpgrade(req, socket, head);
129
+ });
130
+ server.on('connection', (socket) => {
131
+ this.activeSockets.add(socket);
132
+ socket.on('close', () => {
133
+ this.activeSockets.delete(socket);
134
+ });
135
+ });
136
+ this.server = server;
137
+ const maxTries = this.autoIncrementPort ? 10 : 1;
138
+ let currentPort = this.configuredPort;
139
+ for (let attempt = 0; attempt < maxTries; attempt++) {
140
+ try {
141
+ await new Promise((resolve, reject) => {
142
+ server.once('error', (err) => {
143
+ reject(err);
144
+ });
145
+ server.listen(currentPort, this.host, () => {
146
+ resolve();
147
+ });
148
+ });
149
+ const addr = server.address();
150
+ this.boundPort = addr && typeof addr === 'object' ? addr.port : currentPort;
151
+ this.status = 'running';
152
+ this.logger?.info?.(`[dsh-remote-mobile] 独立安全反向代理网关已就绪: http://${this.host}:${this.boundPort} -> 127.0.0.1:${this.corePort}`);
153
+ return { port: this.boundPort, host: this.host };
154
+ }
155
+ catch (err) {
156
+ if (err.code === 'EADDRINUSE' && attempt < maxTries - 1 && currentPort > 0) {
157
+ this.logger?.warn?.(`[dsh-remote-mobile] 端口 ${currentPort} 已被占用,尝试自增探测端口 ${currentPort + 1}...`);
158
+ currentPort++;
159
+ continue;
160
+ }
161
+ this.status = 'error';
162
+ this.lastError = err?.message || String(err);
163
+ this.server = null;
164
+ throw err;
165
+ }
166
+ }
167
+ this.status = 'error';
168
+ this.lastError = `尝试端口均被占用 (${this.configuredPort} ~ ${currentPort})`;
169
+ throw new Error(`[dsh-remote-mobile] 启动安全网关失败:${this.lastError}`);
170
+ }
171
+ /**
172
+ * 优雅关闭网关服务,立即释放端口与所有客户端 Socket
173
+ */
174
+ async close() {
175
+ if (!this.server) {
176
+ this.status = 'stopped';
177
+ return;
178
+ }
179
+ // 1. 遍历销毁所有保持活跃的连接(包括 Keep-Alive 与长连接 WebSocket)
180
+ for (const socket of this.activeSockets) {
181
+ try {
182
+ socket.destroy();
183
+ }
184
+ catch { }
185
+ }
186
+ this.activeSockets.clear();
187
+ // 2. 彻底关闭 Server 监听
188
+ const s = this.server;
189
+ this.server = null;
190
+ this.status = 'stopped';
191
+ await new Promise((resolve) => {
192
+ s.close(() => resolve());
193
+ });
194
+ }
195
+ /**
196
+ * 处理 HTTP 请求:
197
+ * 1. 严格校验 Host 白名单(阻断 DNS-Rebinding)
198
+ * 2. 执行安全门禁 gateMiddleware 与跨站写拦截
199
+ * 3. 构造请求头:抹除客户端伪造 Secret,注入真实 IP 与内部有效握手密钥
200
+ * 4. 实施成对虚拟化决策矩阵
201
+ * 5. 转发到底座并流式透明 Pipe
202
+ */
203
+ handleHttpRequest(req, res) {
204
+ const originalHost = req.headers.host || '';
205
+ // 1. Host 校验:不在有效网络白名单内的未知域名直接 400 阻断
206
+ if (!this.isHostAllowed(originalHost)) {
207
+ res.writeHead(400, {
208
+ 'Content-Type': 'application/json; charset=utf-8',
209
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
210
+ });
211
+ res.end(JSON.stringify({
212
+ error: 'Bad Request',
213
+ message: `未受信任的主机名: "${originalHost}"。若使用 Tailscale MagicDNS 或自定义域名,请在插件设置中的 trustedHosts 声明。`,
214
+ }));
215
+ return;
216
+ }
217
+ // 网关已完成 Host 白名单校验,通知共享门禁跳过重复 Host 校验
218
+ // (gatewayHost 可能是自定义域名,不属于核心的 trustedHosts / 网卡 IP)
219
+ ;
220
+ req.__dsh_rm_host_allowed__ = true;
221
+ // 2. 门禁中间件校验与原因标记获取
222
+ this.gateMiddleware(req, res, () => {
223
+ this.proxyHttpRequest(req, res);
224
+ });
225
+ }
226
+ /**
227
+ * 转发已授权的 HTTP 请求至 DSH 本地核心服务
228
+ */
229
+ proxyHttpRequest(req, res) {
230
+ const originalHost = req.headers.host || '';
231
+ const externalProto = this.getExternalProtocol(req);
232
+ const gateResult = req.__dsh_rm_gate_result || 'public';
233
+ // 以门禁判定结果为准:同机反代会从 127 连入但 Host/Forwarded 指向外部,
234
+ // 不能被误当成 loopback 本机流量。
235
+ const isLoopbackClient = gateResult === 'loopback';
236
+ const clientIp = this.getEffectiveClientIp(req, !isLoopbackClient && this.hasForwardedHeaders(req));
237
+ // 构造发往内部核心的请求头
238
+ const proxyHeaders = { ...req.headers };
239
+ // 强制消除客户端可能伪造的内部网关密钥头
240
+ delete proxyHeaders['x-dsh-gateway-secret'];
241
+ // 注入当前进程真实有效的握手密钥,通知底座采纳真实客户端 IP
242
+ const secret = getGatewaySecret(this.server);
243
+ if (secret) {
244
+ proxyHeaders['x-dsh-gateway-secret'] = secret;
245
+ }
246
+ // 规范化溯源专用代理头
247
+ proxyHeaders['x-dsh-real-ip'] = clientIp;
248
+ proxyHeaders['x-real-ip'] = clientIp;
249
+ proxyHeaders['x-forwarded-for'] = clientIp;
250
+ proxyHeaders['x-original-host'] = originalHost;
251
+ proxyHeaders['x-forwarded-host'] = this.coreTargetAuthority;
252
+ proxyHeaders['x-forwarded-proto'] = externalProto;
253
+ // 成对虚拟化决策矩阵
254
+ if (isLoopbackClient) {
255
+ // 回环客户端(本机访问):
256
+ // 1. 若带有 Origin:
257
+ // - 若 origin === 'null':按跨站处理,绝不修改洗白;
258
+ // - 若属于本地回环变体且同源/本地等价:成对规范化 Host 和 Origin 为内部核心权威源,端口保持一致;
259
+ // - 若为外部跨站域名(如 evil.com):原样保留供下游 CSRF 检查,严禁洗白;
260
+ // 2. 若不带 Origin:仅规范化 Host 为内部核心权威源
261
+ const origin = req.headers.origin;
262
+ const hasOrigin = typeof origin === 'string' && origin;
263
+ if (hasOrigin) {
264
+ if (origin !== 'null') {
265
+ try {
266
+ const originUrl = new URL(origin);
267
+ if (isSameOriginOrLocalEquivalent(originUrl.host, originalHost, originUrl.protocol, externalProto)) {
268
+ proxyHeaders['host'] = this.coreTargetAuthority;
269
+ proxyHeaders['origin'] = `${externalProto}://${this.coreTargetAuthority}`;
270
+ if (proxyHeaders['referer'] && typeof proxyHeaders['referer'] === 'string') {
271
+ try {
272
+ const refUrl = new URL(proxyHeaders['referer']);
273
+ refUrl.host = this.coreTargetAuthority;
274
+ refUrl.protocol = `${externalProto}:`;
275
+ proxyHeaders['referer'] = refUrl.toString();
276
+ }
277
+ catch { }
278
+ }
279
+ }
280
+ else {
281
+ // 跨站 Origin:保留原始 host 或不做洗白
282
+ }
283
+ }
284
+ catch { }
285
+ }
286
+ }
287
+ else {
288
+ proxyHeaders['host'] = this.coreTargetAuthority;
289
+ }
290
+ }
291
+ else if (gateResult === 'public') {
292
+ // 公开路径(/auth 等):成对原则
293
+ // 若带有 Origin 且与原始 Host 同源,则 Host + Origin 成对规范化为内部核心;
294
+ // 若跨站则 Origin 绝不洗白,原样保留传递;若不带 Origin 则仅改写 Host
295
+ const origin = req.headers.origin;
296
+ const hasOrigin = typeof origin === 'string' && origin;
297
+ if (hasOrigin) {
298
+ try {
299
+ const originUrl = new URL(origin);
300
+ if (isSameOriginOrLocalEquivalent(originUrl.host, originalHost, originUrl.protocol, externalProto)) {
301
+ proxyHeaders['host'] = this.coreTargetAuthority;
302
+ proxyHeaders['origin'] = `${externalProto}://${this.coreTargetAuthority}`;
303
+ if (proxyHeaders['referer'] && typeof proxyHeaders['referer'] === 'string') {
304
+ try {
305
+ const refUrl = new URL(proxyHeaders['referer']);
306
+ refUrl.host = this.coreTargetAuthority;
307
+ refUrl.protocol = `${externalProto}:`;
308
+ proxyHeaders['referer'] = refUrl.toString();
309
+ }
310
+ catch { }
311
+ }
312
+ proxyHeaders['sec-fetch-site'] = 'same-origin';
313
+ }
314
+ }
315
+ catch { }
316
+ }
317
+ else {
318
+ proxyHeaders['host'] = this.coreTargetAuthority;
319
+ }
320
+ }
321
+ else {
322
+ // token 或 bypass-* 已认证外部合法流量:
323
+ // 在确认同源后,成对虚拟化为内部回环权威源,解除生态插件 403 围栏
324
+ const origin = req.headers.origin;
325
+ const hasOrigin = typeof origin === 'string' && origin;
326
+ if (hasOrigin) {
327
+ try {
328
+ const originUrl = new URL(origin);
329
+ if (isSameOriginOrLocalEquivalent(originUrl.host, originalHost, originUrl.protocol, externalProto)) {
330
+ proxyHeaders['host'] = this.coreTargetAuthority;
331
+ proxyHeaders['origin'] = `${externalProto}://${this.coreTargetAuthority}`;
332
+ if (proxyHeaders['referer'] && typeof proxyHeaders['referer'] === 'string') {
333
+ try {
334
+ const refUrl = new URL(proxyHeaders['referer']);
335
+ refUrl.host = this.coreTargetAuthority;
336
+ refUrl.protocol = `${externalProto}:`;
337
+ proxyHeaders['referer'] = refUrl.toString();
338
+ }
339
+ catch { }
340
+ }
341
+ proxyHeaders['sec-fetch-site'] = 'same-origin';
342
+ }
343
+ }
344
+ catch { }
345
+ }
346
+ else {
347
+ proxyHeaders['host'] = this.coreTargetAuthority;
348
+ if (!proxyHeaders['sec-fetch-site'] || proxyHeaders['sec-fetch-site'] === 'cross-site') {
349
+ proxyHeaders['sec-fetch-site'] = 'same-origin';
350
+ }
351
+ }
352
+ }
353
+ const proxyReq = http.request({
354
+ hostname: this.coreHost,
355
+ port: this.corePort,
356
+ path: req.url,
357
+ method: req.method,
358
+ headers: proxyHeaders,
359
+ }, (proxyRes) => {
360
+ // 1. 重定向 Location 头改写:防止底座 302 重定向到 127.0.0.1 导致手机打不开
361
+ const location = proxyRes.headers.location;
362
+ if (location && [301, 302, 303, 307, 308].includes(proxyRes.statusCode || 0)) {
363
+ proxyRes.headers.location = this.rewriteRedirectLocation(location, originalHost, externalProto);
364
+ }
365
+ // 2. 针对 SSE 长连接流式输出(打字机效果),禁用代理缓冲并立即刷头
366
+ const contentType = String(proxyRes.headers['content-type'] || '').toLowerCase();
367
+ if (contentType.includes('text/event-stream')) {
368
+ proxyRes.headers['x-accel-buffering'] = 'no';
369
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
370
+ if (typeof res.flushHeaders === 'function') {
371
+ res.flushHeaders();
372
+ }
373
+ }
374
+ else {
375
+ res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
376
+ }
377
+ // 3. 将底座响应流透明 pipe 回客户端
378
+ proxyRes.pipe(res);
379
+ });
380
+ proxyReq.on('error', (err) => {
381
+ if (!res.headersSent) {
382
+ res.writeHead(502, {
383
+ 'Content-Type': 'text/plain; charset=utf-8',
384
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
385
+ });
386
+ res.end(`[dsh-remote-mobile] 502 Bad Gateway: 无法连接 DSH 本地核心服务 (${this.coreTargetAuthority}): ${err.message}`);
387
+ }
388
+ });
389
+ // 客户端提前中断时同步销毁代理请求
390
+ req.on('aborted', () => {
391
+ proxyReq.destroy();
392
+ });
393
+ // 将客户端请求体 pipe 到底座
394
+ req.pipe(proxyReq);
395
+ }
396
+ /**
397
+ * 处理 WebSocket Upgrade 协议升级代理(支持超时清除、非 101 状态传递与 CSWSH 防御)
398
+ */
399
+ handleUpgrade(req, clientSocket, head) {
400
+ const originalHost = req.headers.host || '';
401
+ // 1. Host 白名单校验
402
+ if (!this.isHostAllowed(originalHost)) {
403
+ clientSocket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
404
+ clientSocket.destroy();
405
+ return;
406
+ }
407
+ // 网关已完成 Host 白名单校验,通知共享门禁跳过重复 Host 校验
408
+ ;
409
+ req.__dsh_rm_host_allowed__ = true;
410
+ const externalProto = this.getExternalProtocol(req);
411
+ // 2. WebSocket 跨站劫持 (CSWSH) 校验:Origin 必须与当前 Host 同源
412
+ const origin = req.headers.origin;
413
+ if (typeof origin === 'string' && origin) {
414
+ try {
415
+ const originUrl = new URL(origin);
416
+ if (!isSameOriginOrLocalEquivalent(originUrl.host, originalHost, originUrl.protocol, externalProto)) {
417
+ clientSocket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
418
+ clientSocket.destroy();
419
+ return;
420
+ }
421
+ }
422
+ catch {
423
+ clientSocket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
424
+ clientSocket.destroy();
425
+ return;
426
+ }
427
+ }
428
+ // 3. 门禁鉴权守卫:未通过身份校验的 WebSocket 连接直接拒绝
429
+ if (!isRequestAuthorizedByGate(req, this.store)) {
430
+ clientSocket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
431
+ clientSocket.destroy();
432
+ return;
433
+ }
434
+ const isLoopbackClient = isLoopbackRequest(req);
435
+ const clientIp = this.getEffectiveClientIp(req, !isLoopbackClient && this.hasForwardedHeaders(req));
436
+ const proxyHeaders = { ...req.headers };
437
+ // 消除伪造并注入有效内部握手密钥与真实 IP
438
+ delete proxyHeaders['x-dsh-gateway-secret'];
439
+ const secret = getGatewaySecret(this.server);
440
+ if (secret) {
441
+ proxyHeaders['x-dsh-gateway-secret'] = secret;
442
+ }
443
+ proxyHeaders['x-dsh-real-ip'] = clientIp;
444
+ proxyHeaders['x-real-ip'] = clientIp;
445
+ proxyHeaders['x-forwarded-for'] = clientIp;
446
+ proxyHeaders['x-original-host'] = originalHost;
447
+ proxyHeaders['x-forwarded-host'] = this.coreTargetAuthority;
448
+ proxyHeaders['x-forwarded-proto'] = externalProto;
449
+ // 成对虚拟化 Host 与 Origin,解除生态插件 WebSocket 升级围栏
450
+ proxyHeaders['host'] = this.coreTargetAuthority;
451
+ if (proxyHeaders['origin']) {
452
+ proxyHeaders['x-forwarded-origin'] = proxyHeaders['origin'];
453
+ proxyHeaders['origin'] = `${externalProto}://${this.coreTargetAuthority}`;
454
+ }
455
+ if (!proxyHeaders['sec-fetch-site'] || proxyHeaders['sec-fetch-site'] === 'cross-site') {
456
+ proxyHeaders['sec-fetch-site'] = 'same-origin';
457
+ }
458
+ let isUpgraded = false;
459
+ const proxyReq = http.request({
460
+ hostname: this.coreHost,
461
+ port: this.corePort,
462
+ path: req.url,
463
+ method: req.method || 'GET',
464
+ headers: proxyHeaders,
465
+ });
466
+ // 设置握手阶段超时(仅在升级前生效)
467
+ proxyReq.setTimeout(this.upgradeTimeoutMs, () => {
468
+ if (!isUpgraded) {
469
+ proxyReq.destroy();
470
+ try {
471
+ clientSocket.write('HTTP/1.1 504 Gateway Timeout\r\nConnection: close\r\n\r\n');
472
+ clientSocket.destroy();
473
+ }
474
+ catch { }
475
+ }
476
+ });
477
+ // 客户端若在升级完成前主动断开,及时清理上游请求
478
+ const onEarlyClose = () => {
479
+ if (!isUpgraded) {
480
+ proxyReq.destroy();
481
+ }
482
+ };
483
+ clientSocket.once('close', onEarlyClose);
484
+ // 底座返回非 101 HTTP 响应处理(如 403 / 401 / 500 等)
485
+ proxyReq.on('response', (proxyRes) => {
486
+ proxyReq.setTimeout(0); // 立即清除握手阶段超时,防止大响应体被误杀
487
+ clientSocket.removeListener('close', onEarlyClose);
488
+ const rawStatusLine = `HTTP/${proxyRes.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage || ''}\r\n`;
489
+ let headersStr = '';
490
+ const raw = proxyRes.rawHeaders;
491
+ for (let i = 0; i < raw.length; i += 2) {
492
+ headersStr += `${raw[i]}: ${raw[i + 1]}\r\n`;
493
+ }
494
+ try {
495
+ clientSocket.write(`${rawStatusLine}${headersStr}\r\n`);
496
+ proxyRes.pipe(clientSocket);
497
+ }
498
+ catch {
499
+ clientSocket.destroy();
500
+ }
501
+ });
502
+ proxyReq.on('upgrade', (proxyRes, coreSocket, proxyHead) => {
503
+ isUpgraded = true;
504
+ clientSocket.removeListener('close', onEarlyClose);
505
+ // 升级成功:立即清除握手阶段的超时定时器,保证长连接不被误杀
506
+ proxyReq.setTimeout(0);
507
+ // 下发 101 Switching Protocols 响应头给客户端
508
+ const rawStatusLine = `HTTP/${proxyRes.httpVersion} 101 Switching Protocols\r\n`;
509
+ let headersStr = '';
510
+ const raw = proxyRes.rawHeaders;
511
+ for (let i = 0; i < raw.length; i += 2) {
512
+ headersStr += `${raw[i]}: ${raw[i + 1]}\r\n`;
513
+ }
514
+ clientSocket.write(`${rawStatusLine}${headersStr}\r\n`);
515
+ // 推回预先消费的头缓冲区
516
+ if (proxyHead && proxyHead.length) {
517
+ clientSocket.unshift(proxyHead);
518
+ }
519
+ if (head && head.length) {
520
+ coreSocket.unshift(head);
521
+ }
522
+ // 开启 TCP_NODELAY,关闭 Nagle 算法,消除终端交互延迟
523
+ clientSocket.setNoDelay(true);
524
+ coreSocket.setNoDelay(true);
525
+ // 全双工透传
526
+ clientSocket.pipe(coreSocket);
527
+ coreSocket.pipe(clientSocket);
528
+ // 异常互斥同步清理
529
+ clientSocket.on('error', () => coreSocket.destroy());
530
+ coreSocket.on('error', () => clientSocket.destroy());
531
+ clientSocket.on('close', () => coreSocket.destroy());
532
+ coreSocket.on('close', () => clientSocket.destroy());
533
+ });
534
+ proxyReq.on('error', (err) => {
535
+ clientSocket.removeListener('close', onEarlyClose);
536
+ if (!isUpgraded) {
537
+ this.logger?.error?.('[dsh-remote-mobile] WebSocket 代理建立失败:', err);
538
+ try {
539
+ clientSocket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
540
+ clientSocket.destroy();
541
+ }
542
+ catch { }
543
+ }
544
+ });
545
+ proxyReq.end();
546
+ }
547
+ /**
548
+ * 改写底座 30x 重定向的目标 Location,将回环地址替换为外部真实访问的 Host
549
+ * 兼容 127.0.0.1, localhost, [::1] 以及 127.* 回环变体
550
+ */
551
+ rewriteRedirectLocation(location, originalHost, externalProto = 'http') {
552
+ if (!location || !originalHost)
553
+ return location;
554
+ try {
555
+ // 匹配 http://127.0.0.1:3080/..., http://localhost:3080/..., http://[::1]:3080/...
556
+ const loopbackPattern = new RegExp(`^(?:https?):\\/\\/(?:127\\.\\d+\\.\\d+\\.\\d+|localhost|\\[::1\\]):${this.corePort}(\\/.*)?$`, 'i');
557
+ const match = location.match(loopbackPattern);
558
+ if (match) {
559
+ const pathPart = match[1] || '';
560
+ return `${externalProto}://${originalHost}${pathPart}`;
561
+ }
562
+ return location;
563
+ }
564
+ catch {
565
+ return location;
566
+ }
567
+ }
568
+ }
package/lib/index.d.ts CHANGED
@@ -32,3 +32,5 @@ export * from './auth/gate.js';
32
32
  export * from './routes/api.js';
33
33
  export * from './bridge/compat.js';
34
34
  export * from './styles/style-snippets.js';
35
+ export * from './gateway/detector.js';
36
+ export * from './gateway/server.js';