rdsh-gateway 0.8.2 → 0.8.4

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/dist/config.d.ts CHANGED
@@ -43,6 +43,13 @@ export interface GatewayConfig {
43
43
  export interface DshUiCompat {
44
44
  /** E2EE 激活(或宿主启用)时 patch JS;false = 保持 DSH 原样(共享 host/敏感场景) */
45
45
  trustE2EEAsLoopback?: boolean;
46
+ /**
47
+ * LAN / 已配对会话是否同样视同 loopback(默认 true)。
48
+ * 背景:DSH 的设置与"持久化凭据"界面只对 loopback 开放,而 LAN 访问的 hostname 是局域网 IP,
49
+ * 不满足 DSH 的 loopback 判定 ⇒ 不打开时 LAN 用户无法在界面里输入 API key。
50
+ * false = 严格模式(LAN 用户需自行在 `~/.dsh/.credentials.yaml` 配置)。
51
+ */
52
+ trustPairedAsLoopback?: boolean;
46
53
  }
47
54
  export declare const DEFAULT_HOST_CONFIG_PATH: string;
48
55
  /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
package/dist/config.js CHANGED
@@ -21,7 +21,7 @@ const DEFAULTS = {
21
21
  behindProxy: false,
22
22
  allowFrom: [],
23
23
  auth: DEFAULT_AUTH,
24
- dshUiCompat: { trustE2EEAsLoopback: true },
24
+ dshUiCompat: { trustE2EEAsLoopback: true, trustPairedAsLoopback: true },
25
25
  gateway: { accessCode: null },
26
26
  };
27
27
  /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
@@ -182,12 +182,20 @@ export function normalizeConfig(raw, source = "config") {
182
182
  throw new Error(`${source}: "dshUiCompat" must be an object`);
183
183
  }
184
184
  const compat = cfg.dshUiCompat;
185
+ const compatOut = {};
185
186
  if (compat.trustE2EEAsLoopback !== undefined) {
186
187
  if (typeof compat.trustE2EEAsLoopback !== "boolean") {
187
188
  throw new Error(`${source}: "dshUiCompat.trustE2EEAsLoopback" must be boolean`);
188
189
  }
189
- out.dshUiCompat = { trustE2EEAsLoopback: compat.trustE2EEAsLoopback };
190
+ compatOut.trustE2EEAsLoopback = compat.trustE2EEAsLoopback;
190
191
  }
192
+ if (compat.trustPairedAsLoopback !== undefined) {
193
+ if (typeof compat.trustPairedAsLoopback !== "boolean") {
194
+ throw new Error(`${source}: "dshUiCompat.trustPairedAsLoopback" must be boolean`);
195
+ }
196
+ compatOut.trustPairedAsLoopback = compat.trustPairedAsLoopback;
197
+ }
198
+ out.dshUiCompat = compatOut;
191
199
  }
192
200
  // ---- gateway(访问口令;缺省 accessCode null = gate off)----
