rdsh-gateway 0.6.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.
@@ -0,0 +1,13 @@
1
+ /** 网关访问 cookie 名(hub 侧 relay 白名单同名硬编码,注释互指)。 */
2
+ export declare const GATE_COOKIE = "rdsh_gate";
3
+ /** 访问 cookie 有效期(对齐 host cookie 7 天)。 */
4
+ export declare const GATE_COOKIE_TTL_MS: number;
5
+ /** 签发访问 cookie;返回 value + 过期时间(毫秒)。 */
6
+ export declare function signGateCookie(accessCode: string, now?: number): {
7
+ value: string;
8
+ expiresAt: number;
9
+ };
10
+ /** 验签访问 cookie:签名(恒定时间)+ 未过期。 */
11
+ export declare function verifyGateCookie(accessCode: string, value: string, now?: number): boolean;
12
+ /** code 恒定时间比对(对 sha256 摘要比较,避免长度侧信道)。 */
13
+ export declare function verifyGateCode(input: string, accessCode: string): boolean;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * access-gate.ts — host 侧访问口令(feature 15):访问 cookie 签发/验签 + code 恒定时间比对。
3
+ *
4
+ * - cookie 名 `rdsh_gate`(hub relay D12 白名单需同名透传,见 packages/hub/src/relay.ts);
5
+ * - 无状态验签:HMAC-SHA256(payload, key=sha256(accessCode)),payload = `${exp}.${nonce}`;
6
+ * - 改 code → key 变 → 旧 cookie 全失效(无需版本/黑名单)。
7
+ */
8
+ import { createHmac, createHash, randomBytes, timingSafeEqual } from "node:crypto";
9
+ /** 网关访问 cookie 名(hub 侧 relay 白名单同名硬编码,注释互指)。 */
10
+ export const GATE_COOKIE = "rdsh_gate";
11
+ /** 访问 cookie 有效期(对齐 host cookie 7 天)。 */
12
+ export const GATE_COOKIE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
13
+ /** 派生 HMAC 密钥:sha256(accessCode)(改 code 即吊销旧 cookie)。 */
14
+ function keyFor(accessCode) {
15
+ return createHash("sha256").update(accessCode).digest();
16
+ }
17
+ function hmac(payload, accessCode) {
18
+ return createHmac("sha256", keyFor(accessCode)).update(payload).digest("base64url");
19
+ }
20
+ /** 签发访问 cookie;返回 value + 过期时间(毫秒)。 */
21
+ export function signGateCookie(accessCode, now = Date.now()) {
22
+ const exp = now + GATE_COOKIE_TTL_MS;
23
+ const nonce = randomBytes(16).toString("base64url");
24
+ const payload = `${exp}.${nonce}`;
25
+ return { value: `${payload}.${hmac(payload, accessCode)}`, expiresAt: exp };
26
+ }
27
+ /** 验签访问 cookie:签名(恒定时间)+ 未过期。 */
28
+ export function verifyGateCookie(accessCode, value, now = Date.now()) {
29
+ const parts = value.split(".");
30
+ if (parts.length !== 3)
31
+ return false;
32
+ const [expStr, nonce, sig] = parts;
33
+ if (expStr === "" || nonce === "" || sig === "")
34
+ return false;
35
+ const exp = Number(expStr);
36
+ if (!Number.isFinite(exp) || exp <= now)
37
+ return false;
38
+ const payload = `${expStr}.${nonce}`;
39
+ const expected = Buffer.from(hmac(payload, accessCode));
40
+ const actual = Buffer.from(sig);
41
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
42
+ }
43
+ /** code 恒定时间比对(对 sha256 摘要比较,避免长度侧信道)。 */
44
+ export function verifyGateCode(input, accessCode) {
45
+ const a = createHash("sha256").update(input).digest();
46
+ const b = createHash("sha256").update(accessCode).digest();
47
+ return timingSafeEqual(a, b);
48
+ }
49
+ //# sourceMappingURL=access-gate.js.map
package/dist/config.d.ts CHANGED
@@ -32,6 +32,12 @@ export interface RdshConfig {
32
32
  insecure?: boolean;
33
33
  /** DSH UI 兼容开关(跟随 E2EE;trustE2EEAsLoopback 默认 true) */
34
34
  dshUiCompat?: DshUiCompat;
35
+ /** 网关访问口令(host 侧独立于 hub 的访问保护) */
36
+ gateway?: GatewayConfig;
37
+ }
38
+ /** 网关访问口令:accessCode 缺失/null/"" → null(gate off);非空 ≥4 位(gate on)。 */
39
+ export interface GatewayConfig {
40
+ accessCode: string | null;
35
41
  }
