rdsh-gateway 0.11.0 → 0.11.2

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/join.d.ts CHANGED
@@ -36,6 +36,8 @@ export interface JoinOptions {
36
36
  host?: string;
37
37
  port?: number;
38
38
  };
39
+ /** host E2EE 静态密钥对目录(缺省 ~/.rdsh;**仅供测试注入**,生产不要传 —— 见 e2ee-key-store.ts) */
40
+ e2eeKeyDir?: string;
39
41
  }
40
42
  /** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
41
43
  export interface RegisterOutcome {
@@ -65,6 +67,8 @@ export interface StartJoinOptions {
65
67
  role: JoinLockRole;
66
68
  /** 锁文件路径(缺省 ~/.rdsh/join.lock;测试可注入临时路径) */
67
69
  lockPath?: string;
70
+ /** E2EE 静态密钥对目录(缺省 ~/.rdsh;**仅供测试注入**,见 e2ee-key-store.ts) */
71
+ e2eeKeyDir?: string;
68
72
  hooks?: JoinHooks;
69
73
  /** DSH UI 兼容(缺省 trustE2EEAsLoopback=true;false 关闭 JS patch) */
70
74
  dshUiCompat?: {
package/dist/join.js CHANGED
@@ -110,7 +110,7 @@ export async function registerJoin(opts) {
110
110
  let token;
111
111
  if (opts.token !== undefined) {
112
112
  // --token = join token(或旧 host token)→ register 端点换 host token
113
- const e2eeKeyPair = loadOrCreateE2eeKeyPair();
113
+ const e2eeKeyPair = loadOrCreateE2eeKeyPair(opts.e2eeKeyDir);
114
114
  const { hostToken } = await register(opts.hubUrl, opts.token, name, insecure, e2eeKeyPair.publicRaw.toString("base64url"));
115
115
  token = hostToken;
116
116
  persistToken(opts.hubUrl, token);
@@ -273,6 +273,19 @@ export function startJoin(opts) {
273
273
  const uiCompat = { trustE2EEAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false };
274
274
  // 访问口令(feature 15):可变引用 → setAccessCode 运行中切换;null = gate off
275
275
  const gate = { accessCode: opts.gateway?.accessCode ?? null };
276
+ /**
277
+ * raw 门禁:页面是否已授权。**实例级共享**(原为 per-raw-stream)。
278
+ *
279
+ * authorize 证明的是"**页面**知道口令",不是"某条 raw 流知道"——而页面注入脚本每页只跑一次
280
+ * (`direct.ts` 的 `__rdshDirectBootstrapped` 守卫)。原先声明在 `makeInnerDispatcher()` 内,
281
+ * 该方法**每条 raw 流都会调用一次**(`startRawStream`),于是新建流标志被重置为 false、
282
+ * 页面又不会重新 authorize ⇒ 该流上所有请求被 CLOSE 403 `raw stream not authorized`。
283
+ *
284
+ * 放在 `startJoin` 作用域 = **每个网关进程一份**:同一进程内共享(**含隧道重连**——`connect()`
285
+ * 嵌在 `startJoin` 内、重连不重建该作用域),跨进程仍隔离。
286
+ * 2026-10-04 修复,详见 `doc/fix/20261004-raw-authorize-per-stream/`。
287
+ */
288
+ let rawAuthorized = false;
276
289
  // raw 门禁 + 候选(24-direct-first):提供 direct 回调即启用候选端点;raw 门禁(authorize)仅设口令时生效
277
290
  const rawGate = opts.direct !== undefined
278
291
  ? { candidates: opts.direct.candidates, mintTicket: opts.direct.mintTicket }
@@ -338,8 +351,6 @@ export function startJoin(opts) {
338
351
  const patchSkipped = new Set();
339
352
  /** rdsh WebView API 是否已注入过(每个进程只打一次日志,避免页面加载刷屏)。 */
340
353
  let adapterInjectionLogged = false;
341
- /** raw 门禁:当前 raw 流是否已被页面授权(动态判定:见 handleOpen 的条件,含运行中 gate 开关) */
342
- let rawAuthorized = false;
343
354
  /** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
344
355
  function gateCookie(headers) {
345
356
  const ck = headers["cookie"];
@@ -470,27 +481,19 @@ export function startJoin(opts) {
470
481
  send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
471
482
  return;
472
483
  }
473
- // ---- raw 门禁 + 候选端点(仅 raw inner dispatcher:dio.rawGate 定义时)----
474
- if (dio?.rawGate !== undefined) {
475
- // raw 门禁(仅设口令时):授权前只接受 authorize 请求,否则 fail-closed 关流(修 raw 绕过,R7)
476
- if (gate.accessCode !== null && !rawAuthorized) {
477
- if (path.startsWith("/__rdsh/authorize")) {
478
- const token = new URL(path, "http://rdsh.local").searchParams.get("token") ?? "";
479
- if (token !== "" && verifyGateCookie(gate.accessCode, token)) {
480
- rawAuthorized = true;
481
- sendSyntheticHttp(send, frame.streamId, 204, {}, Buffer.alloc(0));
482
- return;
483
- }
484
+ // ---- raw 门禁(仅 raw inner dispatcher:dio.rawGate 定义时)----
485
+ if (dio?.rawGate !== undefined && gate.accessCode !== null && !rawAuthorized) {
486
+ // 授权前只接受 authorize 请求,否则 fail-closed 关流(修 raw 绕过,R7)
487
+ if (path.startsWith("/__rdsh/authorize")) {
488
+ const token = new URL(path, "http://rdsh.local").searchParams.get("token") ?? "";
489
+ if (token !== "" && verifyGateCookie(gate.accessCode, token)) {
490
+ rawAuthorized = true;
491
+ sendSyntheticHttp(send, frame.streamId, 204, {}, Buffer.alloc(0));
492
+ return;
484
493
  }
485
- send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 403, message: "raw stream not authorized" })));
486
- return;
487
- }
488
- // 候选 + 一次性直连票(无论是否设口令都提供;经 E2EE,hub 不可见,R3/R4)
489
- if (path === "/__rdsh/direct-candidates" || path.startsWith("/__rdsh/direct-candidates?")) {
490
- const body = Buffer.from(JSON.stringify({ candidates: dio.rawGate.candidates(), ticket: dio.rawGate.mintTicket() }));
491
- sendSyntheticHttp(send, frame.streamId, 200, { "content-type": "application/json" }, body);
492
- return;
493
494
  }
495
+ send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 403, message: "raw stream not authorized" })));
496
+ return;
494
497
  }
495
498
  // ---- 访问口令 gate(仅 plain dispatcher:dio.gate=true 且已设 accessCode)----
496
499
  if (dio?.gate === true && gate.accessCode !== null) {
@@ -514,6 +517,21 @@ export function startJoin(opts) {
514
517
  return;
515
518
  }
516
519
  }
520
+ // ---- 直连候选 + 一次性票(**raw 与 plain 两条路径都要提供**)----
521
+ // 位置在**两道门禁之后**:raw 已过 rawGate,plain 已过访问口令(未过则上面已 return),
522
+ // 因此既不会泄露内网候选地址、也不会在未授权时签发直连票(R3/R4/R7)。
523
+ // 为什么 plain 也要提供:plain 页同样注入了直连 bootstrap(见 plainDispatcher 的 htmlInject),
524
+ // 缺了这个端点,页面里的 fetch("/__rdsh/direct-candidates") 会被转发给 dsh web → 404,
525
+ // 于是 window.__rdshDirectInfo 永不赋值 —— 即 2026-10-04 真机 "no __rdshDirectInfo" 的根因。
526
+ if (dio?.directCandidates !== undefined &&
527
+ (path === "/__rdsh/direct-candidates" || path.startsWith("/__rdsh/direct-candidates?"))) {
528
+ const body = Buffer.from(JSON.stringify({
529
+ candidates: dio.directCandidates.candidates(),
530
+ ticket: dio.directCandidates.mintTicket(),
531
+ }));
532
+ sendSyntheticHttp(send, frame.streamId, 200, { "content-type": "application/json" }, body);
533
+ return;
534
+ }
517
535
  if (kind === "ws") {
518
536
  openWsStream(frame.streamId, path, headers);
519
537
  return;
@@ -552,7 +570,11 @@ export function startJoin(opts) {
552
570
  delete outHeaders["transfer-encoding"];
553
571
  if (!adapterInjectionLogged) {
554
572
  adapterInjectionLogged = true;
555
- console.log("[rdsh] rdsh webview api injected (window.__rdshWebViewApi v1)");
573
+ // 按**实际注入的内容**打日志:raw 路径只注入直连 bootstrap,不含 WebView API。
574
+ // (旧文案一律说 "webview api injected",在 raw 路径上是假的,曾误导排查。)
575
+ console.log(inject.includes("__rdshWebViewApi")
576
+ ? "[rdsh] html inject: webview api + direct bootstrap (plain path)"
577
+ : "[rdsh] html inject: direct bootstrap only (raw/E2EE path)");
556
578
  }
557
579
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: upRes.statusMessage, headers: outHeaders })));
558
580
  sendChunkedBody(send, streamId, Buffer.from(html));
@@ -720,6 +742,8 @@ export function startJoin(opts) {
720
742
  const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, {
721
743
  jsPatch: () => uiCompat.trustE2EEAsLoopback,
722
744
  gate: true,
745
+ // plain 页也要能取直连候选:它同样注入了直连 bootstrap,端点由下方统一提供
746
+ directCandidates: rawGate,
723
747
  htmlInject: () => {
724
748
  // 直连 bootstrap 始终注入(join 模式);页面授权 token 仅设口令时生成(无口令则跳过 raw 授权、只取候选)
725
749
  return RDSH_WEBVIEW_API + pageAuthorizeScript(gate.accessCode);
@@ -728,7 +752,7 @@ export function startJoin(opts) {
728
752
  // host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
729
753
  let hostE2eeKeypair;
730
754
  try {
731
- hostE2eeKeypair = loadOrCreateE2eeKeyPair();
755
+ hostE2eeKeypair = loadOrCreateE2eeKeyPair(opts.e2eeKeyDir);
732
756
  }
733
757
  catch (err) {
734
758
  releaseLockAndRethrow(err);
@@ -759,6 +783,7 @@ export function startJoin(opts) {
759
783
  }, {
760
784
  jsPatch: () => uiCompat.trustE2EEAsLoopback,
761
785
  rawGate,
786
+ directCandidates: rawGate,
762
787
  // raw 门禁的解锁脚本:与 plain 同一来源(pageAuthorizeScript)。
763
788
  // raw 流不转发 cookie,这里是页面唯一的授权入口;缺了它 raw 门禁必然 fail-closed。
764
789
  htmlInject: () => pageAuthorizeScript(gate.accessCode),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
5
5
  "license": "MIT",
6
6
  "type": "module",