193
201
  if (cfg.gateway !== undefined) {
@@ -0,0 +1,6 @@
1
+ /** 取首个 content-encoding(小写;identity/空 → "")。 */
2
+ export declare function firstEncoding(value: string | string[] | undefined): string;
3
+ /** 解码为明文;不支持/损坏 → null。 */
4
+ export declare function decodeBody(body: Buffer, encoding: string): Buffer | null;
5
+ /** 按原编码重新压缩;identity → 原样;不支持/失败 → null。 */
6
+ export declare function encodeBody(body: Buffer, encoding: string): Buffer | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * http-encoding.ts — 响应体 `content-encoding` 的解码/重编码(供 loopback JS 补丁使用)。
3
+ *
4
+ * 背景(2026-09-14 实测事故):dsh 在客户端声明 `accept-encoding` 时会对 JS **gzip 压缩**,
5
+ * 而 `patchLoopbackJs()` 是在字节里做字面量替换 —— 在压缩体上必然 miss(fail-open 静默失效),
6
+ * 导致前端 `isLoopback` 判定没被改写、DSH 设置页/API key 界面不可用。
7
+ *
8
+ * 约定:只处理 identity/gzip/deflate/br;**未知或多重编码返回 null**,调用方 fail-open(原样透传)。
9
+ */
10
+ import { brotliCompressSync, brotliDecompressSync, deflateSync, gunzipSync, gzipSync, inflateSync } from "node:zlib";
11
+ /** 取首个 content-encoding(小写;identity/空 → "")。 */
12
+ export function firstEncoding(value) {
13
+ const raw = Array.isArray(value) ? value[0] : value;
14
+ const enc = (raw ?? "").split(",")[0]?.trim().toLowerCase() ?? "";
15
+ return enc === "identity" ? "" : enc;
16
+ }
17
+ /** 解码为明文;不支持/损坏 → null。 */
18
+ export function decodeBody(body, encoding) {
19
+ try {
20
+ if (encoding === "")
21
+ return body;
22
+ if (encoding === "gzip")
23
+ return gunzipSync(body);
24
+ if (encoding === "deflate")
25
+ return inflateSync(body);
26
+ if (encoding === "br")
27
+ return brotliDecompressSync(body);
28
+ return null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** 按原编码重新压缩;identity → 原样;不支持/失败 → null。 */
35
+ export function encodeBody(body, encoding) {
36
+ try {
37
+ if (encoding === "")
38
+ return body;
39
+ if (encoding === "gzip")
40
+ return gzipSync(body);
41
+ if (encoding === "deflate")
42
+ return deflateSync(body);
43
+ if (encoding === "br")
44
+ return brotliCompressSync(body);
45
+ return null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ //# sourceMappingURL=http-encoding.js.map
package/dist/join.d.ts CHANGED
@@ -62,6 +62,16 @@ export interface StartJoinOptions {
62
62
  name?: string;
63
63
  /** 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-*`,0.1.2+);有值时注入隧道→本地转发 */
64
64
  dshAuthCookieHeader?: string | null;
65
+ /**
66
+ * 心跳间隔(缺省 30s,协议 PROTOCOL.md「心跳与重连」)。
67
+ * 测试可注入毫秒级小值以快速验证超时判定。
68
+ */
69
+ heartbeatMs?: number;
70
+ /**
71
+ * 发出 PING 后等待对端任何帧的上限(缺省 10s);超时即判连接已死并主动断开。
72
+ * 测试可注入小值。
73
+ */
74
+ pongTimeoutMs?: number;
65
75
  }
66
76
  /** 可停止的 join 隧道句柄。 */
67
77
  export interface JoinHandle {
@@ -81,6 +91,18 @@ export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome
81
91
  * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
82
92
  * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
83
93
  */
94
+ /**
95
+ * 上游失败分类。
96
+ *
97
+ * 旧实现把所有 `up.on("error")` 都报成 `UPSTREAM_UNREACHABLE: dsh not reachable`,
98
+ * 于是"dsh 已接受连接、但在读请求体阶段就断开(常见于 401/413)"被伪装成部署故障,
99
+ * 掩盖真实原因(2026-09-14 实测:上传大文件得到 502 "dsh not reachable",与事实不符)。
100
+ */
101
+ export declare function classifyUpstreamFailure(code: string | undefined, responded: boolean, message?: string): {
102
+ kind: "error" | "close";
103
+ code: string;
104
+ message: string;
105
+ };
84
106
  /** JS 响应判定(content-type 含 javascript)。 */
85
107
  export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
86
108
  /**
@@ -89,6 +111,13 @@ export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
89
111
  * fail-open:未命中目标串 → 返回 null,调用方原样透传(DSH 升级不炸)。
90
112
  */
91
113
  export declare function patchLoopbackJs(body: Buffer): Buffer | null;
114
+ /**
115
+ * E2EE 包装的固定开销:内层帧 15 B 头(MAGIC4+ver1+flags1+type1+streamId4+len4)+ AEAD
116
+ * nonce 12 B + GCM tag 16 B = **43 B**。WS 消息判界必须预留它,否则"明文 ≤16 MiB"的 WS 消息
117
+ * 经 E2EE 加密后刚好超出隧道上限,`encodeFrame` 在加密发送器里抛 `ProtocolError` 打死 host
118
+ *(2026-09-14 第三审发现的 28 B 窗口,加上内层帧头实为 43 B)。
119
+ */
120
+ export declare const E2EE_FRAME_OVERHEAD: number;
92
121
  export declare function startJoin(opts: StartJoinOptions): JoinHandle;
93
122
  /** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
94
123
  export declare function join(opts: JoinOptions): Promise<void>;
package/dist/join.js CHANGED
@@ -13,10 +13,11 @@ import { request as httpRequest } from "node:http";
13
13
  import { request as httpsRequest } from "node:https";
14
14
  import { hostname as osHostname } from "node:os";
15
15
  import { WebSocket } from "ws";
16
- import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E } from "rdsh-tunnel";
16
+ import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E, MAX_PAYLOAD_LENGTH } from "rdsh-tunnel";
17
17
  import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning } from "./spawn-dsh.js";
18
18
  import { rewriteHeadersForDsh } from "./proxy.js";
19
19
  import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
20
+ import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
20
21
  import { acquireJoinLock, releaseJoinLock } from "./lock.js";
21
22
  import { responderHandshake, Aead } from "./e2ee.js";
22
23
  import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
@@ -41,6 +42,8 @@ export async function detectInsecure(hubUrl) {
41
42
  }
42
43
  }
43
44
  const HEARTBEAT_MS = 30_000;
45
+ /** 发出 PING 后等待对端任何帧的上限:超时即判连接已死并主动断开(协议 PROTOCOL.md)。 */
46
+ const PONG_TIMEOUT_MS = 10_000;
44
47
  const RECONNECT_BASE_MS = 1_000;
45
48
  const RECONNECT_MAX_MS = 60_000;
46
49
  /** hub HTTP 调用(node:https 支持自签跳过校验 —— undici fetch 不受 NODE_TLS_REJECT_UNAUTHORIZED 影响)。 */
@@ -129,6 +132,25 @@ async function register(hubUrl, joinToken, name, insecure, e2eePublicKey) {
129
132
  * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
130
133
  * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
131
134
  */
135
+ /**
136
+ * 上游失败分类。
137
+ *
138
+ * 旧实现把所有 `up.on("error")` 都报成 `UPSTREAM_UNREACHABLE: dsh not reachable`,
139
+ * 于是"dsh 已接受连接、但在读请求体阶段就断开(常见于 401/413)"被伪装成部署故障,
140
+ * 掩盖真实原因(2026-09-14 实测:上传大文件得到 502 "dsh not reachable",与事实不符)。
141
+ */
142
+ export function classifyUpstreamFailure(code, responded, message) {
143
+ const reason = code !== undefined && code !== "" ? code : (message ?? "unknown");
144
+ if (responded) {
145
+ // 客户端已拿到状态头/部分 body:此时只能发 CLOSE 表示"响应体被截断"
146
+ return { kind: "close", code: "UPSTREAM_ABORTED", message: `upstream aborted mid-response (${reason})` };
147
+ }
148
+ const unreachable = code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN" || code === "EHOSTUNREACH" || code === "ENETUNREACH";
149
+ if (unreachable) {
150
+ return { kind: "error", code: "UPSTREAM_UNREACHABLE", message: `dsh not reachable (${reason})` };
151
+ }
152
+ return { kind: "error", code: "UPSTREAM_ABORTED", message: `upstream closed before responding (${reason})` };
153
+ }
132
154
  /** JS 响应判定(content-type 含 javascript)。 */
133
155
  export function isJsContentType(headers) {
134
156
  const ct = headers["content-type"];
@@ -181,10 +203,42 @@ function gateChallengeHtml(hostName, actionPath, error, acceptLanguage) {
181
203
  `<button type="submit" style="width:100%;margin-top:12px;height:38px;border:none;border-radius:8px;background:#2563eb;color:#fff;font-size:14px;cursor:pointer">${t.submit}</button>` +
182
204
  `</form>${errHtml}</div></body></html>`);
183
205
  }
206
+ /** http 响应体的分片发送上限:单帧超过隧道 16 MiB 会让 `encodeFrame` 抛 `ProtocolError`,
207
+ * 抛点在响应回调里未捕获 ⇒ 打死 host(gateway)进程(P1,2026-09-14 复审发现)。1 MiB 远低于上限,
208
+ * 且给 E2EE 方向(密文 = 内层帧 + 28 B nonce/tag)也留足余量。 */
209
+ const DATA_FRAME_CHUNK = 1 << 20;
210
+ /**
211
+ * E2EE 包装的固定开销:内层帧 15 B 头(MAGIC4+ver1+flags1+type1+streamId4+len4)+ AEAD
212
+ * nonce 12 B + GCM tag 16 B = **43 B**。WS 消息判界必须预留它,否则"明文 ≤16 MiB"的 WS 消息
213
+ * 经 E2EE 加密后刚好超出隧道上限,`encodeFrame` 在加密发送器里抛 `ProtocolError` 打死 host
214
+ *(2026-09-14 第三审发现的 28 B 窗口,加上内层帧头实为 43 B)。
215
+ */
216
+ export const E2EE_FRAME_OVERHEAD = 15 + 12 + 16;
217
+ /** 按 DATA_FRAME_CHUNK 分片发送响应体(多 DATA 帧在 hub/浏览器侧天然拼回同一个 body)。 */
218
+ function sendChunkedBody(send, streamId, body) {
219
+ if (body.length === 0)
220
+ return;
221
+ for (let off = 0; off < body.length; off += DATA_FRAME_CHUNK) {
222
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, body.subarray(off, Math.min(off + DATA_FRAME_CHUNK, body.length))));
223
+ }
224
+ }
225
+ /**
226
+ * WS 消息转发:**一条消息 = 一帧,不能分片**(分片会破坏 host 侧的消息边界)。
227
+ * 超限(>16 MiB − E2EE_FRAME_OVERHEAD)时发 CLOSE(1009) 给客户端并返回 false,由调用方关掉上游
228
+ * —— 绝不让 `encodeFrame` 的 `ProtocolError` 逃逸到 ws 回调打死 host 进程。
229
+ */
230
+ function sendWsData(send, streamId, buf) {
231
+ if (buf.length > MAX_PAYLOAD_LENGTH - E2EE_FRAME_OVERHEAD) {
232
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 1009, message: "upstream ws message too large" })));
233
+ return false;
234
+ }
235
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
236
+ return true;
237
+ }
184
238
  /** 发送合成的 HTTP 响应帧(gateway 不触达 dsh)。 */
185
239
  function sendSyntheticHttp(send, streamId, status, headers, body) {
186
240
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: undefined, headers })));
187
- send(encodeFrame(FRAME_TYPE.DATA, streamId, body));
241
+ sendChunkedBody(send, streamId, body);
188
242
  send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