36
42
  /** DSH UI 兼容:把经隧道访问的前端 isLoopback 判定视为 loopback,使 Models/设置持久化可用。 */
37
43
  export interface DshUiCompat {
package/dist/config.js CHANGED
@@ -22,6 +22,7 @@ const DEFAULTS = {
22
22
  allowFrom: [],
23
23
  auth: DEFAULT_AUTH,
24
24
  dshUiCompat: { trustE2EEAsLoopback: true },
25
+ gateway: { accessCode: null },
25
26
  };
26
27
  /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
27
28
  export function resolveConfigPath(cliPath, env = process.env) {
@@ -188,6 +189,20 @@ export function normalizeConfig(raw, source = "config") {
188
189
  out.dshUiCompat = { trustE2EEAsLoopback: compat.trustE2EEAsLoopback };
189
190
  }
190
191
  }
192
+ // ---- gateway(访问口令;缺省 accessCode null = gate off)----
193
+ if (cfg.gateway !== undefined) {
194
+ if (typeof cfg.gateway !== "object" || cfg.gateway === null)
195
+ throw new Error(`${source}: "gateway" must be an object`);
196
+ const g = cfg.gateway;
197
+ // 折叠语义:缺失 / null / "" → null(gate off);非空字符串 → ≥4 位(gate on)
198
+ if (g.accessCode !== undefined && g.accessCode !== null && g.accessCode !== "") {
199
+ if (typeof g.accessCode !== "string")
200
+ throw new Error(`${source}: "gateway.accessCode" must be a string`);
201
+ if (g.accessCode.length < 4)
202
+ throw new Error(`${source}: "gateway.accessCode" must be at least 4 chars`);
203
+ out.gateway = { accessCode: g.accessCode };
204
+ }
205
+ }
191
206
  return out;
192
207
  }
193
208
  function assertString(v, field, source) {
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
@@ -16,6 +16,10 @@ export interface JoinOptions {
16
16
  dshUiCompat?: {
17
17
  trustE2EEAsLoopback?: boolean;
18
18
  };
19
+ /** 网关访问口令(feature 15;accessCode null = 关闭) */
20
+ gateway?: {
21
+ accessCode?: string | null;
22
+ };
19
23
  }
20
24
  /** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
21
25
  export interface RegisterOutcome {
@@ -50,12 +54,22 @@ export interface StartJoinOptions {
50
54
  dshUiCompat?: {
51
55
  trustE2EEAsLoopback?: boolean;
52
56
  };
57
+ /** 网关访问口令(feature 15;accessCode null = 关闭) */
58
+ gateway?: {
59
+ accessCode?: string | null;
60
+ };
61
+ /** 主机名(challenge 页展示;缺省「本主机」) */
62
+ name?: string;
63
+ /** 宿主代持的 dsh 浏览器会话 cookie(`dsh-auth-*`,0.1.2+);有值时注入隧道→本地转发 */
64
+ dshAuthCookieHeader?: string | null;
53
65
  }
54
66
  /** 可停止的 join 隧道句柄。 */
