rdsh-gateway 0.8.4 → 0.8.6
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 +1 -1
- package/dist/index.js +1 -1
- package/dist/join.js +36 -5
- package/dist/lock.d.ts +17 -4
- package/dist/lock.js +129 -24
- package/dist/serve.js +5 -1
- package/dist/spawn-dsh.d.ts +37 -0
- package/dist/spawn-dsh.js +87 -0
- package/package.json +1 -1
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, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.ts";
|
|
13
|
+
export { findDsh, spawnDsh, dshSpawnEnv, RDSH_REMOTE_TTY, checkRemotePickerGraph, remotePickerWarning, 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";
|
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, exchangeDshSessionCookie, detectDshVersion, compareDshVersions, dshVersionWarning, DSH_COMPAT_MIN, DSH_COMPAT_MAX } from "./spawn-dsh.js";
|
|
10
|
+
export { findDsh, spawnDsh, dshSpawnEnv, RDSH_REMOTE_TTY, checkRemotePickerGraph, remotePickerWarning, 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";
|
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, MAX_PAYLOAD_LENGTH } from "rdsh-tunnel";
|
|
17
|
-
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning } from "./spawn-dsh.js";
|
|
17
|
+
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning, checkRemotePickerGraph, remotePickerWarning } from "./spawn-dsh.js";
|
|
18
18
|
import { rewriteHeadersForDsh } from "./proxy.js";
|
|
19
19
|
import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
|
|
20
20
|
import { decodeBody, encodeBody, firstEncoding } from "./http-encoding.js";
|
|
@@ -260,8 +260,22 @@ export function startJoin(opts) {
|
|
|
260
260
|
};
|
|
261
261
|
const lock = acquireJoinLock(opts.role, opts.lockPath);
|
|
262
262
|
if (!lock.ok) {
|
|
263
|
-
|
|
263
|
+
// 「同机单隧道」铁律:任何活锁都拒绝(含本进程自己的 pid —— 那是热重载时的同进程重复获取)
|
|
264
|
+
throw new Error("contended" in lock
|
|
265
|
+
? "join lock is contended by another instance; retry"
|
|
266
|
+
: lock.heldBy.pid === process.pid
|
|
267
|
+
? "another tunnel is already running in this process"
|
|
268
|
+
: `join lock held by ${lock.heldBy.role} (pid ${lock.heldBy.pid}); stop it first`);
|
|
264
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* 构造期同步抛错必须把锁还回去(否则本进程再也起不来隧道,还谎报「本进程已有隧道」)。
|
|
272
|
+
* 现实抛点:E2EE 密钥生成/序列化(`loadOrCreateE2eeKeyPair`)与 `connect()` 里的
|
|
273
|
+
* `new WebSocket(非法 URL)`、以及 embedder 的 onState 钩子。
|
|
274
|
+
*/
|
|
275
|
+
const releaseLockAndRethrow = (err) => {
|
|
276
|
+
releaseJoinLock(opts.lockPath);
|
|
277
|
+
throw err;
|
|
278
|
+
};
|
|
265
279
|
const parser = new FrameParser();
|
|
266
280
|
let shuttingDown = false;
|
|
267
281
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
@@ -603,7 +617,13 @@ export function startJoin(opts) {
|
|
|
603
617
|
}
|
|
604
618
|
const plainDispatcher = makeInnerDispatcher(sendTunnelFrame, { jsPatch: () => uiCompat.trustE2EEAsLoopback, gate: true });
|
|
605
619
|
// host 端 E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
|
|
606
|
-
|
|
620
|
+
let hostE2eeKeypair;
|
|
621
|
+
try {
|
|
622
|
+
hostE2eeKeypair = loadOrCreateE2eeKeyPair();
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
releaseLockAndRethrow(err);
|
|
626
|
+
}
|
|
607
627
|
const rawStreams = new Map();
|
|
608
628
|
function startRawStream(streamId) {
|
|
609
629
|
const inner = makeInnerDispatcher((frame) => {
|
|
@@ -733,7 +753,6 @@ export function startJoin(opts) {
|
|
|
733
753
|
const url = `${hubWsBase}/tunnel`;
|
|
734
754
|
const client = new WebSocket(url, { headers: { authorization: `Bearer ${opts.token}` }, rejectUnauthorized: !opts.insecure });
|
|
735
755
|
currentClient = client;
|
|
736
|
-
setState("connecting", { message: `connecting to ${opts.hubUrl}` });
|
|
737
756
|
// 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
|
|
738
757
|
// 需手动 terminate → 触发 close → 决定「重配对」还是「普通重连」。
|
|
739
758
|
let tokenRejected = false;
|
|
@@ -826,8 +845,16 @@ export function startJoin(opts) {
|
|
|
826
845
|
/* 已关闭 */
|
|
827
846
|
}
|
|
828
847
|
});
|
|
848
|
+
// 状态回调放在所有监听器挂好**之后**:embedder 的 onState 若抛错,异常会向上抛并被
|
|
849
|
+
// releaseLockAndRethrow 处理;此时 socket 已有 error 监听,不会因后续连接失败变成未捕获 error 崩进程。
|
|
850
|
+
setState("connecting", { message: `connecting to ${opts.hubUrl}` });
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
connect();
|
|
854
|
+
}
|
|
855
|
+
catch (err) {
|
|
856
|
+
releaseLockAndRethrow(err);
|
|
829
857
|
}
|
|
830
|
-
connect();
|
|
831
858
|
return {
|
|
832
859
|
setUiCompat(trustE2EEAsLoopback) {
|
|
833
860
|
uiCompat.trustE2EEAsLoopback = trustE2EEAsLoopback;
|
|
@@ -878,6 +905,10 @@ export async function join(opts) {
|
|
|
878
905
|
console.warn("rdsh join: dsh 0.1.2+ 会话 cookie 换发失败——远程访问将返回 401。");
|
|
879
906
|
}
|
|
880
907
|
}
|
|
908
|
+
// 自检:目录选择器必须是浏览器内形态(否则远端浏览器只会看到弹在宿主屏幕上的原生对话框)
|
|
909
|
+
if ((await checkRemotePickerGraph(dsh.port, dshAuthCookieHeader)) === false) {
|
|
910
|
+
console.warn(`\n⚠ ${remotePickerWarning()}\n`);
|
|
911
|
+
}
|
|
881
912
|
// 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
|
|
882
913
|
const { token, insecure, name } = await registerJoin(opts);
|
|
883
914
|
console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
|
package/dist/lock.d.ts
CHANGED
|
@@ -5,8 +5,11 @@ export interface JoinLock {
|
|
|
5
5
|
role: JoinLockRole;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* 读锁(**纯读,无副作用**):不存在 / 损坏 / pid 已死(stale)→ null。
|
|
9
9
|
* 供面板「外部托管」态判定(role=cli)与 acquire 前检测复用。
|
|
10
|
+
*
|
|
11
|
+
* 注意:不再顺手删除 stale 文件(旧实现有此行为且无二次校验,会误删他人新锁);
|
|
12
|
+
* 清理统一由 acquire 路径的 `reapDeadLock()` 以 compare-and-delete 完成。
|
|
10
13
|
*/
|
|
11
14
|
export declare function readJoinLock(path?: string): JoinLock | null;
|
|
12
15
|
export type AcquireResult = {
|
|
@@ -14,11 +17,21 @@ export type AcquireResult = {
|
|
|
14
17
|
} | {
|
|
15
18
|
ok: false;
|
|
16
19
|
heldBy: JoinLock;
|
|
20
|
+
}
|
|
21
|
+
/** 连续被抢占(拿不到也读不到持有者,极罕见):不伪造持有者,由调用方给出「被占用」提示。 */
|
|
22
|
+
| {
|
|
23
|
+
ok: false;
|
|
24
|
+
contended: true;
|
|
17
25
|
};
|
|
18
26
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
27
|
+
* 获取锁:**原子发布**锁文件(临时文件 + `link`;目录 0700、文件 0600),内容 `{pid, role}`。
|
|
28
|
+
*
|
|
29
|
+
* - 已有活锁(**无论 pid 是否为自己**)→ 拒绝,防同机 / 同进程双隧道;
|
|
30
|
+
* - stale(pid 已死)或损坏残锁 → compare-and-delete 清除后重试;
|
|
31
|
+
* - 文件系统不支持硬链接 → 退回 `wx` 创建(旧实现在这些文件系统上就是这个行为,
|
|
32
|
+
* 短暂的"空文件窗口"由 `reapDeadLock` 的新鲜度保护兜住);
|
|
33
|
+
* - 连续 3 次被抢占 → `{ok:false, contended:true}`。
|
|
21
34
|
*/
|
|
22
35
|
export declare function acquireJoinLock(role: JoinLockRole, path?: string): AcquireResult;
|
|
23
|
-
/** 释放锁:仅当锁是自己 pid
|
|
36
|
+
/** 释放锁:仅当锁是自己 pid 持有才删(不误删他人锁);同时清本进程记账。 */
|
|
24
37
|
export declare function releaseJoinLock(path?: string): void;
|
package/dist/lock.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lock.ts — join pid 锁:CLI 与插件共享「单身份铁律」(同机单隧道,D5 档2)。
|
|
3
3
|
*
|
|
4
|
-
* 锁文件 `~/.rdsh/join.lock`(0600)记 `{pid, role}
|
|
4
|
+
* 锁文件 `~/.rdsh/join.lock`(0600)记 `{pid, role}`。
|
|
5
5
|
* role = "cli"(rdsh CLI / systemd 服务持有)| "plugin"(dsh 插件持有)——面板据此显示「外部托管」。
|
|
6
|
+
*
|
|
7
|
+
* 三条不变量(2026-09-15 真机复盘:多个 `dsh web` 实例互相顶替导致面板反复掉线/连线):
|
|
8
|
+
* 1. **原子发布**「文件 + 内容」:先写临时文件再 `link` —— 锁路径要么不存在、要么内容完整,
|
|
9
|
+
* 不存在「已存在但内容为空」的窗口(旧实现 `writeFileSync(..., {flag:"wx"})` 有这个窗口,
|
|
10
|
+
* 会被并发清理者当成坏锁删掉,两边都以为自己持锁);
|
|
11
|
+
* 2. **拒绝任何活锁**(包括本进程自己的 pid):同进程重复获取由 `heldPaths` 记账拦住;
|
|
12
|
+
* 3. **清理只走 compare-and-delete**:读 → 判断 → 删除之间不得误删他人刚发布的锁
|
|
13
|
+
* (旧实现 `readJoinLock` 读到 stale 就无条件 `rmSync`,会删掉别人刚建立的新锁)。
|
|
6
14
|
*/
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
8
16
|
import { homedir } from "node:os";
|
|
9
17
|
import { dirname, join } from "node:path";
|
|
10
18
|
const RDSH_DIR = join(homedir(), ".rdsh");
|
|
@@ -19,47 +27,144 @@ function isAlive(pid) {
|
|
|
19
27
|
return err.code === "EPERM";
|
|
20
28
|
}
|
|
21
29
|
}
|
|
30
|
+
/** 解析锁内容;损坏/字段非法 → null。纯函数,不碰文件系统。 */
|
|
31
|
+
function parseLock(raw) {
|
|
32
|
+
try {
|
|
33
|
+
const j = JSON.parse(raw);
|
|
34
|
+
if (typeof j.pid !== "number" || (j.role !== "cli" && j.role !== "plugin"))
|
|
35
|
+
return null;
|
|
36
|
+
return { pid: j.pid, role: j.role };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
22
42
|
/**
|
|
23
|
-
*
|
|
43
|
+
* 读锁(**纯读,无副作用**):不存在 / 损坏 / pid 已死(stale)→ null。
|
|
24
44
|
* 供面板「外部托管」态判定(role=cli)与 acquire 前检测复用。
|
|
45
|
+
*
|
|
46
|
+
* 注意:不再顺手删除 stale 文件(旧实现有此行为且无二次校验,会误删他人新锁);
|
|
47
|
+
* 清理统一由 acquire 路径的 `reapDeadLock()` 以 compare-and-delete 完成。
|
|
25
48
|
*/
|
|
26
49
|
export function readJoinLock(path = JOIN_LOCK_PATH) {
|
|
27
50
|
try {
|
|
28
51
|
if (!existsSync(path))
|
|
29
52
|
return null;
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
53
|
+
const held = parseLock(readFileSync(path, "utf8"));
|
|
54
|
+
if (held === null)
|
|
32
55
|
return null;
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
rmSync(path, { force: true });
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
/* 忽略 */
|
|
39
|
-
}
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
return { pid: raw.pid, role: raw.role };
|
|
56
|
+
return isAlive(held.pid) ? held : null;
|
|
43
57
|
}
|
|
44
58
|
catch {
|
|
45
59
|
return null;
|
|
46
60
|
}
|
|
47
61
|
}
|
|
62
|
+
function snapshotLock(path) {
|
|
63
|
+
try {
|
|
64
|
+
const st = statSync(path);
|
|
65
|
+
return { raw: readFileSync(path, "utf8"), ino: st.ino, mtimeMs: st.mtimeMs };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** 内容解析不出来的锁文件,多新以内视为"并发创建者写到一半"而不回收。 */
|
|
72
|
+
const FRESH_LOCK_MS = 1000;
|
|
73
|
+
/**
|
|
74
|
+
* 清除「确定已经死了」的残锁(pid 已死,或内容损坏且已不新),成功则锁路径变空、可重试获取。
|
|
75
|
+
*
|
|
76
|
+
* 两道保护,避免把别人的**活锁**删掉:
|
|
77
|
+
* 1. compare-and-delete 比对**内容 + inode**(同内容但换了 inode = 别人刚发布的新锁,不删);
|
|
78
|
+
* 2. 内容解析不出来(空/半截)且文件很新 → 视为并发创建者写到一半,不删
|
|
79
|
+
* (本实现的发布是原子的,这种文件只可能来自旧版本或外部写入者)。
|
|
80
|
+
*/
|
|
81
|
+
function reapDeadLock(path) {
|
|
82
|
+
const first = snapshotLock(path);
|
|
83
|
+
if (first === null)
|
|
84
|
+
return;
|
|
85
|
+
const held = parseLock(first.raw);
|
|
86
|
+
if (held !== null && isAlive(held.pid))
|
|
87
|
+
return; // 有效活锁
|
|
88
|
+
if (held === null && Date.now() - first.mtimeMs < FRESH_LOCK_MS)
|
|
89
|
+
return; // 可能是写到一半
|
|
90
|
+
const second = snapshotLock(path);
|
|
91
|
+
if (second === null || second.ino !== first.ino || second.raw !== first.raw)
|
|
92
|
+
return; // 期间被替换
|
|
93
|
+
try {
|
|
94
|
+
rmSync(path, { force: true });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* 忽略 */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* 本进程已持有的锁路径。pid 无法区分同进程的两次获取,必须单独记账。
|
|
102
|
+
*
|
|
103
|
+
* 必要性:插件热重载(`patchReload: "live"`)时同一进程先后起两条隧道 —— 若只按 pid 判断,
|
|
104
|
+
* 「锁是自己」会被放行(旧实现正是如此)→ 同机双隧道 → 与 hub 的顶替逻辑叠加成无限互踢。
|
|
105
|
+
*/
|
|
106
|
+
const heldPaths = new Set();
|
|
107
|
+
/** 文件系统不支持硬链接时 `linkSync` 的 errno(SMB / FUSE / exFAT 之类)。 */
|
|
108
|
+
const LINK_UNSUPPORTED = new Set(["EPERM", "ENOSYS", "ENOTSUP", "EOPNOTSUPP"]);
|
|
48
109
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
110
|
+
* 获取锁:**原子发布**锁文件(临时文件 + `link`;目录 0700、文件 0600),内容 `{pid, role}`。
|
|
111
|
+
*
|
|
112
|
+
* - 已有活锁(**无论 pid 是否为自己**)→ 拒绝,防同机 / 同进程双隧道;
|
|
113
|
+
* - stale(pid 已死)或损坏残锁 → compare-and-delete 清除后重试;
|
|
114
|
+
* - 文件系统不支持硬链接 → 退回 `wx` 创建(旧实现在这些文件系统上就是这个行为,
|
|
115
|
+
* 短暂的"空文件窗口"由 `reapDeadLock` 的新鲜度保护兜住);
|
|
116
|
+
* - 连续 3 次被抢占 → `{ok:false, contended:true}`。
|
|
51
117
|
*/
|
|
52
118
|
export function acquireJoinLock(role, path = JOIN_LOCK_PATH) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
119
|
+
if (heldPaths.has(path)) {
|
|
120
|
+
return { ok: false, heldBy: { pid: process.pid, role } };
|
|
121
|
+
}
|
|
122
|
+
const data = JSON.stringify({ pid: process.pid, role });
|
|
123
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
124
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
125
|
+
try {
|
|
126
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
127
|
+
// 内容先落到临时文件(默认 flag 覆盖写,不会 EEXIST),再 link 到锁路径;
|
|
128
|
+
// link 是原子的:目标已存在则 EEXIST,且目标**一出现就带完整内容**。
|
|
129
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
130
|
+
linkSync(tmp, path);
|
|
131
|
+
heldPaths.add(path);
|
|
132
|
+
return { ok: true };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const code = err.code;
|
|
136
|
+
if (code !== "EEXIST") {
|
|
137
|
+
if (!LINK_UNSUPPORTED.has(code ?? ""))
|
|
138
|
+
throw err;
|
|
139
|
+
try {
|
|
140
|
+
writeFileSync(path, data, { flag: "wx", mode: 0o600 });
|
|
141
|
+
heldPaths.add(path);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
}
|
|
144
|
+
catch (err2) {
|
|
145
|
+
if (err2.code !== "EEXIST")
|
|
146
|
+
throw err2;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
try {
|
|
152
|
+
rmSync(tmp, { force: true }); // 删掉临时名,锁路径(硬链接)不受影响
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* 忽略 */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const held = readJoinLock(path);
|
|
159
|
+
if (held !== null)
|
|
160
|
+
return { ok: false, heldBy: held };
|
|
161
|
+
reapDeadLock(path);
|
|
56
162
|
}
|
|
57
|
-
|
|
58
|
-
writeFileSync(path, JSON.stringify({ pid: process.pid, role }), { mode: 0o600 });
|
|
59
|
-
return { ok: true };
|
|
163
|
+
return { ok: false, contended: true };
|
|
60
164
|
}
|
|
61
|
-
/** 释放锁:仅当锁是自己 pid
|
|
165
|
+
/** 释放锁:仅当锁是自己 pid 持有才删(不误删他人锁);同时清本进程记账。 */
|
|
62
166
|
export function releaseJoinLock(path = JOIN_LOCK_PATH) {
|
|
167
|
+
heldPaths.delete(path);
|
|
63
168
|
try {
|
|
64
169
|
const held = readJoinLock(path);
|
|
65
170
|
if (held !== null && held.pid === process.pid) {
|
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, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning } from "./spawn-dsh.js";
|
|
6
|
+
import { findDsh, spawnDsh, exchangeDshSessionCookie, detectDshVersion, dshVersionWarning, checkRemotePickerGraph, remotePickerWarning } 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";
|
|
@@ -39,6 +39,10 @@ export async function serve(opts) {
|
|
|
39
39
|
console.warn("rdsh: dsh 0.1.2+ 会话 cookie 换发失败——远程访问将返回 401(请确认 dsh web 已就绪或暂用 dsh@0.1.1-rc.2)。");
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
// 自检:目录选择器必须是浏览器内形态(否则远端浏览器只会看到弹在宿主屏幕上的原生对话框)
|
|
43
|
+
if ((await checkRemotePickerGraph(dsh.port, dshAuthCookieHeader)) === false) {
|
|
44
|
+
console.warn(`\n⚠ ${remotePickerWarning()}\n`);
|
|
45
|
+
}
|
|
42
46
|
// TLS 决策:有 tls.cert/key → https;无 → http(behindProxy 或 pair/none)。
|
|
43
47
|
// password + http + 非反代 → server.ts 安全约束拒绝启动(需自行提供证书)。
|
|
44
48
|
const tlsMaterial = await loadTls(config.tls, config.behindProxy);
|
package/dist/spawn-dsh.d.ts
CHANGED
|
@@ -16,6 +16,27 @@ export interface SpawnedDsh {
|
|
|
16
16
|
* 找不到返回 null。
|
|
17
17
|
*/
|
|
18
18
|
export declare function findDsh(override?: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* 注入给 dsh 的「远端操作者」信号值。用可读字面量而非 `/dev/...`:
|
|
21
|
+
* 让 `env` 里一眼看出这是 rdsh 注入的,而不是真实终端路径。
|
|
22
|
+
*/
|
|
23
|
+
export declare const RDSH_REMOTE_TTY = "rdsh-remote";
|
|
24
|
+
/**
|
|
25
|
+
* 构造 spawn dsh 时的环境:保证 dsh 把目录选择器解析为 **browse**(浏览器内目录浏览器)。
|
|
26
|
+
*
|
|
27
|
+
* 事实依据(doc/fix/20260917-remote-workspace-picker/discussion.md F2/F4/F8):
|
|
28
|
+
* `@deepseek-ai/dsh-host-directory-picker-auto` 在 boot 时读 `SSH_CONNECTION`/`SSH_TTY`
|
|
29
|
+
* (仅继承进程层,非空即可)——「操作者看不到宿主显示」正是 rdsh 的场景(等价于
|
|
30
|
+
* SSH 端口转发下的无人值守宿主)。不注入它,宿主是 macOS/Windows/带 DISPLAY 的 Linux
|
|
31
|
+
* 时会解析成 `native`,原生对话框弹在宿主屏幕上、远端浏览器无法操作。
|
|
32
|
+
*
|
|
33
|
+
* 只设 `SSH_TTY`:`SSH_CONNECTION` 有固定 `"ip port ip port"` 格式,写假值会误导解析它的工具。
|
|
34
|
+
* 用户真的在 SSH 会话里启动时(已有非空信号)保持原值不动。
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ 这是上游启发式的输入:上游若改变判定信号,本注入会静默失效(spawn 后自检见
|
|
37
|
+
* `checkRemotePickerGraph` 与 doc/fix/20260917-remote-workspace-picker/solution.md §6)。
|
|
38
|
+
*/
|
|
39
|
+
export declare function dshSpawnEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
19
40
|
/**
|
|
20
41
|
* spawn `dsh web --port 0 --no-open` 并等待其报告监听端口。
|
|
21
42
|
* 就绪后把 dsh 的 stdout/stderr 透传到本进程。
|
|
@@ -29,6 +50,22 @@ export declare function spawnDsh(dshPath: string): Promise<SpawnedDsh>;
|
|
|
29
50
|
* 返回 cookie 的 `name=value` 段(不含属性);换发失败/超时/无 cookie → null(调用方降级)。
|
|
30
51
|
*/
|
|
31
52
|
export declare function exchangeDshSessionCookie(port: number, token: string, timeoutMs?: number): Promise<string | null>;
|
|
53
|
+
/**
|
|
54
|
+
* 自检:spawn 出的 dsh 是否真的把目录选择器解析成了 **browse**。
|
|
55
|
+
*
|
|
56
|
+
* 方法(与 doc/fix/20260917-remote-workspace-picker 的验证手段一致):取一次首页,
|
|
57
|
+
* 在注入的 boot graph 里找浏览器内选择器的客户端模块 —— 在 → 远端浏览器可用;
|
|
58
|
+
* 不在 → 选择器是 `native`(原生对话框弹在宿主屏幕上)。
|
|
59
|
+
*
|
|
60
|
+
* @returns `true`(已挂载 browse)/ `false`(确认未挂载)/ `null`(无法判定:网络、鉴权、超限)
|
|
61
|
+
* 只有 `false` 才值得告警;`null` 静默,避免噪声。
|
|
62
|
+
*/
|
|
63
|
+
export declare function checkRemotePickerGraph(port: number, cookieHeader?: string | null, timeoutMs?: number): Promise<boolean | null>;
|
|
64
|
+
/**
|
|
65
|
+
* 自检失败(确认未挂载浏览器内选择器)时的告警文案。
|
|
66
|
+
* 零文档导向:说清后果 + 直接给动作。
|
|
67
|
+
*/
|
|
68
|
+
export declare function remotePickerWarning(): string;
|
|
32
69
|
/** 探测 dsh 版本号(`dsh --version` 输出如 `0.1.2-rc.1`);失败/不可解析 → null。 */
|
|
33
70
|
export declare function detectDshVersion(dshPath: string, timeoutMs?: number): Promise<string | null>;
|
|
34
71
|
/**
|
package/dist/spawn-dsh.js
CHANGED
|
@@ -42,6 +42,32 @@ export function findDsh(override) {
|
|
|
42
42
|
}
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* 注入给 dsh 的「远端操作者」信号值。用可读字面量而非 `/dev/...`:
|
|
47
|
+
* 让 `env` 里一眼看出这是 rdsh 注入的,而不是真实终端路径。
|
|
48
|
+
*/
|
|
49
|
+
export const RDSH_REMOTE_TTY = "rdsh-remote";
|
|
50
|
+
/**
|
|
51
|
+
* 构造 spawn dsh 时的环境:保证 dsh 把目录选择器解析为 **browse**(浏览器内目录浏览器)。
|
|
52
|
+
*
|
|
53
|
+
* 事实依据(doc/fix/20260917-remote-workspace-picker/discussion.md F2/F4/F8):
|
|
54
|
+
* `@deepseek-ai/dsh-host-directory-picker-auto` 在 boot 时读 `SSH_CONNECTION`/`SSH_TTY`
|
|
55
|
+
* (仅继承进程层,非空即可)——「操作者看不到宿主显示」正是 rdsh 的场景(等价于
|
|
56
|
+
* SSH 端口转发下的无人值守宿主)。不注入它,宿主是 macOS/Windows/带 DISPLAY 的 Linux
|
|
57
|
+
* 时会解析成 `native`,原生对话框弹在宿主屏幕上、远端浏览器无法操作。
|
|
58
|
+
*
|
|
59
|
+
* 只设 `SSH_TTY`:`SSH_CONNECTION` 有固定 `"ip port ip port"` 格式,写假值会误导解析它的工具。
|
|
60
|
+
* 用户真的在 SSH 会话里启动时(已有非空信号)保持原值不动。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ 这是上游启发式的输入:上游若改变判定信号,本注入会静默失效(spawn 后自检见
|
|
63
|
+
* `checkRemotePickerGraph` 与 doc/fix/20260917-remote-workspace-picker/solution.md §6)。
|
|
64
|
+
*/
|
|
65
|
+
export function dshSpawnEnv(base = process.env) {
|
|
66
|
+
const hasSignal = (base.SSH_TTY ?? "") !== "" || (base.SSH_CONNECTION ?? "") !== "";
|
|
67
|
+
if (hasSignal)
|
|
68
|
+
return { ...base };
|
|
69
|
+
return { ...base, SSH_TTY: RDSH_REMOTE_TTY };
|
|
70
|
+
}
|
|
45
71
|
/**
|
|
46
72
|
* spawn `dsh web --port 0 --no-open` 并等待其报告监听端口。
|
|
47
73
|
* 就绪后把 dsh 的 stdout/stderr 透传到本进程。
|
|
@@ -51,6 +77,7 @@ export function spawnDsh(dshPath) {
|
|
|
51
77
|
return new Promise((resolve, reject) => {
|
|
52
78
|
const child = spawn(dshPath, ["web", "--port", "0", "--no-open"], {
|
|
53
79
|
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
env: dshSpawnEnv(),
|
|
54
81
|
});
|
|
55
82
|
let stderrBuf = "";
|
|
56
83
|
const timeout = setTimeout(() => {
|
|
@@ -124,6 +151,66 @@ export function exchangeDshSessionCookie(port, token, timeoutMs = EXCHANGE_TIMEO
|
|
|
124
151
|
});
|
|
125
152
|
});
|
|
126
153
|
}
|
|
154
|
+
const GRAPH_CHECK_TIMEOUT_MS = 5_000;
|
|
155
|
+
const MAX_GRAPH_BYTES = 512 * 1024;
|
|
156
|
+
/** boot graph 里浏览器内目录选择器的客户端模块标识(命中即说明 picker = browse)。 */
|
|
157
|
+
const BROWSE_PICKER_MODULE = "@deepseek-ai/dsh-client-ui-directory-picker-browse";
|
|
158
|
+
/**
|
|
159
|
+
* 自检:spawn 出的 dsh 是否真的把目录选择器解析成了 **browse**。
|
|
160
|
+
*
|
|
161
|
+
* 方法(与 doc/fix/20260917-remote-workspace-picker 的验证手段一致):取一次首页,
|
|
162
|
+
* 在注入的 boot graph 里找浏览器内选择器的客户端模块 —— 在 → 远端浏览器可用;
|
|
163
|
+
* 不在 → 选择器是 `native`(原生对话框弹在宿主屏幕上)。
|
|
164
|
+
*
|
|
165
|
+
* @returns `true`(已挂载 browse)/ `false`(确认未挂载)/ `null`(无法判定:网络、鉴权、超限)
|
|
166
|
+
* 只有 `false` 才值得告警;`null` 静默,避免噪声。
|
|
167
|
+
*/
|
|
168
|
+
export function checkRemotePickerGraph(port, cookieHeader = null, timeoutMs = GRAPH_CHECK_TIMEOUT_MS) {
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
const headers = { Host: `127.0.0.1:${port}` };
|
|
171
|
+
if (cookieHeader !== null)
|
|
172
|
+
headers.Cookie = cookieHeader;
|
|
173
|
+
let settled = false;
|
|
174
|
+
const finish = (value) => {
|
|
175
|
+
if (settled)
|
|
176
|
+
return;
|
|
177
|
+
settled = true;
|
|
178
|
+
resolve(value);
|
|
179
|
+
};
|
|
180
|
+
const req = get({ host: "127.0.0.1", port, path: "/", headers }, (res) => {
|
|
181
|
+
if (res.statusCode !== 200) {
|
|
182
|
+
res.resume();
|
|
183
|
+
finish(null);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
let body = "";
|
|
187
|
+
res.setEncoding("utf8");
|
|
188
|
+
res.on("data", (chunk) => {
|
|
189
|
+
body += chunk;
|
|
190
|
+
if (body.length > MAX_GRAPH_BYTES) {
|
|
191
|
+
req.destroy();
|
|
192
|
+
finish(null);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
res.on("end", () => finish(body.includes(BROWSE_PICKER_MODULE)));
|
|
196
|
+
res.on("error", () => finish(null));
|
|
197
|
+
});
|
|
198
|
+
req.on("error", () => finish(null));
|
|
199
|
+
req.setTimeout(timeoutMs, () => {
|
|
200
|
+
req.destroy();
|
|
201
|
+
finish(null);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* 自检失败(确认未挂载浏览器内选择器)时的告警文案。
|
|
207
|
+
* 零文档导向:说清后果 + 直接给动作。
|
|
208
|
+
*/
|
|
209
|
+
export function remotePickerWarning() {
|
|
210
|
+
return ("rdsh: 远端浏览器的目录选择器不是浏览器内目录浏览器(很可能是宿主原生对话框,远端无法操作)。" +
|
|
211
|
+
"若装了 dsh-web-remote 插件,请检查 profile 的 cordis.patch.yml 是否被其它 patch 层覆盖(目录选择器只允许一个 pin 通道)。" +
|
|
212
|
+
"否则可能是 dsh 版本变更导致远端信号失效——升级 remote-dsh:npm i -g remote-dsh@latest。");
|
|
213
|
+
}
|
|
127
214
|
/** 探测 dsh 版本号(`dsh --version` 输出如 `0.1.2-rc.1`);失败/不可解析 → null。 */
|
|
128
215
|
export function detectDshVersion(dshPath, timeoutMs = VERSION_TIMEOUT_MS) {
|
|
129
216
|
return new Promise((resolve) => {
|