rdsh-gateway 0.2.2 → 0.3.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.
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type AuthMode = "pair" | "password" | "none";
2
+ export type HostMode = "lan" | "cloud" | "join";
2
3
  export interface TlsConfig {
3
4
  cert: string;
4
5
  key: string;
@@ -15,6 +16,8 @@ export interface AuthConfig {
15
16
  users: AuthUser[];
16
17
  }
17
18
  export interface RdshConfig {
19
+ /** 运行模式:lan/cloud = 独立服务;join = 出站隧道 */
20
+ mode: HostMode;
18
21
  host: string;
19
22
  port: number;
20
23
  sessionTtlSeconds: number;
@@ -23,11 +26,17 @@ export interface RdshConfig {
23
26
  allowFrom: string[];
24
27
  auth: AuthConfig;
25
28
  dshPath?: string;
29
+ /** join 模式字段 */
30
+ hub?: string;
31
+ name?: string;
32
+ insecure?: boolean;
26
33
  }
27
- export declare const DEFAULT_CONFIG_PATH: string;
28
- /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认)。 */
34
+ export declare const DEFAULT_HOST_CONFIG_PATH: string;
35
+ /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
29
36
  export declare function resolveConfigPath(cliPath?: string, env?: NodeJS.ProcessEnv): string;
30
- /** 加载并校验配置;文件不存在时返回默认值。 */
37
+ /** 原子写回配置(tmp + rename,0600)。 */
38
+ export declare function saveConfig(path: string, config: RdshConfig): Promise<void>;
39
+ /** 加载并校验配置;默认路径下文件不存在时尝试迁移旧 config.json,否则返回默认值。 */
31
40
  export declare function loadConfig(path: string): Promise<RdshConfig>;
32
41
  /** 校验并规范化任意输入(测试/CLI 覆盖复用)。 */
33
42
  export declare function normalizeConfig(raw: unknown, source?: string): RdshConfig;
package/dist/config.js CHANGED
@@ -1,15 +1,20 @@
1
1
  /**
2
- * config.ts — 配置加载/默认/校验。持久配置唯一来源(~/.rdsh/config.json)。
2
+ * config.ts — host 配置加载/默认/校验/迁移。持久配置唯一来源(~/.rdsh/host.json,3 模式)。
3
3
  *
4
+ * 模式:`mode = "lan" | "cloud" | "join"`(lan/cloud = 独立服务;join = 出站隧道)。
4
5
  * 优先级:CLI 参数 > config 文件 > 默认值。
5
- * 路径:`--config <path>` > `$RDSH_CONFIG` > 默认 `~/.rdsh/config.json`。
6
+ * 路径:`--config <path>` > `$RDSH_CONFIG` > 默认 `~/.rdsh/host.json`。
7
+ * 迁移:默认路径下 host.json 不存在但旧 `~/.rdsh/config.json` 存在时,按 tls/auth.mode 推断 mode 并写回 host.json(原文件保留)。
6
8
  */
7
- import { readFile } from "node:fs/promises";
9
+ import { readFile, writeFile, rename } from "node:fs/promises";
8
10
  import { homedir } from "node:os";
9
11
  import { join } from "node:path";
10
- export const DEFAULT_CONFIG_PATH = join(homedir(), ".rdsh", "config.json");
12
+ export const DEFAULT_HOST_CONFIG_PATH = join(homedir(), ".rdsh", "host.json");
13
+ /** 旧版 serve 配置(迁移源,保留不删)。 */
14
+ const LEGACY_CONFIG_PATH = join(homedir(), ".rdsh", "config.json");
11
15
  const DEFAULT_AUTH = { mode: "pair", version: 1, users: [] };
12
16
  const DEFAULTS = {
17
+ mode: "lan",
13
18
  host: "0.0.0.0",
14
19
  port: 8443,
15
20
  sessionTtlSeconds: 12 * 3600,
@@ -17,11 +22,17 @@ const DEFAULTS = {
17
22
  allowFrom: [],
18
23
  auth: DEFAULT_AUTH,
19
24
  };
20
- /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认)。 */
25
+ /** 解析配置文件路径(--config > $RDSH_CONFIG > 默认 host.json)。 */
21
26
  export function resolveConfigPath(cliPath, env = process.env) {
22
- return cliPath ?? env.RDSH_CONFIG ?? DEFAULT_CONFIG_PATH;
27
+ return cliPath ?? env.RDSH_CONFIG ?? DEFAULT_HOST_CONFIG_PATH;
23
28
  }
24
- /** 加载并校验配置;文件不存在时返回默认值。 */
29
+ /** 原子写回配置(tmp + rename,0600)。 */
30
+ export async function saveConfig(path, config) {
31
+ const tmp = `${path}.tmp`;
32
+ await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
33
+ await rename(tmp, path);
34
+ }
35
+ /** 加载并校验配置;默认路径下文件不存在时尝试迁移旧 config.json,否则返回默认值。 */
25
36
  export async function loadConfig(path) {
26
37
  let raw = {};
27
38
  try {
@@ -32,10 +43,29 @@ export async function loadConfig(path) {
32
43
  if (code !== "ENOENT") {
33
44
  throw new Error(`failed to read config ${path}: ${err.message}`);
34
45
  }
35
- // ENOENT → 默认配置
46
+ // ENOENT:默认路径下迁移旧 config.json host.json(幂等:写回含 mode 的规范化配置)
47
+ if (path === DEFAULT_HOST_CONFIG_PATH) {
48
+ const legacy = await readLegacyConfig();
49
+ if (legacy !== null) {
50
+ await writeFile(path, `${JSON.stringify(normalizeConfig(legacy, LEGACY_CONFIG_PATH), null, 2)}\n`, { mode: 0o600 });
51
+ raw = legacy;
52
+ }
53
+ }
36
54
  }
37
55
  return normalizeConfig(raw, path);
38
56
  }
57
+ /** 读取旧 ~/.rdsh/config.json(不存在 → null;坏 JSON → 抛错)。 */
58
+ async function readLegacyConfig() {
59
+ try {
60
+ return JSON.parse(await readFile(LEGACY_CONFIG_PATH, "utf8"));
61
+ }
62
+ catch (err) {
63
+ const code = err.code;
64
+ if (code === "ENOENT")
65
+ return null;
66
+ throw new Error(`failed to read legacy config ${LEGACY_CONFIG_PATH}: ${err.message}`);
67
+ }
68
+ }
39
69
  /** 校验并规范化任意输入(测试/CLI 覆盖复用)。 */
40
70
  export function normalizeConfig(raw, source = "config") {
41
71
  if (typeof raw !== "object" || raw === null) {
@@ -46,6 +76,18 @@ export function normalizeConfig(raw, source = "config") {
46
76
  ...DEFAULTS,
47
77
  auth: { ...DEFAULT_AUTH, users: [] },
48
78
  };
79
+ // ---- mode(三态;缺省按 tls/auth.mode 推断,兼容旧 config.json)----
80
+ if (cfg.mode !== undefined) {
81
+ if (cfg.mode !== "lan" && cfg.mode !== "cloud" && cfg.mode !== "join") {
82
+ throw new Error(`${source}: "mode" must be lan|cloud|join`);
83
+ }
84
+ out.mode = cfg.mode;
85
+ }
86
+ else {
87
+ const tls = cfg.tls;
88
+ const authMode = cfg.auth?.mode;
89
+ out.mode = tls !== undefined || authMode === "password" ? "cloud" : "lan";
90
+ }
49
91
  if (cfg.host !== undefined) {
50
92
  assertString(cfg.host, "host", source);
51
93
  out.host = cfg.host;
@@ -114,6 +156,20 @@ export function normalizeConfig(raw, source = "config") {
114
156
  });
115
157
  }
116
158
  }
159
+ // ---- join 字段 ----
160
+ if (cfg.hub !== undefined) {
161
+ assertString(cfg.hub, "hub", source);
162
+ out.hub = cfg.hub;
163
+ }
164
+ if (cfg.name !== undefined) {
165
+ assertString(cfg.name, "name", source);
166
+ out.name = cfg.name;
167
+ }
168
+ if (cfg.insecure !== undefined) {
169
+ if (typeof cfg.insecure !== "boolean")
170
+ throw new Error(`${source}: "insecure" must be boolean`);
171
+ out.insecure = cfg.insecure;
172
+ }
117
173
  if (cfg.dshPath !== undefined) {
118
174
  assertString(cfg.dshPath, "dshPath", source);
119
175
  out.dshPath = cfg.dshPath;
package/dist/index.d.ts CHANGED
@@ -14,14 +14,16 @@ export { findDsh, spawnDsh } 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";
17
- export { loadConfig, normalizeConfig, resolveConfigPath, DEFAULT_CONFIG_PATH } from "./config.ts";
18
- export type { RdshConfig, AuthMode, AuthUser, AuthConfig, TlsConfig } from "./config.ts";
17
+ export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, DEFAULT_HOST_CONFIG_PATH } from "./config.ts";
18
+ export type { RdshConfig, AuthMode, AuthUser, AuthConfig, TlsConfig, HostMode } from "./config.ts";
19
19
  export { hashPassword, verifyPassword, UserManager } from "./auth.ts";
20
20
  export { ipInCidrs, parseCidr, ipToInt } from "./cidr.ts";
21
21
  export { loadTls } from "./tls.ts";
22
22
  export type { TlsMaterial } from "./tls.ts";
23
23
  export { loginPageHtml } from "./login-page.ts";
24
- export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist } from "./service.ts";
24
+ export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
25
+ export type { ServiceSpec } from "./service.ts";
25
26
  export declare const NAME = "rdsh-gateway";
26
- export { join } from "./join.ts";
27
- export type { JoinOptions } from "./join.ts";
27
+ export { join, registerJoin, detectInsecure, selfRevoke } from "./join.ts";
28
+ export type { JoinOptions, RegisterOutcome } from "./join.ts";
29
+ export { readPersistedToken, clearPersistedToken } from "./token-store.ts";
package/dist/index.js CHANGED
@@ -9,12 +9,13 @@ export { PairManager } from "./pair.js";
9
9
  export { startGateway } from "./server.js";
10
10
  export { findDsh, spawnDsh } from "./spawn-dsh.js";
11
11
  export { forwardHttp, createUpgradeProxy, rewriteHeadersForDsh } from "./proxy.js";
12
- export { loadConfig, normalizeConfig, resolveConfigPath, DEFAULT_CONFIG_PATH } from "./config.js";
12
+ export { loadConfig, normalizeConfig, resolveConfigPath, saveConfig, DEFAULT_HOST_CONFIG_PATH } from "./config.js";
13
13
  export { hashPassword, verifyPassword, UserManager } from "./auth.js";
14
14
  export { ipInCidrs, parseCidr, ipToInt } from "./cidr.js";
15
15
  export { loadTls } from "./tls.js";
16
16
  export { loginPageHtml } from "./login-page.js";
17
- export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist } from "./service.js";
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 } from "./join.js";
19
+ export { join, registerJoin, detectInsecure, selfRevoke } from "./join.js";
20
+ export { readPersistedToken, clearPersistedToken } from "./token-store.js";
20
21
  //# sourceMappingURL=index.js.map