55
67
  export interface JoinHandle {
56
68
  stop(): Promise<void>;
57
69
  /** 运行中切换 DSH UI 兼容(trustE2EEAsLoopback);下一个请求即生效。 */
58
70
  setUiCompat(trustE2EEAsLoopback: boolean): void;
71
+ /** 运行中设置/清除访问口令(null = 关闭 gate);下一个请求即生效。 */
72
+ setAccessCode(code: string | null): void;
59
73
  }
60
74
  /** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
61
75
  export declare function detectInsecure(hubUrl: string): Promise<boolean>;
package/dist/join.js CHANGED
@@ -14,12 +14,13 @@ 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";
21
21
  import { responderHandshake, Aead } from "./e2ee.js";
22
22
  import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
23
+ import { GATE_COOKIE, signGateCookie, verifyGateCookie, verifyGateCode } from "./access-gate.js";
23
24
  /** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
24
25
  function isCertError(err) {
25
26
  const code = err?.code ?? "";
@@ -146,11 +147,57 @@ export function patchLoopbackJs(body) {
146
147
  return null;
147
148
  return Buffer.from(src.split(target).join("true"), "utf8");
148
149
  }
150
+ /** 从转发头里取 Accept-Language(数组取首个,缺失 undefined)。 */
151
+ function headerAcceptLanguage(headers) {
152
+ const al = headers["accept-language"];
153
+ return Array.isArray(al) ? al.join(",") : typeof al === "string" ? al : undefined;
154
+ }
155
+ /** HTML 转义(challenge 页内插 hostName/actionPath 防注入;actionPath 经 hub URL 解析已 percent-encoded,此处为纵深防御)。 */
156
+ function escapeHtml(s) {
157
+ return s
158
+ .replaceAll("&", "&amp;")
159
+ .replaceAll("<", "&lt;")
160
+ .replaceAll(">", "&gt;")
161
+ .replaceAll('"', "&quot;")
162
+ .replaceAll("'", "&#39;");
163
+ }
164
+ /** 访问口令 challenge 页(内联,零外部依赖;经隧道在 hub 域名下展示;Accept-Language 含 zh → 中文,否则英文兜底)。 */
165
+ function gateChallengeHtml(hostName, actionPath, error, acceptLanguage) {
166
+ const safeHost = escapeHtml(hostName);
167
+ const safePath = escapeHtml(actionPath);
168
+ const zh = typeof acceptLanguage === "string" && /zh/i.test(acceptLanguage);
169
+ const t = zh
170
+ ? { title: "访问密码", heading: `主机「${safeHost}」受访问密码保护`, note: "此密码由主机所有者设置,hub 无法绕过。", placeholder: "请输入访问密码", submit: "进入", wrong: "访问密码错误", locked: "尝试次数过多,请稍后再试" }
171
+ : { title: "Access code", heading: `Host "${safeHost}" is protected by an access code`, note: "This code is set by the host owner; the hub cannot bypass it.", placeholder: "Enter access code", submit: "Enter", wrong: "Incorrect access code", locked: "Too many attempts — please try again later" };
172
+ const errHtml = error === null ? "" : `<p style="color:#dc2626;font-size:13px;margin:10px 0 0">${error === "wrong" ? t.wrong : t.locked}</p>`;
173
+ return (`<!doctype html><html lang="${zh ? "zh-CN" : "en"}"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
174
+ `<title>${t.title}</title>` +
175
+ `<body style="font-family:system-ui,sans-serif;background:#f6f7f9;margin:0">` +
176
+ `<div style="max-width:360px;margin:64px auto;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px">` +
177
+ `<h1 style="font-size:18px;margin:0 0 8px">${t.heading}</h1>` +
178
+ `<p style="font-size:13px;color:#6b7280;margin:0 0 16px">${t.note}</p>` +
179
+ `<form method="POST" action="${safePath}">` +
180
+ `<input name="gate_code" type="password" autocomplete="off" autofocus placeholder="${t.placeholder}" style="width:100%;box-sizing:border-box;height:38px;border:1px solid #d1d5db;border-radius:8px;padding:0 12px;font-size:14px">` +
181
+ `<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
+ `</form>${errHtml}</div></body></html>`);
183
+ }
184
+ /** 发送合成的 HTTP 响应帧(gateway 不触达 dsh)。 */
185
+ function sendSyntheticHttp(send, streamId, status, headers, body) {
186
+ send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({ kind: "http", status, reason: undefined, headers })));
187
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, body));
188
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
189
+ }
149
190
  export function startJoin(opts) {
150
191
  const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
151
192
  const hooks = opts.hooks ?? {};
193
+ // 宿主代持的 dsh 会话 cookie(0.1.2+);null = 无认证(0.1.1)或换发失败
194
+ const dshAuthCookie = opts.dshAuthCookieHeader ?? null;
152
195
  // DSH UI 兼容开关:缺省 true(跟随 E2EE);可变引用 → 运行中可切换(插件面板即时生效)
153
196
  const uiCompat = { trustE2EEAsLoopback: opts.dshUiCompat?.trustE2EEAsLoopback !== false };
197
+ // 访问口令(feature 15):可变引用 → setAccessCode 运行中切换;null = gate off
198
+ const gate = { accessCode: opts.gateway?.accessCode ?? null };
199
+ const hostName = opts.name ?? "本主机";
200
+ const gateFailures = { count: 0, lockedUntil: 0 };
154
201
  const log = (level, message) => {
155
202
  hooks.onLog?.(level, message);
156
203
  };
@@ -176,6 +223,66 @@ export function startJoin(opts) {
176
223
  function makeInnerDispatcher(send, dio) {
177
224
  const httpStreams = new Map();
178
225
  const wsStreams = new Map();
226
+ // gate 未过、等待 code 提交的 http 流(OPEN 后缓冲 DATA,CLOSE 时校验)
227
+ const gatedHttp = new Map();
228
+ /** 从转发头里取 rdsh_gate cookie(hub D12 白名单透传)。 */
229
+ function gateCookie(headers) {
230
+ const ck = headers["cookie"];
231
+ const s = Array.isArray(ck) ? ck.join(";") : typeof ck === "string" ? ck : "";
232
+ for (const part of s.split(";")) {
233
+ const idx = part.indexOf("=");
234
+ if (idx <= 0)
235
+ continue;
236
+ if (part.slice(0, idx).trim() === GATE_COOKIE)
237
+ return part.slice(idx + 1).trim();
238
+ }
239
+ return null;
240
+ }
241
+ /** gate 开启时的失败计数:全局封顶,达限短时锁定(隧道流量无真实客户端 IP)。 */
242
+ function gateBlocked() {
243
+ if (gateFailures.lockedUntil > Date.now())
244
+ return true;
245
+ if (gateFailures.lockedUntil !== 0)
246
+ gateFailures.lockedUntil = 0;
247
+ return false;
248
+ }
249
+ /** 发送 challenge 页(或带错误)响应。 */
250
+ function sendChallenge(streamId, path, error, acceptLanguage) {
251
+ const html = gateChallengeHtml(hostName, path, error, acceptLanguage);
252
+ sendSyntheticHttp(send, streamId, 200, { "content-type": "text/html; charset=utf-8" }, Buffer.from(html));
253
+ }
254
+ /** 校验 code 提交(POST gate_code)→ 302 回跳 + 发 cookie,或回 challenge 错误。 */
255
+ function handleGateSubmit(streamId, state) {
256
+ const code = gate.accessCode;
257
+ if (code === null) {
258
+ sendSyntheticHttp(send, streamId, 302, { location: state.path }, Buffer.alloc(0));
259
+ return;
260
+ }
261
+ if (gateBlocked()) {
262
+ sendChallenge(streamId, state.path, "locked", state.acceptLanguage);
263
+ return;
264
+ }
265
+ const raw = Buffer.concat(state.body).toString("utf8");
266
+ let input = null;
267
+ try {
268
+ const params = new URLSearchParams(raw);
269
+ input = params.get("gate_code");
270
+ }
271
+ catch {
272
+ input = null;
273
+ }
274
+ if (input !== null && verifyGateCode(input, code)) {
275
+ gateFailures.count = 0;
276
+ const { value } = signGateCookie(code);
277
+ sendSyntheticHttp(send, streamId, 302, { location: state.path, "set-cookie": `${GATE_COOKIE}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${7 * 24 * 3600}` }, Buffer.alloc(0));
278
+ }
279
+ else {
280
+ gateFailures.count += 1;
281
+ if (gateFailures.count >= 10)
282
+ gateFailures.lockedUntil = Date.now() + 60_000;
283
+ sendChallenge(streamId, state.path, "wrong", state.acceptLanguage);
284
+ }
285
+ }
179
286
  function closeStream(streamId) {
180
287
  const ws = wsStreams.get(streamId);
181
288
  if (ws !== undefined) {
@@ -196,7 +303,7 @@ export function startJoin(opts) {
196
303
  }
197
304
  function openWsStream(streamId, path, headers) {
198
305
  const upstream = new WebSocket(`ws://${opts.target.host}:${opts.target.port}${path}`, {
199
- headers: rewriteHeadersForDsh(headers, opts.target),
306
+ headers: rewriteHeadersForDsh(headers, opts.target, dshAuthCookie),
200
307
  });
201
308
  const queue = [];
202
309
  wsStreams.set(streamId, { upstream, queue });
@@ -239,6 +346,28 @@ export function startJoin(opts) {
239
346
  send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
240
347
  return;
241
348
  }
349
+ // ---- 访问口令 gate(仅 plain dispatcher:dio.gate=true 且已设 accessCode)----
350
+ if (dio?.gate === true && gate.accessCode !== null) {
351
+ const code = gate.accessCode;
352
+ const authed = verifyGateCookie(code, gateCookie(headers) ?? "");
353
+ if (kind === "ws") {
354
+ if (!authed) {
355
+ send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 403, message: "access code required" })));
356
+ return;
357
+ }
358
+ openWsStream(frame.streamId, path, headers);
359
+ return;
360
+ }
361
+ if (!authed) {
362
+ if (method === "POST") {
363
+ // 可能是 code 提交:缓冲 body,CLOSE 时校验(见 handleFrame)
364
+ gatedHttp.set(frame.streamId, { method, path, body: [], size: 0, acceptLanguage: headerAcceptLanguage(headers) });
365
+ return;
366
+ }
367
+ sendChallenge(frame.streamId, path, null, headerAcceptLanguage(headers));
368
+ return;
369
+ }
370
+ }
242
371
  if (kind === "ws") {
243
372
  openWsStream(frame.streamId, path, headers);
244
373
  return;
@@ -253,7 +382,7 @@ export function startJoin(opts) {
253
382
  port: opts.target.port,
254
383
  path,
255
384
  method,
256
- headers: rewriteHeadersForDsh(headers, opts.target),
385
+ headers: rewriteHeadersForDsh(headers, opts.target, dshAuthCookie),
257
386
  }, (upRes) => {
258
387
  send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
259
388
  kind: "http",
@@ -305,6 +434,16 @@ export function startJoin(opts) {
305
434
  return;
306
435
  }
307
436
  case FRAME_TYPE.DATA: {
437
+ const gated = gatedHttp.get(frame.streamId);
438
+ if (gated !== undefined) {
439
+ gated.body.push(frame.payload);
440
+ gated.size += frame.payload.length;
441
+ if (gated.size > 64 * 1024) {
442
+ gatedHttp.delete(frame.streamId);
443
+ send(encodeFrame(FRAME_TYPE.CLOSE, frame.streamId, jsonPayload({ code: 413, message: "body too large" })));
444
+ }
445
+ return;
446
+ }
308
447
  const ws = wsStreams.get(frame.streamId);
309
448
  if (ws !== undefined) {
310
449
  if (ws.upstream.readyState === ws.upstream.OPEN)
@@ -320,6 +459,13 @@ export function startJoin(opts) {
320
459
  }
321
460
  case FRAME_TYPE.CLOSE:
322
461
  case FRAME_TYPE.ERROR: {
462
+ const gated = gatedHttp.get(frame.streamId);
463
+ if (gated !== undefined) {
464
+ gatedHttp.delete(frame.streamId);
465
+ if (frame.type === FRAME_TYPE.CLOSE)
466
+ handleGateSubmit(frame.streamId, gated);
467
+ return;
468
+ }
323
469
  closeStream(frame.streamId);
324
470
  return;
325
471
  }
@@ -346,10 +492,11 @@ export function startJoin(opts) {
346
492
  }
347
493
  }
348
494
  wsStreams.clear();
495
+ gatedHttp.clear();
349
496
  }
350
497
  return { handleFrame, cleanup };
351
498
  }
352
- const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback });
499
+ const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true });
353
500
  // host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
