rdsh-gateway 0.2.2 → 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 CHANGED
@@ -2,6 +2,8 @@ export interface JoinOptions {
2
2
  hubUrl: string;
3
3
  /** 直填 host token(跳过配对码绑定流程) */
4
4
  token?: string;
5
+ /** 清除持久化 token 并强制重新配对 */
6
+ reset?: boolean;
5
7
  dshPath?: string;
6
8
  /** 跳过 TLS 证书校验(自签 hub 用;正式证书无需) */
7
9
  insecure?: boolean;
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
- const token = opts.token ?? (await bind(opts.hubUrl, opts.insecure === true));
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
- console.log(`\nrdsh: received ${signal}, shutting down...`);
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(0);
123
+ process.exit(code);
104
124
  };
105
125
  process.on("SIGINT", () => void shutdown("SIGINT"));
106
126
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
@@ -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.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
  }