package/dist/join.d.ts CHANGED
@@ -1,9 +1,24 @@
1
1
  export interface JoinOptions {
2
2
  hubUrl: string;
3
- /** 直填 host token(跳过配对码绑定流程) */
3
+ /** join token(用户级,register 换 host token */
4
4
  token?: string;
5
+ /** 清除持久化 token 并强制重新配对 */
6
+ reset?: boolean;
5
7
  dshPath?: string;
6
- /** 跳过 TLS 证书校验(自签 hub 用;正式证书无需) */
8
+ /** 跳过 TLS 证书校验(自签 hub 用;正式证书无需,缺省自动检测) */
7
9
  insecure?: boolean;
10
+ /** 主机名(注册命名 / host.json) */
11
+ name?: string;
8
12
  }
13
+ /** 注册/接入结果:解析出的 host token + 是否需 insecure。 */
14
+ export interface RegisterOutcome {
15
+ token: string;
16
+ insecure: boolean;
17
+ }
18
+ /** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
19
+ export declare function detectInsecure(hubUrl: string): Promise<boolean>;
20
+ /** 调用 hub self-revoke 注销本机(host 持自己的 host token)。`rdsh host leave` 使用。 */
21
+ export declare function selfRevoke(hubUrl: string, token: string, insecure: boolean): Promise<void>;
22
+ /** 解析 host token(--token 注册 > 持久化复用)+ 自动检测证书;供 CLI 配置命令与 join() 复用。 */
23
+ export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome>;
9
24
  export declare function join(opts: JoinOptions): Promise<void>;