189
243
  }
190
244
  export function startJoin(opts) {
@@ -212,7 +266,13 @@ export function startJoin(opts) {
212
266
  let shuttingDown = false;
213
267
  let reconnectDelay = RECONNECT_BASE_MS;
214
268
  let heartbeat;
269
+ /** 发出 PING 后的存活死线:期间收到**任何**入站帧即撤销(PROTOCOL.md 心跳与重连)。 */
270
+ let livenessDeadline;
215
271
  let currentClient;
272
+ const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
273
+ const pongTimeoutMs = opts.pongTimeoutMs ?? PONG_TIMEOUT_MS;
274
+ /** 日志用人类可读单位:生产 30000ms → "30s";测试注入的毫秒值原样显示。 */
275
+ const heartbeatLabel = heartbeatMs % 1000 === 0 ? `${heartbeatMs / 1000}s` : `${heartbeatMs}ms`;
216
276
  /** 发送一个隧道帧(走当前隧道 WS;flags 由调用方在 encodeFrame 时给定)。 */
217
277
  function sendTunnelFrame(frame) {
218
278
  if (currentClient !== undefined && currentClient.readyState === currentClient.OPEN) {
@@ -225,6 +285,8 @@ export function startJoin(opts) {
225
285
  const wsStreams = new Map();
226
286
  // gate 未过、等待 code 提交的 http 流(OPEN 后缓冲 DATA,CLOSE 时校验)
227
287
  const gatedHttp = new Map();
288
+ /** 已报告过"loopback 补丁未命中"的路径(每个路径只报一次,避免日志刷屏)。 */
289
+ const patchMissReported = new Set();
228
290
  /** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
229
291
  function gateCookie(headers) {
230
292
  const ck = headers["cookie"];
@@ -318,7 +380,16 @@ export function startJoin(opts) {
318
380
  : Buffer.isBuffer(data)
319
381
  ? data
320
382
  : Buffer.from(data);
321
- send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
383
+ if (!sendWsData(send, streamId, buf)) {
384
+ // 超限:已发 CLOSE(1009) 给客户端,这里关掉上游 dsh 连接,绝不让异常逃逸
385
+ console.error(`[join] upstream ws message too large (${buf.length}B), closing stream ${streamId}`);
386
+ try {
387
+ upstream.close(1009, "message too big");
388
+ }
389
+ catch {
390
+ /* 已关闭 */
391
+ }
392
+ }
322
393
  });
323
394
  const cleanup = () => {
324
395
  wsStreams.delete(streamId);
@@ -377,6 +448,8 @@ export function startJoin(opts) {
377
448
  return;
378
449
  }
379
450
  const streamId = frame.streamId;
451
+ /** 响应头是否已发给客户端(决定失败时能回 ERROR 还是只能用 CLOSE 表示截断)。 */
452
+ let responded = false;
380
453
  const up = httpRequest({
381
454
  host: opts.target.host,
382
455
  port: opts.target.port,
@@ -384,22 +457,17 @@ export function startJoin(opts) {
384
457
  method,
385
458
  headers: rewriteHeadersForDsh(headers, opts.target, dshAuthCookie),
386
459
  }, (upRes) => {
387
- send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
388
- kind: "http",
389
- status: upRes.statusCode ?? 502,
390
- reason: upRes.statusMessage,
391
- headers: normalizeRespHeaders(upRes.headers),
392
- })));
393
- if (dio?.jsPatch?.() === true && isJsContentType(upRes.headers)) {
394
- // 最小 patch:E2EE 流上的 JS 响应,把前端 isLoopback 判定替换为 true
395
- // (fail-open:未命中 → 原样透传;DSH 升级不炸)
396
- const chunks = [];
397
- upRes.on("data", (chunk) => chunks.push(chunk));
460
+ responded = true;
461
+ const status = upRes.statusCode ?? 502;
462
+ const baseHeaders = normalizeRespHeaders(upRes.headers);
463
+ const wantsPatch = dio?.jsPatch?.() === true && isJsContentType(upRes.headers);
464
+ // 非 JS / 未开补丁:保持原流式路径(OPEN 立即发,body 边到边发)
465
+ if (!wantsPatch) {
466
+ send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: baseHeaders })));
467
+ upRes.on("data", (chunk) => {
468
+ sendChunkedBody(send, streamId, chunk);
469
+ });
398
470
  upRes.on("end", () => {
399
- const body = Buffer.concat(chunks);
400
- const patched = patchLoopbackJs(body);
401
- // fail-open:未命中也必须原样发 body(否则空响应白屏)
402
- send(encodeFrame(FRAME_TYPE.DATA, streamId, patched !== null ? patched : body));
403
471
  send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
404
472
  httpStreams.delete(streamId);
405
473
  });
