rdsh-gateway 0.7.0 → 0.8.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.
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export type { SessionPayload } from "./session.ts";
10
10
  export { PairManager } from "./pair.ts";
11
11
  export { startGateway } from "./server.ts";
12
12
  export type { GatewayOptions, RunningGateway } from "./server.ts";
13
- export { findDsh, spawnDsh } from "./spawn-dsh.ts";
13
+ export { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.ts";
14
14
  export type { SpawnedDsh } from "./spawn-dsh.ts";
15
15
  export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.ts";
16
16
  export type { ProxyTarget } from "./proxy.ts";
@@ -24,7 +24,7 @@ export { loginPageHtml } from "./login-page.ts";
24
24
  export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
25
25
  export type { ServiceSpec } from "./service.ts";
26
26
  export declare const NAME = "rdsh-gateway";
27
- export { join, startJoin, registerJoin, detectInsecure, selfRevoke } from "./join.ts";
27
+ export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.ts";
28
28
  export type { JoinOptions, RegisterOutcome, JoinState, JoinHooks, StartJoinOptions, JoinHandle } from "./join.ts";
29
29
  export { readPersistedToken, clearPersistedToken } from "./token-store.ts";
30
30
  export { acquireJoinLock, releaseJoinLock, readJoinLock, JOIN_LOCK_PATH } from "./lock.ts";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { serve } from "./serve.js";
7
7
  export { SessionManager, SESSION_COOKIE, sessionTokenFromCookie } from "./session.js";
8
8
  export { PairManager } from "./pair.js";
9
9
  export { startGateway } from "./server.js";
10
- export { findDsh, spawnDsh } from "./spawn-dsh.js";
10
+ export { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.js";
11
11
  export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.js";
12
12
  export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, DEFAULT_HOST_CONFIG_PATH } from "./config.js";
13
13
  export { hashPassword, verifyPassword, UserManager } from "./auth.js";
@@ -16,7 +16,7 @@ export { loadTls } from "./tls.js";
16
16
  export { loginPageHtml } from "./login-page.js";
17
17
  export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.js";
18
18
  export const NAME = "rdsh-gateway";
19
- export { join, startJoin, registerJoin, detectInsecure, selfRevoke } from "./join.js";
19
+ export { join, startJoin, registerJoin, detectInsecure, selfRevoke, patchLoopbackJs } from "./join.js";
20
20
  export { readPersistedToken, clearPersistedToken } from "./token-store.js";
21
21
  export { acquireJoinLock, releaseJoinLock, readJoinLock, JOIN_LOCK_PATH } from "./lock.js";
22
22
  //# sourceMappingURL=index.js.map
package/dist/join.d.ts CHANGED
@@ -60,6 +60,8 @@ export interface StartJoinOptions {
60
60
  };
61
61
  /** 主机名(challenge 页展示;缺省「本主机」) */
62
62
  name?: string;
63
+ /** 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-*`,0.1.2+);有值时注入隧道→本地转发 */
64
+ dshAuthCookieHeader?: string | null;
63
65
  }
64
66
  /** 可停止的 join 隧道句柄。 */
65
67
  export interface JoinHandle {
package/dist/join.js CHANGED
@@ -14,7 +14,7 @@ import { request as httpsRequest } from "node:https";
14
14
  import { hostname as osHostname } from "node:os";
15
15
  import { WebSocket } from "ws";
16
16
  import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E } from "rdsh-tunnel";
17
- import { findDsh, spawnDsh } from "./spawn-dsh.js";
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
20
  import { acquireJoinLock, releaseJoinLock } from "./lock.js";
