rdsh-gateway 0.8.4 → 0.8.5
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.js +31 -4
- package/dist/lock.d.ts +17 -4
- package/dist/lock.js +129 -24
- package/package.json +1 -1
package/dist/join.js
CHANGED
|
@@ -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;
|
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) {
|