package/dist/join.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * join.ts — `rdsh join <hub-url>`:出站隧道客户端(公网模式,M3)。
3
3
  *
4
- * 流程:spawn dsh(复用)→ 绑定(配对码轮询 --token 直填)→ WSS 隧道(?token=)
4
+ * 流程:spawn dsh(复用)→ 注册(join token → host token)→ WSS 隧道(?token=)
5
5
  * → 帧循环(OPEN http/ws → 本地 dsh 转发 → 响应帧回传)→ 断线指数退避重连。
6
6
  *
7
7
  * 安全:只出站(不监听任何入站端口);hub 认证在层 1,gateway 侧只认隧道内来源。
@@ -12,36 +12,29 @@ 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
- const PENDING_POLL_MS = 5_000;
16
- const BIND_TIMEOUT_MS = 10 * 60 * 1000; // 配对码 10 分钟
17
- const HEARTBEAT_MS = 30_000;
18
- const RECONNECT_BASE_MS = 1_000;
19
- const RECONNECT_MAX_MS = 60_000;
20
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
- /** 绑定流程:POST /api/hosts/pending → 打印配对码 → 轮询取 host token。 */
22
- async function bind(hubUrl, insecure) {
23
- const pendingRes = await hubRequest(hubUrl, "/api/hosts/pending", { method: "POST", insecure });
24
- if (!pendingRes.ok) {
25
- throw new Error(`hub rejected pending request: HTTP ${pendingRes.status}`);
15
+ import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
16
+ /** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
17
+ function isCertError(err) {
18
+ const code = err?.code ?? "";
19
+ return (code.includes("CERT_") ||
20
+ code.includes("TLS") ||
21
+ code.includes("SELF_SIGNED") ||
22
+ code.includes("UNABLE_TO_VERIFY") ||
23
+ code.includes("DEPTH_ZERO"));
24
+ }
25
+ /** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 true(需 insecure)。 */
26
+ export async function detectInsecure(hubUrl) {
27
+ try {
28
+ await hubRequest(hubUrl, "/api/auth/login", { method: "GET", insecure: false });
29
+ return false; // TLS 握手成功
26
30
  }
27
- const pending = pendingRes.body;
28
- console.log(`rdsh join: pair code: ${pending.code}`);
29
- console.log(`rdsh join: sign in to ${hubUrl} and enter this code (10 min) to bind this host.`);
30
- console.log(`rdsh join: waiting for binding...`);
31
- const deadline = Date.now() + BIND_TIMEOUT_MS;
32
- while (Date.now() < deadline) {
33
- await sleep(PENDING_POLL_MS);
34
- const res = await hubRequest(hubUrl, `/api/hosts/pending/${pending.pendingId}`, { method: "GET", insecure });
35
- if (!res.ok)
36
- continue;
37
- const body = res.body;
38
- if (body.status === "bound" && typeof body.token === "string") {
39
- console.log("rdsh join: bound — establishing tunnel...");
40
- return body.token;
41
- }
31
+ catch (err) {
32
+ return isCertError(err);
42
33
  }
43
- throw new Error("binding timed out (10 min): re-run rdsh join to get a new code");
44
34
  }
35
+ const HEARTBEAT_MS = 30_000;
36
+ const RECONNECT_BASE_MS = 1_000;
37
+ const RECONNECT_MAX_MS = 60_000;
45
38
  /** hub HTTP 调用(node:https 支持自签跳过校验 —— undici fetch 不受 NODE_TLS_REJECT_UNAUTHORIZED 影响)。 */
46
39
  function hubRequest(baseUrl, path, opts) {
47
40
  const url = new URL(baseUrl + path);
@@ -70,9 +63,54 @@ function hubRequest(baseUrl, path, opts) {
70
63
  res.on("error", reject);
71
64
  });
72
65
  req.on("error", reject);
73
- req.end(opts.method === "POST" ? "{}" : undefined);
66
+ req.end(opts.method === "POST" ? JSON.stringify(opts.body ?? {}) : undefined);
74
67
  });
75
68
  }
