rdsh-gateway 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/join.d.ts +2 -0
- package/dist/join.js +68 -6
- package/dist/token-store.d.ts +8 -0
- package/dist/token-store.js +50 -0
- package/package.json +2 -2
package/dist/join.d.ts
CHANGED
package/dist/join.js
CHANGED
|
@@ -12,6 +12,7 @@ import { WebSocket } from "ws";
|
|
|
12
12
|
import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload } from "rdsh-tunnel";
|
|
13
13
|
import { findDsh, spawnDsh } from "./spawn-dsh.js";
|
|
14
14
|
import { rewriteHeadersForDsh } from "./proxy.js";
|
|
15
|
+
import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
|
|
15
16
|
const PENDING_POLL_MS = 5_000;
|
|
16
17
|
const BIND_TIMEOUT_MS = 10 * 60 * 1000; // 配对码 10 分钟
|
|
17
18
|
const HEARTBEAT_MS = 30_000;
|
|
@@ -81,7 +82,25 @@ export async function join(opts) {
|
|
|
81
82
|
}
|
|
82
83
|
const dsh = await spawnDsh(foundDsh);
|
|
83
84
|
const target = { host: "127.0.0.1", port: dsh.port };
|
|
84
|
-
|
|
85
|
+
// token 来源优先级:--token 直填 > 持久化复用 > 配对码绑定(绑定成功后落盘)。
|
|
86
|
+
// 进程重启后复用已绑定 token,避免重复配对;被吊销时(401)自动回退重配对。
|
|
87
|
+
let token;
|
|
88
|
+
if (opts.token !== undefined) {
|
|
89
|
+
token = opts.token;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (opts.reset === true)
|
|
93
|
+
clearPersistedToken(opts.hubUrl);
|
|
94
|
+
const persisted = readPersistedToken(opts.hubUrl);
|
|
95
|
+
if (persisted !== null) {
|
|
96
|
+
token = persisted;
|
|
97
|
+
console.log("rdsh join: reusing persisted host token");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
token = await bind(opts.hubUrl, opts.insecure === true);
|
|
101
|
+
persistToken(opts.hubUrl, token);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
85
104
|
console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
|
|
86
105
|
console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
|
|
87
106
|
const parser = new FrameParser();
|
|
@@ -92,15 +111,16 @@ export async function join(opts) {
|
|
|
92
111
|
let shuttingDown = false;
|
|
93
112
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
94
113
|
let heartbeat;
|
|
95
|
-
const shutdown = async (signal) => {
|
|
114
|
+
const shutdown = async (signal, code = 0) => {
|
|
96
115
|
if (shuttingDown)
|
|
97
116
|
return;
|
|
98
117
|
shuttingDown = true;
|
|
99
|
-
|
|
118
|
+
if (signal !== "")
|
|
119
|
+
console.log(`\nrdsh: received ${signal}, shutting down...`);
|
|
100
120
|
if (heartbeat !== undefined)
|
|
101
121
|
clearInterval(heartbeat);
|
|
102
122
|
await dsh.stop();
|
|
103
|
-
process.exit(
|
|
123
|
+
process.exit(code);
|
|
104
124
|
};
|
|
105
125
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
106
126
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
@@ -124,7 +144,7 @@ export async function join(opts) {
|
|
|
124
144
|
const ws = wsStreams.get(frame.streamId);
|
|
125
145
|
if (ws !== undefined) {
|
|
126
146
|
if (ws.upstream.readyState === ws.upstream.OPEN) {
|
|
127
|
-
ws.upstream.send(frame.payload, { binary:
|
|
147
|
+
ws.upstream.send(frame.payload, { binary: false }); // DSH WS 为 text(JSON),保持文本帧
|
|
128
148
|
}
|
|
129
149
|
else {
|
|
130
150
|
ws.queue.push(frame.payload);
|
|
@@ -243,7 +263,7 @@ export async function join(opts) {
|
|
|
243
263
|
wsStreams.set(streamId, { upstream, queue });
|
|
244
264
|
upstream.on("open", () => {
|
|
245
265
|
for (const q of queue)
|
|
246
|
-
upstream.send(q, { binary:
|
|
266
|
+
upstream.send(q, { binary: false });
|
|
247
267
|
queue.length = 0;
|
|
248
268
|
});
|
|
249
269
|
upstream.on("message", (data, isBinary) => {
|
|
@@ -265,11 +285,41 @@ export async function join(opts) {
|
|
|
265
285
|
upstream.on("close", cleanup);
|
|
266
286
|
upstream.on("error", cleanup);
|
|
267
287
|
}
|
|
288
|
+
/** 持久化 token 被 hub 拒绝(吊销/重置)→ 删旧文件 → 重新配对 → 重连。 */
|
|
289
|
+
async function rebindAndReconnect() {
|
|
290
|
+
clearPersistedToken(opts.hubUrl);
|
|
291
|
+
console.log("rdsh join: host token rejected (revoked?) — re-pairing...");
|
|
292
|
+
try {
|
|
293
|
+
token = await bind(opts.hubUrl, opts.insecure === true);
|
|
294
|
+
persistToken(opts.hubUrl, token);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
298
|
+
console.error(`rdsh join: re-pairing failed (${msg}); retrying in ${RECONNECT_BASE_MS / 1000}s...`);
|
|
299
|
+
setTimeout(() => void rebindAndReconnect(), RECONNECT_BASE_MS);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
reconnectDelay = RECONNECT_BASE_MS;
|
|
303
|
+
connect();
|
|
304
|
+
}
|
|
268
305
|
function connect() {
|
|
269
306
|
if (shuttingDown)
|
|
270
307
|
return;
|
|
271
308
|
const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(token)}`;
|
|
272
309
|
const client = new WebSocket(url, { rejectUnauthorized: opts.insecure !== true });
|
|
310
|
+
// 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
|
|
311
|
+
// 需手动 terminate → 触发 close → 决定「重配对」还是「普通重连」。
|
|
312
|
+
let tokenRejected = false;
|
|
313
|
+
client.on("unexpected-response", (_req, res) => {
|
|
314
|
+
if (res.statusCode === 401 || res.statusCode === 403)
|
|
315
|
+
tokenRejected = true;
|
|
316
|
+
try {
|
|
317
|
+
client.terminate();
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
/* 已关闭 */
|
|
321
|
+
}
|
|
322
|
+
});
|
|
273
323
|
client.on("open", () => {
|
|
274
324
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
275
325
|
console.log("rdsh join: tunnel established (heartbeat 30s)");
|
|
@@ -325,6 +375,18 @@ export async function join(opts) {
|
|
|
325
375
|
wsStreams.clear();
|
|
326
376
|
if (shuttingDown)
|
|
327
377
|
return;
|
|
378
|
+
if (tokenRejected) {
|
|
379
|
+
if (opts.token === undefined) {
|
|
380
|
+
// 持久化 token 被拒 → 删旧文件 + 回退配对码
|
|
381
|
+
void rebindAndReconnect();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// 显式 --token 被拒 = 永久失败(token 不会再变有效)→ fail-fast,
|
|
385
|
+
// 让脚本/systemd 拿到非零退出码与明确报错,而非静默无限重连
|
|
386
|
+
console.error("rdsh join: host token rejected by hub (revoked or removed); exiting.");
|
|
387
|
+
void shutdown("", 1);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
328
390
|
console.log(`rdsh join: tunnel lost — reconnecting in ${Math.round(reconnectDelay / 1000)}s...`);
|
|
329
391
|
setTimeout(connect, reconnectDelay + Math.random() * 500);
|
|
330
392
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** token 文件路径:join-<hostname>[-<port>].token(按 hub URL 区分,端口非默认时带端口)。 */
|
|
2
|
+
export declare function tokenFilePath(hubUrl: string, dir?: string): string;
|
|
3
|
+
/** 读取持久化 token;不存在/损坏/过短 → null。 */
|
|
4
|
+
export declare function readPersistedToken(hubUrl: string, dir?: string): string | null;
|
|
5
|
+
/** 写入 token(目录 0700、文件 0600)。 */
|
|
6
|
+
export declare function persistToken(hubUrl: string, token: string, dir?: string): void;
|
|
7
|
+
/** 删除持久化 token(吊销/被拒后清理)。 */
|
|
8
|
+
export declare function clearPersistedToken(hubUrl: string, dir?: string): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token-store.ts — `rdsh join` host token 持久化(~/.rdsh/join-<host>[-<port>].token,0600)。
|
|
3
|
+
*
|
|
4
|
+
* 目的:进程重启/崩溃恢复后复用已绑定的 host token,避免每次重新配对;
|
|
5
|
+
* 被 hub 拒绝(吊销/重置)时由 join 删除该文件并回退到配对码流程。
|
|
6
|
+
*
|
|
7
|
+
* 安全:明文 token 只落 gateway 本地(0600),hub 侧仍只存 SHA-256 摘要。
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
const RDSH_DIR = join(homedir(), ".rdsh");
|
|
13
|
+
/** host token 最小长度(与 hub server.ts handleTunnelUpgrade 的 `token.length < 16` 一致)。 */
|
|
14
|
+
const MIN_TOKEN_LEN = 16;
|
|
15
|
+
/** token 文件路径:join-<hostname>[-<port>].token(按 hub URL 区分,端口非默认时带端口)。 */
|
|
16
|
+
export function tokenFilePath(hubUrl, dir = RDSH_DIR) {
|
|
17
|
+
const u = new URL(hubUrl);
|
|
18
|
+
const host = u.hostname.replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
19
|
+
const port = u.port === "" ? "" : `-${u.port}`;
|
|
20
|
+
return join(dir, `join-${host}${port}.token`);
|
|
21
|
+
}
|
|
22
|
+
/** 读取持久化 token;不存在/损坏/过短 → null。 */
|
|
23
|
+
export function readPersistedToken(hubUrl, dir = RDSH_DIR) {
|
|
24
|
+
try {
|
|
25
|
+
const p = tokenFilePath(hubUrl, dir);
|
|
26
|
+
if (!existsSync(p))
|
|
27
|
+
return null;
|
|
28
|
+
const t = readFileSync(p, "utf8").trim();
|
|
29
|
+
return t.length >= MIN_TOKEN_LEN ? t : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** 写入 token(目录 0700、文件 0600)。 */
|
|
36
|
+
export function persistToken(hubUrl, token, dir = RDSH_DIR) {
|
|
37
|
+
const p = tokenFilePath(hubUrl, dir);
|
|
38
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
|
|
39
|
+
writeFileSync(p, token, { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
/** 删除持久化 token(吊销/被拒后清理)。 */
|
|
42
|
+
export function clearPersistedToken(hubUrl, dir = RDSH_DIR) {
|
|
43
|
+
try {
|
|
44
|
+
rmSync(tokenFilePath(hubUrl, dir), { force: true });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* 文件不存在等,忽略 */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=token-store.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rdsh-gateway",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "rdsh host-side component: LAN auth gateway + outbound tunnel endpoint (spawns dsh web)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc -p tsconfig.json",
|
|
31
|
-
"test": "node --test test
|
|
31
|
+
"test": "node --test \"test/*.test.ts\""
|
|
32
32
|
}
|
|
33
33
|
}
|