354
501
  const hostE2eeKeypair = loadOrCreateE2eeKeyPair();
355
502
  const rawStreams = new Map();
@@ -458,8 +605,9 @@ export function startJoin(opts) {
458
605
  function connect() {
459
606
  if (shuttingDown)
460
607
  return;
461
- const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(opts.token)}`;
462
- const client = new WebSocket(url, { rejectUnauthorized: !opts.insecure });
608
+ // 认证走 Authorization 头(不入 URL,避免 token 进日志)
609
+ const url = `${hubWsBase}/tunnel`;
610
+ const client = new WebSocket(url, { headers: { authorization: `Bearer ${opts.token}` }, rejectUnauthorized: !opts.insecure });
463
611
  currentClient = client;
464
612
  setState("connecting", { message: `connecting to ${opts.hubUrl}` });
465
613
  // 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
@@ -541,6 +689,12 @@ export function startJoin(opts) {
541
689
  uiCompat.trustE2EEAsLoopback = trustE2EEAsLoopback;
542
690
  console.log(`rdsh join: dshUiCompat.trustE2EEAsLoopback = ${trustE2EEAsLoopback}(运行中生效)`);
543
691
  },
692
+ setAccessCode(code) {
693
+ gate.accessCode = code;
694
+ gateFailures.count = 0;
695
+ gateFailures.lockedUntil = 0;
696
+ console.log(`rdsh join: accessCode = ${code === null ? "(off)" : "***"}(运行中生效)`);
697
+ },
544
698
  async stop() {
545
699
  if (shuttingDown)
546
700
  return;
@@ -565,10 +719,23 @@ export async function join(opts) {
565
719
  if (foundDsh === null) {
566
720
  throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or pass --dsh <path>.");
567
721
  }
722
+ // 版本窗口外 → warn(不硬拒)
723
+ const dshVersion = await detectDshVersion(foundDsh);
724
+ const versionWarn = dshVersionWarning(dshVersion);
725
+ if (versionWarn !== null)
726
+ console.warn(`\n⚠ ${versionWarn}\n`);
568
727
  const dsh = await spawnDsh(foundDsh);
569
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
+ }
570
737
  // 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
571
- const { token, insecure } = await registerJoin(opts);
738
+ const { token, insecure, name } = await registerJoin(opts);
572
739
  console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
573
740
  console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
574
741
  const handle = startJoin({
@@ -578,6 +745,9 @@ export async function join(opts) {
578
745
  target,
579
746
  role: "cli",
580
747
  dshUiCompat: opts.dshUiCompat,
748
+ gateway: opts.gateway,
749
+ dshAuthCookieHeader,
750
+ name,
581
751
  hooks: {
582
752
  onLog: (level, message) => {
583
753
  (level === "error" ? console.error : console.log)(`rdsh join: ${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.6.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",