69
+ /** 调用 hub self-revoke 注销本机(host 持自己的 host token)。`rdsh host leave` 使用。 */
70
+ export async function selfRevoke(hubUrl, token, insecure) {
71
+ const res = await hubRequest(hubUrl, "/api/hosts/self-revoke", { method: "POST", insecure, body: { token } });
72
+ if (!res.ok) {
73
+ const msg = res.body.error?.message ?? `HTTP ${res.status}`;
74
+ throw new Error(`hub rejected self-revoke: ${msg}`);
75
+ }
76
+ }
77
+ /** 解析 host token(--token 注册 > 持久化复用)+ 自动检测证书;供 CLI 配置命令与 join() 复用。 */
78
+ export async function registerJoin(opts) {
79
+ const insecure = opts.insecure === true || (await detectInsecure(opts.hubUrl));
80
+ let token;
81
+ if (opts.token !== undefined) {
82
+ // --token = join token(或旧 host token)→ register 端点换 host token
83
+ const { hostToken } = await register(opts.hubUrl, opts.token, opts.name, insecure);
84
+ token = hostToken;
85
+ persistToken(opts.hubUrl, token);
86
+ }
87
+ else {
88
+ if (opts.reset === true)
89
+ clearPersistedToken(opts.hubUrl);
90
+ const persisted = readPersistedToken(opts.hubUrl);
91
+ if (persisted !== null) {
92
+ token = persisted;
93
+ console.log("rdsh join: reusing persisted host token");
94
+ }
95
+ else {
96
+ throw new Error("未接入:无持久化 session 且未提供 --token;先 `rdsh host join <hub>` 生成/粘贴 join token");
97
+ }
98
+ }
99
+ return { token, insecure };
100
+ }
101
+ /** 调 register 端点:join token → host token(对旧 host token 幂等返回同一 token)。 */
102
+ async function register(hubUrl, joinToken, name, insecure) {
103
+ const res = await hubRequest(hubUrl, "/api/hosts/register", { method: "POST", insecure, body: { token: joinToken, name } });
104
+ if (!res.ok) {
105
+ const msg = res.body.error?.message ?? `HTTP ${res.status}`;
106
+ throw new Error(`hub rejected register: ${msg}`);
107
+ }
108
+ const b = res.body;
109
+ if (typeof b.hostId !== "string" || typeof b.hostToken !== "string") {
110
+ throw new Error("hub register returned malformed response");
111
+ }
112
+ return { hostId: b.hostId, hostToken: b.hostToken };
113
+ }
76
114
  export async function join(opts) {
77
115
  const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
78
116
  const foundDsh = findDsh(opts.dshPath);
@@ -81,7 +119,9 @@ export async function join(opts) {
81
119
  }
82
120
  const dsh = await spawnDsh(foundDsh);
83
121
  const target = { host: "127.0.0.1", port: dsh.port };
84
- const token = opts.token ?? (await bind(opts.hubUrl, opts.insecure === true));
122
+ // 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
123
+ const { token: initialToken, insecure } = await registerJoin(opts);
124
+ let token = initialToken;
85
125
  console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
86
126
  console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
87
127
  const parser = new FrameParser();
@@ -92,15 +132,16 @@ export async function join(opts) {
92
132
  let shuttingDown = false;
93
133
  let reconnectDelay = RECONNECT_BASE_MS;
94
134
  let heartbeat;
95
- const shutdown = async (signal) => {
135
+ const shutdown = async (signal, code = 0) => {
96
136
  if (shuttingDown)
97
137
  return;
98
138
  shuttingDown = true;
99
- console.log(`\nrdsh: received ${signal}, shutting down...`);
139
+ if (signal !== "")
140
+ console.log(`\nrdsh: received ${signal}, shutting down...`);
100
141
  if (heartbeat !== undefined)
101
142
  clearInterval(heartbeat);
102
143
  await dsh.stop();
103
- process.exit(0);
144
+ process.exit(code);
104
145
  };
105
146
  process.on("SIGINT", () => void shutdown("SIGINT"));
106
147
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
@@ -269,7 +310,20 @@ export async function join(opts) {
269
310
  if (shuttingDown)
270
311
  return;
271
312
  const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(token)}`;
272
- const client = new WebSocket(url, { rejectUnauthorized: opts.insecure !== true });
313
+ const client = new WebSocket(url, { rejectUnauthorized: !insecure });
314
+ // 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
315
+ // 需手动 terminate → 触发 close → 决定「重配对」还是「普通重连」。
316
+ let tokenRejected = false;
317
+ client.on("unexpected-response", (_req, res) => {
318
+ if (res.statusCode === 401 || res.statusCode === 403)
319
+ tokenRejected = true;
320
+ try {
321
+ client.terminate();
322
+ }
323
+ catch {
324
+ /* 已关闭 */
325
+ }
326
+ });
273
327
  client.on("open", () => {
274
328
  reconnectDelay = RECONNECT_BASE_MS;
275
329
  console.log("rdsh join: tunnel established (heartbeat 30s)");
@@ -325,6 +379,14 @@ export async function join(opts) {
325
379
  wsStreams.clear();
326
380
  if (shuttingDown)
327
381
  return;
382
+ if (tokenRejected) {
383
+ // token 被拒(吊销/删除)= 永久失败,无法自动恢复(已移除配对码重配)
384
+ // → 删旧 session + fail-fast,让 systemd/脚本拿到非零退出码与明确报错。
385
+ clearPersistedToken(opts.hubUrl);
386
+ console.error("rdsh join: host token rejected by hub (revoked or removed); re-run `rdsh host join <hub>` with a new join token.");
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);
package/dist/service.d.ts CHANGED
@@ -1,11 +1,31 @@
1
+ /** serve/hub 共用的服务名。 */
1
2
  export declare const SERVICE_NAME = "rdsh";
2
- /** systemd 用户级 unit 模板。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
3
- export declare function systemdUnit(execStart: string, configPath: string, subcommandArgs?: string[]): string;
3
+ /** join 的独立服务名(同机可与 hub rdsh.service 并存)。 */
4
+ export declare const JOIN_SERVICE_NAME = "rdsh-join";
5
+ /** host 独立服务(lan/cloud)的独立服务名。 */
6
+ export declare const HOST_SERVICE_NAME = "rdsh-host";
7
+ /** hub 的独立服务名(与 host/join 区分,避免共用 rdsh.service)。 */
8
+ export declare const HUB_SERVICE_NAME = "rdsh-hub";
9
+ /** 服务化规格。 */
10
+ export interface ServiceSpec {
11
+ /** 服务名(systemd unit 文件名 / launchd Label)。 */
12
+ name: string;
13
+ /** 命令参数(不含 --config)。如 ["serve"] / ["hub","serve"] / ["join",hubUrl,"--dsh",abs]。 */
14
+ args: string[];
15
+ /** 配置文件路径;提供则追加 `--config <path>`(serve/hub)。 */
16
+ configPath?: string;
17
+ /** 环境文件路径;提供则在 systemd unit 追加 `EnvironmentFile=-<path>`(launchd 暂不支持,忽略)。 */
18
+ envFile?: string;
19
+ /** 子进程(spawn dsh)所需的 PATH(nvm/自装 node 环境下补 node 目录,防 dsh shebang 127)。 */
20
+ pathEnv?: string;
21
+ }
22
+ /** systemd 用户级 unit 模板。 */
23
+ export declare function systemdUnit(execStart: string, spec: ServiceSpec): string;
4
24
  /** launchd plist 模板。 */
5
- export declare function launchdPlist(execStart: string, configPath: string, subcommandArgs?: string[]): string;
6
- /** 安装并启动服务(用户级)。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
7
- export declare function installService(configPath: string, subcommandArgs?: string[]): Promise<string>;
25
+ export declare function launchdPlist(execStart: string, spec: ServiceSpec): string;
26
+ /** 安装并启动服务(用户级)。 */
27
+ export declare function installService(spec: ServiceSpec): Promise<string>;
8
28
  /** 服务状态。 */
9
- export declare function serviceStatus(): Promise<string>;
29
+ export declare function serviceStatus(name?: string): Promise<string>;
10
30
  /** 停止并移除服务。 */
11
- export declare function uninstallService(): Promise<string>;
31
+ export declare function uninstallService(name?: string): Promise<string>;
package/dist/service.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * 设计(roadmap M2):不自带 fork 后台 —— 交给系统进程管理器托管 rdsh(连带其
5
5
  * spawn 的 dsh);用户级安装(~/.config/systemd/user / ~/Library/LaunchAgents),
6
6
  * 无需 sudo;开机自启 + 崩溃重启(Restart=on-failure / KeepAlive)。
7
+ *
8
+ * 服务名:serve/hub 共用 "rdsh";join 用 "rdsh-join"(同机可与 hub 并存,互不覆盖)。
7
9
  */
8
10
  import { mkdir, rm, writeFile } from "node:fs/promises";
9
11
  import { homedir } from "node:os";
@@ -11,27 +13,44 @@ import { join } from "node:path";
11
13
  import { execFile } from "node:child_process";
12
14
  import { promisify } from "node:util";
13
15
  const execFileP = promisify(execFile);
16
+ /** serve/hub 共用的服务名。 */
14
17
  export const SERVICE_NAME = "rdsh";
18
+ /** join 的独立服务名(同机可与 hub 的 rdsh.service 并存)。 */
19
+ export const JOIN_SERVICE_NAME = "rdsh-join";
20
+ /** host 独立服务(lan/cloud)的独立服务名。 */
21
+ export const HOST_SERVICE_NAME = "rdsh-host";
22
+ /** hub 的独立服务名(与 host/join 区分,避免共用 rdsh.service)。 */
23
+ export const HUB_SERVICE_NAME = "rdsh-hub";
15
24
  const SYSTEMD_DIR = join(homedir(), ".config", "systemd", "user");
16
- const SYSTEMD_UNIT = join(SYSTEMD_DIR, "rdsh.service");
17
25
  const LAUNCHD_DIR = join(homedir(), "Library", "LaunchAgents");
18
- const LAUNCHD_PLIST = join(LAUNCHD_DIR, "com.rdsh.plist");
26
+ function systemdUnitPath(name) {
27
+ return join(SYSTEMD_DIR, `${name}.service`);
28
+ }
29
+ function launchdPlistPath(name) {
30
+ return join(LAUNCHD_DIR, `com.${name}.plist`);
31
+ }
19
32
  function isLinux() {
20
33
  return process.platform === "linux";
21
34
  }
22
- function serviceLogPath() {
23
- return join(homedir(), ".rdsh", "service.log");
35
+ /** 日志文件:rdsh → service.log(历史兼容);其余按服务名(如 rdsh-join.log)。 */
36
+ function serviceLogPath(name) {
37
+ return join(homedir(), ".rdsh", name === SERVICE_NAME ? "service.log" : `${name}.log`);
38
+ }
39
+ /** 命令参数(是否追加 --config)。 */
40
+ function commandArgs(spec) {
41
+ return spec.configPath !== undefined ? [...spec.args, "--config", spec.configPath] : spec.args;
24
42
  }
25
- /** systemd 用户级 unit 模板。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
26
- export function systemdUnit(execStart, configPath, subcommandArgs = ["serve"]) {
27
- const args = [...subcommandArgs, "--config", configPath].join(" ");
43
+ /** systemd 用户级 unit 模板。 */
44
+ export function systemdUnit(execStart, spec) {
45
+ const envLine = spec.envFile !== undefined ? `EnvironmentFile=-${spec.envFile}\n` : "";
46
+ const pathLine = spec.pathEnv !== undefined ? `Environment=PATH=${spec.pathEnv}\n` : "";
28
47
  return `[Unit]
29
48
  Description=rdsh — remote access for DeepSeek Harness
30
49
  After=network.target
31
50
 
32
51
  [Service]
33
52
  Type=simple
34
- ExecStart=${execStart} ${args}
53
+ ${envLine}${pathLine}ExecStart=${execStart} ${commandArgs(spec).join(" ")}
35
54
  Restart=on-failure
36
55
  RestartSec=3
37
56
 
@@ -40,14 +59,22 @@ WantedBy=default.target
40
59
  `;
41
60
  }
42
61
  /** launchd plist 模板。 */
43
- export function launchdPlist(execStart, configPath, subcommandArgs = ["serve"]) {
44
- const args = [...subcommandArgs, "--config", configPath].map((a) => ` <string>${a}</string>`).join("\n");
62
+ export function launchdPlist(execStart, spec) {
63
+ const args = commandArgs(spec).map((a) => ` <string>${a}</string>`).join("\n");
64
+ const envBlock = spec.pathEnv !== undefined
65
+ ? ` <key>EnvironmentVariables</key>
66
+ <dict>
67
+ <key>PATH</key>
68
+ <string>${spec.pathEnv}</string>
69
+ </dict>
70
+ `
71
+ : "";
45
72
  return `<?xml version="1.0" encoding="UTF-8"?>
46
73
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
47
74
  <plist version="1.0">
48
75
  <dict>
49
76
  <key>Label</key>
50
- <string>com.rdsh</string>
77
+ <string>com.${spec.name}</string>
51
78
  <key>ProgramArguments</key>
52
79
  <array>
53
80
  <string>${execStart}</string>
@@ -57,10 +84,10 @@ ${args}
57
84
  <true/>
58
85
  <key>KeepAlive</key>
59
86
  <true/>
60
- <key>StandardOutPath</key>
61
- <string>${serviceLogPath()}</string>
87
+ ${envBlock} <key>StandardOutPath</key>
88
+ <string>${serviceLogPath(spec.name)}</string>
62
89
  <key>StandardErrorPath</key>
63
- <string>${serviceLogPath()}</string>
90
+ <string>${serviceLogPath(spec.name)}</string>
64
91
  </dict>
65
92
  </plist>
66
93
  `;
@@ -74,26 +101,26 @@ async function run(cmd, args) {
74
101
  throw new Error(`${cmd} ${args.join(" ")} failed: ${err.message}`);
75
102
  }
76
103
  }
77
- /** 安装并启动服务(用户级)。subcommandArgs 默认 ["serve"](hub 用 ["hub","serve"])。 */
78
- export async function installService(configPath, subcommandArgs = ["serve"]) {
104
+ /** 安装并启动服务(用户级)。 */
105
+ export async function installService(spec) {
79
106
  const execStart = `${process.execPath} ${process.argv[1]}`;
80
107
  if (isLinux()) {
81
108
  await mkdir(SYSTEMD_DIR, { recursive: true });
82
- await writeFile(SYSTEMD_UNIT, systemdUnit(execStart, configPath, subcommandArgs), { mode: 0o600 });
109
+ await writeFile(systemdUnitPath(spec.name), systemdUnit(execStart, spec), { mode: 0o600 });
83
110
  await run("systemctl", ["--user", "daemon-reload"]);
84
- await run("systemctl", ["--user", "enable", "--now", SERVICE_NAME]);
85
- return `installed systemd user unit: ${SYSTEMD_UNIT}`;
111
+ await run("systemctl", ["--user", "enable", "--now", spec.name]);
112
+ return `installed systemd user unit: ${systemdUnitPath(spec.name)}`;
86
113
  }
87
114
  await mkdir(LAUNCHD_DIR, { recursive: true });
88
- await writeFile(LAUNCHD_PLIST, launchdPlist(execStart, configPath, subcommandArgs), { mode: 0o600 });
89
- await run("launchctl", ["load", LAUNCHD_PLIST]);
90
- return `installed launchd plist: ${LAUNCHD_PLIST}`;
115
+ await writeFile(launchdPlistPath(spec.name), launchdPlist(execStart, spec), { mode: 0o600 });
116
+ await run("launchctl", ["load", launchdPlistPath(spec.name)]);
117
+ return `installed launchd plist: ${launchdPlistPath(spec.name)}`;
91
118
  }
92
119
  /** 服务状态。 */
93
- export async function serviceStatus() {
120
+ export async function serviceStatus(name = SERVICE_NAME) {
94
121
  if (isLinux()) {
95
122
  try {
96
- const active = await run("systemctl", ["--user", "is-active", SERVICE_NAME]);
123
+ const active = await run("systemctl", ["--user", "is-active", name]);
97
124
  return `active: ${active}`;
98
125
  }
99
126
  catch {
@@ -101,7 +128,7 @@ export async function serviceStatus() {
101
128
  }
102
129
  }
103
130
  try {
104
- await run("launchctl", ["print", "com.rdsh"]);
131
+ await run("launchctl", ["print", `com.${name}`]);
105
132
  return "active";
106
133
  }
107
134
  catch {
@@ -109,15 +136,15 @@ export async function serviceStatus() {
109
136
  }
110
137
  }
111
138
  /** 停止并移除服务。 */
112
- export async function uninstallService() {
139
+ export async function uninstallService(name = SERVICE_NAME) {
113
140
  if (isLinux()) {
114
- await run("systemctl", ["--user", "disable", "--now", SERVICE_NAME]).catch(() => undefined);
115
- await rm(SYSTEMD_UNIT, { force: true });
141
+ await run("systemctl", ["--user", "disable", "--now", name]).catch(() => undefined);
142
+ await rm(systemdUnitPath(name), { force: true });
116
143
  await run("systemctl", ["--user", "daemon-reload"]).catch(() => undefined);
117
- return `removed ${SYSTEMD_UNIT}`;
144
+ return `removed ${systemdUnitPath(name)}`;
118
145
  }
119
- await run("launchctl", ["unload", LAUNCHD_PLIST]).catch(() => undefined);
120
- await rm(LAUNCHD_PLIST, { force: true });
121
- return `removed ${LAUNCHD_PLIST}`;
146
+ await run("launchctl", ["unload", launchdPlistPath(name)]).catch(() => undefined);
147
+ await rm(launchdPlistPath(name), { force: true });
148
+ return `removed ${launchdPlistPath(name)}`;
122
149
  }
123
150
  //# sourceMappingURL=service.js.map
@@ -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.3.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",
@@ -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
  }