rdsh-gateway 0.8.3 → 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
@@ -91,6 +91,18 @@ export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome
91
91
  * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
92
92
  * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
93
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
+ };
94
106
  /** JS 响应判定(content-type 含 javascript)。 */
95
107
  export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
96
108
  /**
@@ -99,6 +111,13 @@ export declare function isJsContentType(headers: IncomingHttpHeaders): boolean;
99
111
  * fail-open:未命中目标串 → 返回 null,调用方原样透传(DSH 升级不炸)。
100
112
  */
101
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;
102
121
  export declare function startJoin(opts: StartJoinOptions): JoinHandle;
103
122
  /** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
104
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";
@@ -131,6 +132,25 @@ async function register(hubUrl, joinToken, name, insecure, e2eePublicKey) {
131
132
  * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
132
133
  * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
133
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
+ }
134
154
  /** JS 响应判定(content-type 含 javascript)。 */
135
155
  export function isJsContentType(headers) {
136
156
  const ct = headers["content-type"];
@@ -183,10 +203,42 @@ function gateChallengeHtml(hostName, actionPath, error, acceptLanguage) {
183
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>` +
184
204
  `</form>${errHtml}</div></body></html>`);
185
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
+ }
186
238
  /** 发送合成的 HTTP 响应帧(gateway 不触达 dsh)。 */
187
239
  function sendSyntheticHttp(send, streamId, status, headers, body) {
188
240
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: undefined, headers })));
189
- send(encodeFrame(FRAME_TYPE.DATA, streamId, body));
241
+ sendChunkedBody(send, streamId, body);
190
242
  send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
191
243
  }
192
244
  export function startJoin(opts) {
@@ -233,6 +285,8 @@ export function startJoin(opts) {
233
285
  const wsStreams = new Map();
234
286
  // gate 未过、等待 code 提交的 http 流(OPEN 后缓冲 DATA,CLOSE 时校验)
235
287
  const gatedHttp = new Map();
288
+ /** 已报告过"loopback 补丁未命中"的路径(每个路径只报一次,避免日志刷屏)。 */
289
+ const patchMissReported = new Set();
236
290
  /** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
237
291
  function gateCookie(headers) {
238
292
  const ck = headers["cookie"];
@@ -326,7 +380,16 @@ export function startJoin(opts) {
326
380
  : Buffer.isBuffer(data)
327
381
  ? data
328
382
  : Buffer.from(data);
329
- 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
+ }
330
393
  });
331
394
  const cleanup = () => {
332
395
  wsStreams.delete(streamId);
@@ -385,6 +448,8 @@ export function startJoin(opts) {
385
448
  return;
386
449
  }
387
450
  const streamId = frame.streamId;
451
+ /** 响应头是否已发给客户端(决定失败时能回 ERROR 还是只能用 CLOSE 表示截断)。 */
452
+ let responded = false;
388
453
  const up = httpRequest({
389
454
  host: opts.target.host,
390
455
  port: opts.target.port,
@@ -392,22 +457,17 @@ export function startJoin(opts) {
392
457
  method,
393
458
  headers: rewriteHeadersForDsh(headers, opts.target, dshAuthCookie),
394
459
  }, (upRes) => {
395
- send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
396
- kind: "http",
397
- status: upRes.statusCode ?? 502,
398
- reason: upRes.statusMessage,
399
- headers: normalizeRespHeaders(upRes.headers),
400
- })));
401
- if (dio?.jsPatch?.() === true && isJsContentType(upRes.headers)) {
402
- // 最小 patch:E2EE 流上的 JS 响应,把前端 isLoopback 判定替换为 true
403
- // (fail-open:未命中 → 原样透传;DSH 升级不炸)
404
- const chunks = [];
405
- 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
+ });
406
470
  upRes.on("end", () => {
407
- const body = Buffer.concat(chunks);
408
- const patched = patchLoopbackJs(body);
409
- // fail-open:未命中也必须原样发 body(否则空响应白屏)
410
- send(encodeFrame(FRAME_TYPE.DATA, streamId, patched !== null ? patched : body));
411
471
  send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
412
472
  httpStreams.delete(streamId);
413
473
  });
@@ -417,10 +477,45 @@ export function startJoin(opts) {
417
477
  });
418
478
  return;
419
479
  }
420
- upRes.on("data", (chunk) => {
421
- send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
422
- });
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));
423
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);
424
519
  send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
425
520
  httpStreams.delete(streamId);
426
521
  });
@@ -429,8 +524,10 @@ export function startJoin(opts) {
429
524
  httpStreams.delete(streamId);
430
525
  });
431
526
  });
432
- up.on("error", () => {
433
- 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 })));
434
531
  httpStreams.delete(streamId);
435
532
  });
436
533
  httpStreams.set(streamId, { up });
@@ -511,10 +608,25 @@ export function startJoin(opts) {
511
608
  function startRawStream(streamId) {
512
609
  const inner = makeInnerDispatcher((frame) => {
513
610
  const raw = rawStreams.get(streamId);
514
- if (raw?.encryptor !== null && raw?.encryptor !== undefined) {
611
+ if (raw?.encryptor === null || raw?.encryptor === undefined)
612
+ return;
613
+ try {
515
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
+ }
516
624
  sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, ct, FLAG_E2E));
517
625
  }
626
+ catch (err) {
627
+ // 最后一道:任何加密/封帧异常只记录,绝不重抛打死 host
628
+ console.error(`[join] e2ee send failed: ${err instanceof Error ? err.message : String(err)}`);
629
+ }
518
630
  }, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
519
631
  rawStreams.set(streamId, {
520
632
  handshakeBuf: Buffer.alloc(0),
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.3",
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",