@@ -409,10 +477,45 @@ export function startJoin(opts) {
409
477
  });
410
478
  return;
411
479
  }
412
- upRes.on("data", (chunk) => {
413
- send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
414
- });
480
+ // JS + 补丁:**必须先缓冲/处理再发 OPEN** —— patch 与"按原编码重压"都会改变 body 长度,
481
+ // 而 OPEN 帧里的 content-length 必须与实际字节一致(否则浏览器截断或一直等)。
482
+ // dsh 会按 accept-encoding 压缩 JS(gzip 等),所以这里必须先解码再补丁、再按原编码重压。
483
+ const chunks = [];
484
+ upRes.on("data", (chunk) => chunks.push(chunk));
415
485
  upRes.on("end", () => {
486
+ const raw = Buffer.concat(chunks);
487
+ const encoding = firstEncoding(upRes.headers["content-encoding"]);
488
+ const decoded = decodeBody(raw, encoding);
489
+ let body = raw;
490
+ let outHeaders = baseHeaders;
491
+ let hit = false;
492
+ if (decoded !== null) {
493
+ const patched = patchLoopbackJs(decoded);
494
+ const recoded = patched === null ? null : encodeBody(patched, encoding);
495
+ if (recoded !== null) {
496
+ body = recoded;
497
+ hit = true;
498
+ outHeaders = { ...baseHeaders, "content-length": String(body.length) };
499
+ delete outHeaders["transfer-encoding"];
500
+ // 不能沿用上游的 `immutable` / 长 max-age:补丁改了 body 但 URL(rev) 由上游内容决定,
501
+ // 浏览器会把"旧的未补丁 bundle"缓存很久 ⇒ 修复无法生效(2026-09-14 实测踩到)。
502
+ outHeaders["cache-control"] = "public, max-age=300";
503
+ }
504
+ }
505
+ // 未命中必须留痕(每个路径一次):fail-open 是刻意设计,但"补丁从未生效"不能静默
506
+ //(2026-09-14 gzip 事故正是因为没有这条日志而排查了很久)
507
+ if (!hit) {
508
+ const key = (path.split("?")[0] ?? path).slice(0, 120);
509
+ if (!patchMissReported.has(key)) {
510
+ patchMissReported.add(key);
511
+ console.log(`[patch] miss: ${key} (${raw.length}B, content-encoding=${encoding === "" ? "identity" : encoding})`);
512
+ }
513
+ }
514
+ else if (process.env.RDSH_DEBUG_PATCH === "1") {
515
+ console.log(`[patch] hit: ${path.slice(0, 120)} (${raw.length}B → ${body.length}B, ${encoding === "" ? "identity" : encoding})`);
516
+ }
517
+ send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
518
+ sendChunkedBody(send, streamId, body);
416
519
  send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
417
520
  httpStreams.delete(streamId);
418
521
  });