@@ -190,6 +190,8 @@ function sendSyntheticHttp(send, streamId, status, headers, body) {
190
190
  export function startJoin(opts) {
191
191
  const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
192
192
  const hooks = opts.hooks ?? {};
193
+ // 宿主代持的 dsh 会话 cookie(0.1.2+);null = 无认证(0.1.1)或换发失败
194
+ const dshAuthCookie = opts.dshAuthCookieHeader ?? null;
193
195
  // DSH UI 兼容开关:缺省 true(跟随 E2EE);可变引用 → 运行中可切换(插件面板即时生效)
194
196
  const uiCompat = { trustE2EEAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false };
195
197
  // 访问口令(feature 15):可变引用 → setAccessCode 运行中切换;null = gate off
@@ -301,7 +303,7 @@ export function startJoin(opts) {
301
303
  }
302
304
  function openWsStream(streamId, path, headers) {
303
305
  const upstream = new WebSocket(`ws://${opts.target.host}:${opts.target.port}${path}`, {
304
- headers: rewriteHeadersForDsh(headers, opts.target),
306
+ headers: rewriteHeadersForDsh(headers, opts.target, dshAuthCookie),
305
307
  });
306
308
  const queue = [];
307
309
  wsStreams.set(streamId, { upstream, queue });
@@ -380,7 +382,7 @@ export function startJoin(opts) {
380
382
  port: opts.target.port,
381
383
  path,
382
384
  method,
383
- headers: rewriteHeadersForDsh(headers, opts.target),
385
+ headers: rewriteHeadersForDsh(headers, opts.target, dshAuthCookie),
384
386
  }, (upRes) => {
385
387
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
386
388
  kind: "http",
@@ -717,8 +719,21 @@ export async function join(opts) {
717
719
  if (foundDsh === null) {
718
720
  throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or pass --dsh <path>.");
719
721
  }
722
+ // 版本窗口外 → warn(不硬拒)
723
+ const dshVersion = await detectDshVersion(foundDsh);
724
+ const versionWarn = dshVersionWarning(dshVersion);
725
+ if (versionWarn !== null)
726
+ console.warn(`\n⚠ ${versionWarn}\n`);
720
727
  const dsh = await spawnDsh(foundDsh);
721
728
  const target = { host: "127.0.0.1", port: dsh.port };
729
+ // 0.1.2+:换发浏览器会话 cookie 并代持注入隧道→本地转发
730
+ let dshAuthCookieHeader = null;
731
+ if (dsh.authToken !== undefined) {
732
+ dshAuthCookieHeader = await exchangeDshSessionCookie(dsh.port, dsh.authToken);
733
+ if (dshAuthCookieHeader === null) {
734
+ console.warn("rdsh join: dsh 0.1.2+ 会话 cookie 换发失败——远程访问将返回 401。");
735
+ }
736
+ }
722
737
  // 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
723
738
  const { token, insecure, name } = await registerJoin(opts);
724
739
  console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
@@ -731,6 +746,7 @@ export async function join(opts) {
731
746
  role: "cli",
732
747
  dshUiCompat: opts.dshUiCompat,
733
748
  gateway: opts.gateway,
749
+ dshAuthCookieHeader,
734
750
  name,
735
751
  hooks: {
736
752
  onLog: (level, message) => {
package/dist/proxy.d.ts CHANGED
@@ -11,14 +11,23 @@ export interface ForwardOptions {
11
11
  * (DSH 浏览器侧 RPC 依赖它),用非 secure context 也可用的 getRandomValues polyfill。
12
12
  */
13
13
  htmlInject?: string;
14
+ /**
15
+ * 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-<sha256(authority)>`,0.1.2+)。
16
+ * 提供时合并进每个转发/升级请求的 cookie 头,穿透 dsh 0.1.2 认证层。
17
+ */
18
+ authCookie?: string | null;
14
19
  }
15
20
  /**
16
21
  * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
17
22
  * - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
18
23
  * - Origin 同步改写为 http://127.0.0.1:<port>(浏览器视角仍同源,不影响 CORS)
24
+ * - 文档导航请求剥离 accept-encoding:0.1.2 起 dsh 对 HTML 默认 gzip,会使下游
25
+ * HTML 注入(hub 返回条/E2EE shim、serve polyfill)因 content-encoding 跳过;
26
+ * 剥离后 dsh 返回明文 HTML,压缩交给 hub 的 TLS + E2EE 层。静态 JS/CSS 仍 gzip。
27
+ * - 可选注入宿主 dsh 会话 cookie(合并进既有 cookie 头,保留 rdsh_gate 等,不覆盖)
19
28
  * 供 forwardHttp / createUpgradeProxy / join.ts(隧道→本地转发)复用。
20
29
  */
21
- export declare function rewriteHeadersForDsh(headers: Record<string, string | string[] | undefined>, target: ProxyTarget): Record<string, string | string[] | undefined>;
30
+ export declare function rewriteHeadersForDsh(headers: Record<string, string | string[] | undefined>, target: ProxyTarget, dshAuthCookie?: string | null): Record<string, string | string[] | undefined>;
22
31
  /** 转发一个 HTTP 请求(含 SSE:响应流式写回,零缓冲)。 */
23
32
  export declare function forwardHttp(req: IncomingMessage, res: ServerResponse, target: ProxyTarget, opts?: ForwardOptions): void;
24
33
  /**
@@ -27,7 +36,7 @@ export declare function forwardHttp(req: IncomingMessage, res: ServerResponse, t
27
36
  * - handleUpgrade 成功后 ws 库会自动 emit 'connection',这里不再手动 emit;
28
37
  * - 客户端消息立即入队,upstream open 后按序发送(避免握手竞态丢消息)。
29
38
  */
30
- export declare function createUpgradeProxy(target: ProxyTarget): {
39
+ export declare function createUpgradeProxy(target: ProxyTarget, opts?: ForwardOptions): {
31
40
  /** 完成客户端 upgrade 握手并桥接(转发前必须已通过认证)。 */
32
41
  handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void;
33
42
  };
package/dist/proxy.js CHANGED
@@ -10,19 +10,37 @@ import { WebSocket, WebSocketServer } from "ws";
10
10
  * 重写转发头以通过 DSH 围栏(isTrustedApiRequest 要求 Host/Origin 一致且为 loopback)。
11
11
  * - Host → 127.0.0.1:<port>(M1 事实:DSH 只信任 loopback/trusted Host)
12
12
  * - Origin 同步改写为 http://127.0.0.1:<port>(浏览器视角仍同源,不影响 CORS)
13
+ * - 文档导航请求剥离 accept-encoding:0.1.2 起 dsh 对 HTML 默认 gzip,会使下游
14
+ * HTML 注入(hub 返回条/E2EE shim、serve polyfill)因 content-encoding 跳过;
15
+ * 剥离后 dsh 返回明文 HTML,压缩交给 hub 的 TLS + E2EE 层。静态 JS/CSS 仍 gzip。
16
+ * - 可选注入宿主 dsh 会话 cookie(合并进既有 cookie 头,保留 rdsh_gate 等,不覆盖)
13
17
  * 供 forwardHttp / createUpgradeProxy / join.ts(隧道→本地转发)复用。
14
18
  */
15
- export function rewriteHeadersForDsh(headers, target) {
19
+ export function rewriteHeadersForDsh(headers, target, dshAuthCookie) {
16
20
  const out = { ...headers };
17
21
  out.host = `${target.host}:${target.port}`;
18
22
  if (out.origin !== undefined) {
19
23
  out.origin = `http://${target.host}:${target.port}`;
20
24
  }
25
+ if (acceptsHtml(headers)) {
26
+ delete out["accept-encoding"];
27
+ }
28
+ if (dshAuthCookie !== undefined && dshAuthCookie !== null && dshAuthCookie !== "") {
29
+ const existing = out.cookie;
30
+ const current = Array.isArray(existing) ? existing.join("; ") : typeof existing === "string" ? existing : "";
31
+ out.cookie = current === "" ? dshAuthCookie : `${current}; ${dshAuthCookie}`;
32
+ }
21
33
  return out;
22
34
  }
35
+ /** 请求是否期望 text/html(文档导航:地址栏/链接);浏览器 fetch/script 的 Accept 不含它。 */
36
+ function acceptsHtml(headers) {
37
+ const accept = headers["accept"];
38
+ const s = Array.isArray(accept) ? accept.join(",") : typeof accept === "string" ? accept : "";
39
+ return s.toLowerCase().includes("text/html");
40
+ }
23
41
  /** 转发一个 HTTP 请求(含 SSE:响应流式写回,零缓冲)。 */
24
42
  export function forwardHttp(req, res, target, opts) {
25
- const headers = rewriteHeadersForDsh(req.headers, target);
43
+ const headers = rewriteHeadersForDsh(req.headers, target, opts?.authCookie);
26
44
  const upstream = request({
27
45
  host: target.host,
28
46
  port: target.port,
@@ -80,10 +98,10 @@ export function forwardHttp(req, res, target, opts) {
80
98
  * - handleUpgrade 成功后 ws 库会自动 emit 'connection',这里不再手动 emit;
81
99
  * - 客户端消息立即入队,upstream open 后按序发送(避免握手竞态丢消息)。
82
100
  */
83
- export function createUpgradeProxy(target) {
101
+ export function createUpgradeProxy(target, opts) {
84
102
  const wss = new WebSocketServer({ noServer: true });
85
103
  wss.on("connection", (clientWs, req) => {
86
- const headers = rewriteHeadersForDsh(req.headers, target);
104
+ const headers = rewriteHeadersForDsh(req.headers, target, opts?.authCookie);
87
105
  const upstreamUrl = `ws://${target.host}:${target.port}${req.url ?? "/"}`;
88
106
  const upstream = new WebSocket(upstreamUrl, { headers });
89
107
  const queue = [];
package/dist/serve.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { networkInterfaces } from "node:os";
5
5
  import { startGateway } from "./server.js";
6
- import { findDsh, spawnDsh } from "./spawn-dsh.js";
6
+ import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning } from "./spawn-dsh.js";
7
7
  import { loadConfig, resolveConfigPath } from "./config.js";
8
8
  import { UserManager } from "./auth.js";
9
9
  import { loadTls } from "./tls.js";
@@ -25,7 +25,20 @@ export async function serve(opts) {
25
25
  if (foundDsh === null) {
26
26
  throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or set dshPath in config / pass --dsh <path>.");
27
27
  }
28
+ // 版本窗口外 → warn(不硬拒):dsh 太新建议升 rdsh / 太旧建议升 dsh
29
+ const dshVersion = await detectDshVersion(foundDsh);
30
+ const versionWarn = dshVersionWarning(dshVersion);
31
+ if (versionWarn !== null)
32
+ console.warn(`\n⚠ ${versionWarn}\n`);
28
33
  const dsh = await spawnDsh(foundDsh);
34
+ // 0.1.2+:就绪行带 launch token → 换发浏览器会话 cookie 并代持注入转发
35
+ let dshAuthCookieHeader = null;
36
+ if (dsh.authToken !== undefined) {
37
+ dshAuthCookieHeader = await exchangeDshSessionCookie(dsh.port, dsh.authToken);
38
+ if (dshAuthCookieHeader === null) {
39
+ console.warn("rdsh: dsh 0.1.2+ 会话 cookie 换发失败——远程访问将返回 401(请确认 dsh web 已就绪或暂用 dsh@0.1.1-rc.2)。");
40
+ }
41
+ }
29
42
  // TLS 决策:有 tls.cert/key → https;无 → http(behindProxy 或 pair/none)。
30
43
  // password + http + 非反代 → server.ts 安全约束拒绝启动(需自行提供证书)。
31
44
  const tlsMaterial = await loadTls(config.tls, config.behindProxy);
@@ -42,6 +55,7 @@ export async function serve(opts) {
42
55
  pairCode,
43
56
  sessionTtlSeconds,
44
57
  dshPort: dsh.port,
58
+ dshAuthCookieHeader,
45
59
  reset: opts.reset,
46
60
  noCode: opts.noCode,
47
61
  authMode,
package/dist/server.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface GatewayOptions {
24
24
  pairCode?: string;
25
25
  sessionTtlSeconds: number;
26
26
  dshPort: number;
27
+ /** 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-*`,0.1.2+);有值时注入转发 */
28
+ dshAuthCookieHeader?: string | null;
27
29
  /** true = 启动时重置会话密钥(全部会话失效) */
28
30
  reset?: boolean;
29
31
  /** 会话密钥目录(默认 ~/.rdsh;测试可注入临时目录) */
package/dist/server.js CHANGED
@@ -69,6 +69,7 @@ export async function startGateway(opts) {
69
69
  sessionTtlSeconds: opts.sessionTtlSeconds,
70
70
  authMode,
71
71
  behindProxy,
72
+ dshAuthCookieHeader: opts.dshAuthCookieHeader ?? null,
72
73
  getVersion: () => currentVersion,
73
74
  isAllowed: (ip) => currentAllowFrom.length === 0 || ipInCidrs(ip, currentAllowFrom),
74
75
  userManager: opts.userManager,
@@ -98,7 +99,7 @@ export async function startGateway(opts) {
98
99
  });
99
100
  const address = server.address();
100
101
  const actualPort = typeof address === "object" && address !== null ? address.port : opts.port;
101
- const upgradeProxy = createUpgradeProxy(target);
102
+ const upgradeProxy = createUpgradeProxy(target, { authCookie: ctx.dshAuthCookieHeader });
102
103
  return {
103
104
  server,
104
105
  sessions,
@@ -155,7 +156,7 @@ async function handleHttp(req, res, ctx, loginLimiter) {
155
156
  return;
156
157
  }
157
158
  if (ctx.authMode === "none") {
158
- forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL });
159
+ forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL, authCookie: ctx.dshAuthCookieHeader });
159
160
  return;
160
161
  }
161
162
  if (!hasValidSession(req, ctx)) {
@@ -163,7 +164,7 @@ async function handleHttp(req, res, ctx, loginLimiter) {
163
164
  res.end();
164
165
  return;
165
166
  }
166
- forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL });
167
+ forwardHttp(req, res, ctx.target, { htmlInject: SECURE_CONTEXT_POLYFILL, authCookie: ctx.dshAuthCookieHeader });
167
168
  }
168
169
  async function handlePairPost(req, res, ctx) {
169
170
  const ip = req.socket.remoteAddress ?? "unknown";
@@ -2,6 +2,11 @@ import type { ChildProcess } from "node:child_process";
2
2
  export interface SpawnedDsh {
3
3
  /** dsh 实际监听端口(OS 分配) */
4
4
  port: number;
5
+ /**
6
+ * 0.1.2+ 就绪行携带的 launch token(`/?token=` 后);0.1.1 及更早为 undefined。
7
+ * 有值才需要换发浏览器会话 cookie。
8
+ */
9
+ authToken?: string;
5
10
  child: ChildProcess;
6
11
  /** 终止 dsh(SIGTERM,超时 SIGKILL),返回退出码 */
7
12
  stop(): Promise<number>;
@@ -17,3 +22,31 @@ export declare function findDsh(override?: string): string | null;
17
22
  * dsh 启动失败/超时 → reject。
18
23
  */
19
24
  export declare function spawnDsh(dshPath: string): Promise<SpawnedDsh>;
25
+ /**
26
+ * 用 launch token 向 dsh web 换发浏览器会话 cookie(0.1.2+ 认证)。
27
+ * `GET /?token=<t>` → 303 + `Set-Cookie: dsh-auth-<sha256(authority)>=v1...`。
28
+ * 用 node:http 而非 fetch:fetch 的 opaque-redirect 响应读不到 Set-Cookie。
29
+ * 返回 cookie 的 `name=value` 段(不含属性);换发失败/超时/无 cookie → null(调用方降级)。
30
+ */
31
+ export declare function exchangeDshSessionCookie(port: number, token: string, timeoutMs?: number): Promise<string | null>;
32
+ /** 探测 dsh 版本号(`dsh --version` 输出如 `0.1.2-rc.1`);失败/不可解析 → null。 */
33
+ export declare function detectDshVersion(dshPath: string, timeoutMs?: number): Promise<string | null>;
34
+ /**
35
+ * 比较两个 dsh 版本串(如 `0.1.1-rc.2` / `0.1.2-rc.1`),支持 `-rc.N`/`-beta.N` 后缀:
36
+ * 返回负/0/正;同 core 时 release 大于任何 prerelease;不可解析排序为「更旧」。
37
+ * 语义对齐 dsh4vscode 的 compareVersions(跨仓库同一 dsh 版本约定)。
38
+ */
39
+ export declare function compareDshVersions(a: string, b: string): number;
40
+ /**
41
+ * remote-dsh 已实测兼容的 dsh 版本窗口(registry 实存版本定界)。
42
+ * ⚠️ 适配新的 dsh 版本并真机实测后,须同步扩展此窗口(见 doc/fix/20260907-dsh-0.1.2-rc1-auth/)。
43
+ */
44
+ export declare const DSH_COMPAT_MIN = "0.1.1-rc.2";
45
+ export declare const DSH_COMPAT_MAX = "0.1.2-rc.1";
46
+ /**
47
+ * 版本落在实测窗口外时的提示文案(零文档导向:直接给动作指令,用户不查表)。
48
+ * - 比 max 新 → 升级 remote-dsh 或暂用旧 dsh;
49
+ * - 比 min 旧 → 升级 dsh;
50
+ * - 在窗口内 / 探测失败(version=null)→ null(不提示)。
51
+ */
52
+ export declare function dshVersionWarning(version: string | null): string | null;
package/dist/spawn-dsh.js CHANGED
@@ -4,13 +4,20 @@
4
4
  * 事实依据(discussion.md §2):`dsh web --port 0 --no-open` 由 OS 分配端口,
5
5
  * 启动时打印 `dsh web: http://127.0.0.1:<port>`(dsh-web-app/lib/index.js)。
6
6
  */
7
- import { spawn } from "node:child_process";
7
+ import { execFile, spawn } from "node:child_process";
8
8
  import { accessSync } from "node:fs";
9
+ import { get } from "node:http";
9
10
  import { createInterface } from "node:readline";
10
- /** dsh 的 URL 行格式(`dsh web: http://127.0.0.1:<port> (LAN: ...)`)。 */
11
- const URL_LINE_RE = /dsh web:\s*http:\/\/127\.0\.0\.1:(\d+)/;
11
+ /**
12
+ * dsh 的 URL 行格式。0.1.1:`dsh web: http://127.0.0.1:<port>`;
13
+ * 0.1.2+:`dsh web: http://127.0.0.1:<port>/?token=<launchToken> (LAN: ...)`。
14
+ * 就绪行不锚定行尾(LAN 提示可随行出现);token 为 base64url([A-Za-z0-9_-])。
15
+ */
16
+ const URL_LINE_RE = /dsh web:\s*http:\/\/127\.0\.0\.1:(\d+)(?:\/\?token=([A-Za-z0-9_-]+))?/;
12
17
  const READY_TIMEOUT_MS = 30_000;
13
18
  const STOP_TIMEOUT_MS = 5_000;
19
+ const VERSION_TIMEOUT_MS = 5_000;
20
+ const EXCHANGE_TIMEOUT_MS = 5_000;
14
21
  /**
15
22
  * 在 PATH 中查找 dsh 可执行文件;`override` 直接使用。
16
23
  * 找不到返回 null。
@@ -58,7 +65,7 @@ export function spawnDsh(dshPath) {
58
65
  // 就绪:后续 dsh 输出全部透传
59
66
  child.stdout?.pipe(process.stdout);
60
67
  child.stderr?.pipe(process.stderr);
61
- resolve({ port: Number(m[1]), child, stop: () => stopDsh(child) });
68
+ resolve({ port: Number(m[1]), authToken: m[2], child, stop: () => stopDsh(child) });
62
69
  }
63
70
  else {
64
71
  process.stdout.write(`${line}\n`);
@@ -88,4 +95,127 @@ function stopDsh(child) {
88
95
  setTimeout(() => child.kill("SIGKILL"), STOP_TIMEOUT_MS).unref();
89
96
  });
90
97
  }
98
+ /**
99
+ * 用 launch token 向 dsh web 换发浏览器会话 cookie(0.1.2+ 认证)。
100
+ * `GET /?token=<t>` → 303 + `Set-Cookie: dsh-auth-<sha256(authority)>=v1...`。
101
+ * 用 node:http 而非 fetch:fetch 的 opaque-redirect 响应读不到 Set-Cookie。
102
+ * 返回 cookie 的 `name=value` 段(不含属性);换发失败/超时/无 cookie → null(调用方降级)。
103
+ */
104
+ export function exchangeDshSessionCookie(port, token, timeoutMs = EXCHANGE_TIMEOUT_MS) {
105
+ return new Promise((resolve) => {
106
+ const req = get({ host: "127.0.0.1", port, path: `/?token=${encodeURIComponent(token)}` }, (res) => {
107
+ const raw = res.headers["set-cookie"];
108
+ const list = Array.isArray(raw) ? raw : raw !== undefined ? [raw] : [];
109
+ let found = null;
110
+ for (const c of list) {
111
+ const segment = c.split(";", 1)[0]?.trim() ?? "";
112
+ if (segment.startsWith("dsh-auth-")) {
113
+ found = segment;
114
+ break;
115
+ }
116
+ }
117
+ res.resume();
118
+ resolve(found);
119
+ });
120
+ req.on("error", () => resolve(null));
121
+ req.setTimeout(timeoutMs, () => {
122
+ req.destroy();
123
+ resolve(null);
124
+ });
125
+ });
126
+ }
127
+ /** 探测 dsh 版本号(`dsh --version` 输出如 `0.1.2-rc.1`);失败/不可解析 → null。 */
128
+ export function detectDshVersion(dshPath, timeoutMs = VERSION_TIMEOUT_MS) {
129
+ return new Promise((resolve) => {
130
+ execFile(dshPath, ["--version"], { timeout: timeoutMs }, (err, stdout) => {
131
+ if (err) {
132
+ resolve(null);
133
+ return;
134
+ }
135
+ const first = stdout.trim().split(/\r?\n/)[0] ?? "";
136
+ const m = first.match(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
137
+ resolve(m !== null ? m[0] : null);
138
+ });
139
+ });
140
+ }
141
+ function parseVersion(v) {
142
+ const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
143
+ if (m === null)
144
+ return null;
145
+ const core = [Number(m[1]), Number(m[2]), Number(m[3])];
146
+ const pre = m[4] !== undefined ? m[4].split(".").map((s) => (/^\d+$/.test(s) ? Number(s) : s)) : null;
147
+ return { core, pre };
148
+ }
149
+ /**
150
+ * 比较两个 dsh 版本串(如 `0.1.1-rc.2` / `0.1.2-rc.1`),支持 `-rc.N`/`-beta.N` 后缀:
151
+ * 返回负/0/正;同 core 时 release 大于任何 prerelease;不可解析排序为「更旧」。
152
+ * 语义对齐 dsh4vscode 的 compareVersions(跨仓库同一 dsh 版本约定)。
153
+ */
154
+ export function compareDshVersions(a, b) {
155
+ const pa = parseVersion(a);
156
+ const pb = parseVersion(b);
157
+ if (pa === null && pb === null)
158
+ return 0;
159
+ if (pa === null)
160
+ return -1;
161
+ if (pb === null)
162
+ return 1;
163
+ for (let i = 0; i < 3; i++) {
164
+ const x = pa.core[i];
165
+ const y = pb.core[i];
166
+ if (x !== y)
167
+ return x - y;
168
+ }
169
+ if (pa.pre === null && pb.pre === null)
170
+ return 0;
171
+ if (pa.pre === null)
172
+ return 1;
173
+ if (pb.pre === null)
174
+ return -1;
175
+ const len = Math.max(pa.pre.length, pb.pre.length);
176
+ for (let i = 0; i < len; i++) {
177
+ const x = pa.pre[i];
178
+ const y = pb.pre[i];
179
+ if (x === undefined)
180
+ return -1;
181
+ if (y === undefined)
182
+ return 1;
183
+ if (typeof x === "number" && typeof y === "number") {
184
+ if (x !== y)
185
+ return x - y;
186
+ }
187
+ else if (typeof x === "string" && typeof y === "string") {
188
+ if (x !== y)
189
+ return x < y ? -1 : 1;
190
+ }
191
+ else {
192
+ return typeof x === "number" ? -1 : 1;
193
+ }
194
+ }
195
+ return 0;
196
+ }
197
+ /**
198
+ * remote-dsh 已实测兼容的 dsh 版本窗口(registry 实存版本定界)。
199
+ * ⚠️ 适配新的 dsh 版本并真机实测后,须同步扩展此窗口(见 doc/fix/20260907-dsh-0.1.2-rc1-auth/)。
200
+ */
201
+ export const DSH_COMPAT_MIN = "0.1.1-rc.2";
202
+ export const DSH_COMPAT_MAX = "0.1.2-rc.1";
203
+ /**
204
+ * 版本落在实测窗口外时的提示文案(零文档导向:直接给动作指令,用户不查表)。
205
+ * - 比 max 新 → 升级 remote-dsh 或暂用旧 dsh;
206
+ * - 比 min 旧 → 升级 dsh;
207
+ * - 在窗口内 / 探测失败(version=null)→ null(不提示)。
208
+ */
209
+ export function dshVersionWarning(version) {
210
+ if (version === null)
211
+ return null;
212
+ if (compareDshVersions(version, DSH_COMPAT_MAX) > 0) {
213
+ return (`检测到 dsh ${version}:该版本超出 remote-dsh 已实测范围(≤${DSH_COMPAT_MAX}),远程访问可能不可用。` +
214
+ `升级 remote-dsh:npm i -g remote-dsh@latest;或暂用 dsh@${DSH_COMPAT_MIN}。`);
215
+ }
216
+ if (compareDshVersions(version, DSH_COMPAT_MIN) < 0) {
217
+ return `检测到 dsh ${version}:版本过旧,请升级 dsh:npm i -g @deepseek-ai/dsh@latest。`;
218
+ }
219
+ return null;
220
+ }
91
221
  //# sourceMappingURL=spawn-dsh.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
5
5
  "license": "MIT",
6
6
  "type": "module",