@@ -421,8 +524,10 @@ export function startJoin(opts) {
421
524
  httpStreams.delete(streamId);
422
525
  });
423
526
  });
424
- up.on("error", () => {
425
- send(encodeFrame(FRAME_TYPE.ERROR, streamId, jsonPayload({ code: "UPSTREAM_UNREACHABLE", message: "dsh not reachable" })));
527
+ up.on("error", (err) => {
528
+ const outcome = classifyUpstreamFailure(err.code, responded, err.message);
529
+ const type = outcome.kind === "close" ? FRAME_TYPE.CLOSE : FRAME_TYPE.ERROR;
530
+ send(encodeFrame(type, streamId, jsonPayload({ code: outcome.code, message: outcome.message })));
426
531
  httpStreams.delete(streamId);
427
532
  });
428
533
  httpStreams.set(streamId, { up });
@@ -503,10 +608,25 @@ export function startJoin(opts) {
503
608
  function startRawStream(streamId) {
504
609
  const inner = makeInnerDispatcher((frame) => {
505
610
  const raw = rawStreams.get(streamId);
506
- if (raw?.encryptor !== null && raw?.encryptor !== undefined) {
611
+ if (raw?.encryptor === null || raw?.encryptor === undefined)
612
+ return;
613
+ try {
507
614
  const ct = raw.encryptor.encrypt(frame, Buffer.alloc(0));
615
+ if (ct.length > MAX_PAYLOAD_LENGTH) {
616
+ // 结构兜底:内层帧经 E2EE 包装(+43 B)后超限。绝不能让 encodeFrame 的 ProtocolError
617
+ // 逃逸;给该内层流回一个 ERROR(小帧,加密后必然放得下),消费方据此干净报错。
618
+ const innerId = frame.length >= 11 ? frame.readUInt32BE(7) : 0;
619
+ const errCt = raw.encryptor.encrypt(encodeFrame(FRAME_TYPE.ERROR, innerId, jsonPayload({ code: "FRAME_TOO_LARGE", message: "e2ee frame too large" })), Buffer.alloc(0));
620
+ console.error(`[join] e2ee frame too large (${ct.length}B) for inner stream ${innerId}`);
621
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, errCt, FLAG_E2E));
622
+ return;
623
+ }
508
624
  sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, ct, FLAG_E2E));
509
625
  }
626
+ catch (err) {
627
+ // 最后一道:任何加密/封帧异常只记录,绝不重抛打死 host
628
+ console.error(`[join] e2ee send failed: ${err instanceof Error ? err.message : String(err)}`);
629
+ }
510
630
  }, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
511
631
  rawStreams.set(streamId, {
512
632
  handshakeBuf: Buffer.alloc(0),
@@ -597,6 +717,10 @@ export function startJoin(opts) {
597
717
  clearInterval(heartbeat);
598
718
  heartbeat = undefined;
599
719
  }
720
+ if (livenessDeadline !== undefined) {
721
+ clearTimeout(livenessDeadline);
722
+ livenessDeadline = undefined;
723
+ }
600
724
  plainDispatcher.cleanup();
601
725
  for (const raw of rawStreams.values())
602
726
  raw.inner.cleanup();
@@ -626,18 +750,38 @@ export function startJoin(opts) {
626
750
  client.on("open", () => {
627
751
  reconnectDelay = RECONNECT_BASE_MS;
628
752
  setState("connected");
629
- log("info", "tunnel established (heartbeat 30s)");
753
+ log("info", `tunnel established (heartbeat ${heartbeatLabel})`);
630
754
  if (heartbeat !== undefined)
631
755
  clearInterval(heartbeat);
632
756
  heartbeat = setInterval(() => {
633
757
  if (client.readyState === client.OPEN) {
634
758
  client.send(encodeFrame(FRAME_TYPE.PING, 0, jsonPayload({ ts: Date.now() })));
759
+ // 发出 PING 后武装死线:pongTimeoutMs 内收到任何入站帧即撤销(下方 message 处理)。
760
+ // 只在**没有未决死线**时武装——上一发 PING 未获回应时原死线继续计时,不重置。
761
+ if (livenessDeadline === undefined) {
762
+ livenessDeadline = setTimeout(() => {
763
+ livenessDeadline = undefined;
764
+ log("info", `heartbeat timeout — no frame from hub within ${pongTimeoutMs}ms; reconnecting`);
765
+ try {
766
+ client.terminate();
767
+ }
768
+ catch {
769
+ /* 已关闭 */
770
+ }
771
+ }, pongTimeoutMs);
772
+ livenessDeadline.unref?.();
773
+ }
635
774
  }
636
- }, HEARTBEAT_MS);
775
+ }, heartbeatMs);
637
776
  });
638
777
  client.on("message", (data, isBinary) => {
639
778
  if (!isBinary)
640
779
  return;
780
+ // 任何入站帧都是存活证据 → 撤销死线(不限于 PONG)
781
+ if (livenessDeadline !== undefined) {
782
+ clearTimeout(livenessDeadline);
783
+ livenessDeadline = undefined;
784
+ }
641
785
  const chunk = Array.isArray(data)
642
786
  ? Buffer.concat(data)
643
787
  : Buffer.isBuffer(data)
package/dist/proxy.d.ts CHANGED
@@ -16,6 +16,12 @@ export interface ForwardOptions {
16
16
  * 提供时合并进每个转发/升级请求的 cookie 头,穿透 dsh 0.1.2 认证层。
17
17
  */
18
18
  authCookie?: string | null;
19
+ /**
20
+ * JS 响应补丁(LAN 路径;隧道路径见 `join.ts` 的 `patchLoopbackJs`)。
21
+ * 命中 content-type 含 `javascript` 时缓冲后调用;返回 `null` = 原样透传(fail-open)。
22
+ * 用途:把 DSH 前端的 `isLoopback` 判定替换为 `true` —— DSH 的设置/凭据界面只对 loopback 开放。
23
+ */
24
+ jsPatch?: (body: Buffer) => Buffer | null;
19
25
  }
20
26
  /**
21
27
  * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
package/dist/proxy.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { request } from "node:http";
8
8
  import { WebSocket, WebSocketServer } from "ws";
9
+ import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
9
10
  /**
10
11
  * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
11
12
  * - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
@@ -80,6 +81,37 @@ export function forwardHttp(req, res, target, opts) {
80
81
  });
81
82
  return;
82
83
  }
84
+ const canPatchJs = opts?.jsPatch !== undefined && typeof contentType === "string" && /javascript/i.test(contentType);
85
+ if (canPatchJs) {
86
+ // JS bundle 需缓冲后才能替换目标串;先按 content-encoding 解码(dsh 会 gzip JS),
87
+ // 补丁后按原编码重压(fail-open:未命中/不支持编码 → 原字节原头透传)
88
+ void (async () => {
89
+ const chunks = [];
90
+ for await (const chunk of upstreamRes)
91
+ chunks.push(chunk);
92
+ const raw = Buffer.concat(chunks);
93
+ const encoding = firstEncoding(upstreamRes.headers["content-encoding"]);
94
+ const decoded = decodeBody(raw, encoding);
95
+ const patched = decoded === null ? null : opts.jsPatch(decoded);
96
+ const recoded = patched === null ? null : encodeBody(patched, encoding);
97
+ const outBody = recoded ?? raw;
98
+ const outHeaders = { ...upstreamRes.headers };
99
+ if (recoded !== null) {
100
+ outHeaders["content-length"] = String(outBody.length);
101
+ delete outHeaders["transfer-encoding"];
102
+ // 同 join.ts:补丁后不可沿用 immutable/长 max-age(URL 不变,旧未补丁体可能被长期缓存)
103
+ outHeaders["cache-control"] = "public, max-age=300";
104
+ }
105
+ delete outHeaders.connection;
106
+ res.writeHead(upstreamRes.statusCode ?? 200, outHeaders);
107
+ res.end(outBody);
108
+ })().catch(() => {
109
+ if (!res.headersSent)
110
+ res.writeHead(502, { "content-type": "text/plain" });
111
+ res.end("bad gateway");
112
+ });
113
+ return;
114
+ }
83
115
  res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
84
116
  upstreamRes.pipe(res);
85
117
  });
package/dist/serve.js CHANGED
@@ -62,6 +62,7 @@ export async function serve(opts) {
62
62
  authVersion: config.auth.version,
63
63
  allowFrom: config.allowFrom,
64
64
  behindProxy: config.behindProxy,
65
+ dshUiCompat: config.dshUiCompat,
65
66
  tlsMaterial,
66
67
  userManager,
67
68
  configPath,
package/dist/server.d.ts CHANGED
@@ -26,6 +26,13 @@ export interface GatewayOptions {
26
26
  dshPort: number;
27
27
  /** 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-*`,0.1.2+);有值时注入转发 */
28
28
  dshAuthCookieHeader?: string | null;
29
+ /**
30
+ * DSH UI 兼容(LAN 路径):`trustPairedAsLoopback` 默认 true —— 把已通过本机配对/登录的
31
+ * LAN 会话视同 loopback,使 DSH 的设置与"持久化凭据"界面可用(否则 LAN 用户无法在界面里输入 API key)。
32
+ */
33
+ dshUiCompat?: {
34
+ trustPairedAsLoopback?: boolean;
35
+ };
29
36
  /** true = 启动时重置会话密钥(全部会话失效) */
30
37
  reset?: boolean;
31
38
  /** 会话密钥目录(默认 ~/.rdsh;测试可注入临时目录) */
package/dist/server.js CHANGED
@@ -18,6 +18,7 @@ import { basename, dirname } from "node:path";
18
18
  import { SessionManager, sessionTokenFromCookie } from "./session.js";
19
19
  import { PairManager } from "./pair.js";
20
20
  import { createUpgradeProxy, forwardHttp } from "./proxy.js";
21
+ import { patchLoopbackJs } from "./join.js";
21
22
  import { pairPageHtml } from "./pair-page.js";
22
23
  import { loginPageHtml } from "./login-page.js";
23
24
  import { SECURE_CONTEXT_POLYFILL } from "./secure-context-polyfill.js";
@@ -70,6 +71,7 @@ export async function startGateway(opts) {
70
71
  authMode,
71
72
  behindProxy,
72
73
  dshAuthCookieHeader: opts.dshAuthCookieHeader ?? null,
74
+ trustPairedAsLoopback: opts.dshUiCompat?.trustPairedAsLoopback !== false,
73
75
  getVersion: () => currentVersion,
74
76
  isAllowed: (ip) => currentAllowFrom.length === 0 || ipInCidrs(ip, currentAllowFrom),
75
77
  userManager: opts.userManager,
@@ -156,7 +158,11 @@ async function handleHttp(req, res, ctx, loginLimiter) {
156
158
  return;
157
159
  }
158
160
  if (ctx.authMode === "none") {
159
- forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL, authCookie: ctx.dshAuthCookieHeader });
161
+ forwardHttp(req, res, ctx.target, {
162
+ htmlInject: SECURE_CONTEXT_POLYFILL,
163
+ authCookie: ctx.dshAuthCookieHeader,
164
+ jsPatch: ctx.trustPairedAsLoopback ? patchLoopbackJs : undefined,
165
+ });
160
166
  return;
161
167
  }
162
168
  if (!hasValidSession(req, ctx)) {
@@ -164,7 +170,13 @@ async function handleHttp(req, res, ctx, loginLimiter) {
164
170
  res.end();
165
171
  return;
166
172
  }
167
- forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL, authCookie: ctx.dshAuthCookieHeader });
173
+ forwardHttp(req, res, ctx.target, {
174
+ htmlInject: SECURE_CONTEXT_POLYFILL,
175
+ authCookie: ctx.dshAuthCookieHeader,
176
+ // 会话分支同样要打 loopback 补丁:LAN 没有 E2EE shim,设置/API key 唯一依赖这个补丁;
177
+ // 旧实现只在 authMode==="none" 分支传了 jsPatch ⇒ 配对/登录模式下设置页打不开(2026-09-14 复审发现)
178
+ jsPatch: ctx.trustPairedAsLoopback ? patchLoopbackJs : undefined,
179
+ });
168
180
  }
169
181
  async function handlePairPost(req, res, ctx) {
170
182
  const ip = req.socket.remoteAddress ?? "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
5
5
  "license": "MIT",
6
6
  "type": "module",