rdsh-gateway 0.3.0 → 0.5.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.
@@ -0,0 +1,5 @@
1
+ import type { KeyPair } from "./e2ee.ts";
2
+ /** 密钥文件路径(可注入 dir 便于测试)。 */
3
+ export declare function e2eeKeyFilePath(dir?: string): string;
4
+ /** 加载或生成 host E2EE 静态密钥对(一次生成,持久化复用;损坏则重新生成)。 */
5
+ export declare function loadOrCreateE2eeKeyPair(dir?: string): KeyPair;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * e2ee-key-store.ts — host 端 E2EE 静态密钥对持久化(~/.rdsh/e2ee-key.json,0600)。
3
+ *
4
+ * 一次生成、跨 hub 复用(host 身份与 join 到哪个 hub 无关);指纹(公钥)在 join 注册时
5
+ * 上送 hub,供 portal「添加主机」pin 展示。
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ import { generateKeyPair, deserializeKeyPair, serializeKeyPair } from "./e2ee.js";
11
+ const RDSH_DIR = join(homedir(), ".rdsh");
12
+ const KEY_FILE_NAME = "e2ee-key.json";
13
+ /** 密钥文件路径(可注入 dir 便于测试)。 */
14
+ export function e2eeKeyFilePath(dir = RDSH_DIR) {
15
+ return join(dir, KEY_FILE_NAME);
16
+ }
17
+ /** 加载或生成 host E2EE 静态密钥对(一次生成,持久化复用;损坏则重新生成)。 */
18
+ export function loadOrCreateE2eeKeyPair(dir = RDSH_DIR) {
19
+ const p = e2eeKeyFilePath(dir);
20
+ try {
21
+ if (existsSync(p)) {
22
+ const j = JSON.parse(readFileSync(p, "utf8"));
23
+ if (typeof j.publicRaw === "string" && typeof j.privateRaw === "string") {
24
+ return deserializeKeyPair(j.publicRaw, j.privateRaw);
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ /* 损坏 → 重新生成 */
30
+ }
31
+ const kp = generateKeyPair();
32
+ const s = serializeKeyPair(kp);
33
+ try {
34
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
35
+ writeFileSync(p, JSON.stringify(s), { mode: 0o600 });
36
+ }
37
+ catch {
38
+ /* 写失败 → 用内存密钥(下次重启指纹会变,属异常场景) */
39
+ }
40
+ return kp;
41
+ }
42
+ //# sourceMappingURL=e2ee-key-store.js.map
package/dist/e2ee.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ export declare const PROTOCOL_LABEL: Buffer<ArrayBuffer>;
3
+ export interface KeyPair {
4
+ privateKey: KeyObject;
5
+ publicRaw: Buffer;
6
+ }
7
+ export interface E2eeKeys {
8
+ /** initiator(浏览器)→ responder(host)方向密钥 */
9
+ initiatorToResponder: Buffer;
10
+ /** responder(host)→ initiator(浏览器)方向密钥 */
11
+ responderToInitiator: Buffer;
12
+ }
13
+ /** 生成 X25519 密钥对。 */
14
+ export declare function generateKeyPair(): KeyPair;
15
+ export declare function publicToRaw(pub: KeyObject): Buffer;
16
+ export declare function publicFromRaw(raw: Buffer): KeyObject;
17
+ export declare function privateFromRaw(raw: Buffer): KeyObject;
18
+ /** 持久化:raw 私钥 + 公钥(base64url 字符串)。 */
19
+ export declare function serializeKeyPair(kp: KeyPair): {
20
+ publicRaw: string;
21
+ privateRaw: string;
22
+ };
23
+ export declare function deserializeKeyPair(publicRaw: string, privateRaw: string): KeyPair;
24
+ /** X25519 共享密钥(32B)。 */
25
+ export declare function ecdh(privateKey: KeyObject, theirPublicRaw: Buffer): Buffer;
26
+ /** HKDF 派生双向密钥。 */
27
+ export declare function deriveKeys(sharedSecret: Buffer): E2eeKeys;
28
+ /** 指纹(pinning 展示):SHA-256 前 8 字节 hex 分组。 */
29
+ export declare function fingerprint(publicRaw: Buffer): string;
30
+ /** 发起方(浏览器):临时密钥 × host 静态公钥 → 会话密钥;返回要发送的临时公钥。 */
31
+ export declare function initiatorHandshake(responderStaticPublicRaw: Buffer): {
32
+ ephemeralPublicRaw: Buffer;
33
+ keys: E2eeKeys;
34
+ };
35
+ /** 响应方(host):静态私钥 × 收到的发起方临时公钥 → 会话密钥。 */
36
+ export declare function responderHandshake(staticKeyPair: KeyPair, initiatorEphemeralPublicRaw: Buffer): E2eeKeys;
37
+ /** AES-256-GCM 包:[12B nonce][ciphertext][16B tag]。显式 nonce(每方向独立计数,防错)。 */
38
+ export declare class Aead {
39
+ private readonly key;
40
+ private counter;
41
+ constructor(key: Buffer);
42
+ encrypt(plaintext: Buffer, aad: Buffer): Buffer;
43
+ decrypt(packet: Buffer, aad: Buffer): Buffer;
44
+ private nextNonce;
45
+ }
package/dist/e2ee.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * e2ee.ts — 内层 Noise NK(X25519 + HKDF-SHA256 + AES-256-GCM)Node 端(gateway 用)。
3
+ *
4
+ * 简化自 Noise NK 模式(host 静态密钥 + 浏览器临时密钥,浏览器靠 pin 认证 host):
5
+ * - 共享密钥 = X25519(本端私钥, 对端公钥)
6
+ * - 派生 = HKDF-SHA256(ikm=ss, salt=PROTOCOL_LABEL, info="session") → 64B → [initiator→responder(32), responder→initiator(32)]
7
+ * - AEAD = AES-256-GCM,包格式 [12B nonce][ciphertext][16B tag],显式 nonce + 每方向独立计数
8
+ *
9
+ * 注:为浏览器 WebCrypto 兼容选 AES-256-GCM(ChaCha20-Poly1305 在 WebCrypto 支持不普及)。
10
+ * 上线前建议与 Noise 规范 / 被审计库交叉复核(见 solution.md §6/§7)。
11
+ */
12
+ import { generateKeyPairSync, createPublicKey, createPrivateKey, diffieHellman, hkdfSync, createCipheriv, createDecipheriv, createHash, } from "node:crypto";
13
+ export const PROTOCOL_LABEL = Buffer.from("rdsh-e2ee-nk-v1");
14
+ const NONCE_LEN = 12;
15
+ const TAG_LEN = 16;
16
+ const KEY_LEN = 32;
17
+ /** X25519 PKCS8 DER 前缀(16B,后接 32B raw 私钥)。 */
18
+ const PKCS8_X25519_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
19
+ /** 生成 X25519 密钥对。 */
20
+ export function generateKeyPair() {
21
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
22
+ return { privateKey, publicRaw: publicToRaw(publicKey) };
23
+ }
24
+ export function publicToRaw(pub) {
25
+ const jwk = pub.export({ format: "jwk" });
26
+ return Buffer.from(jwk.x, "base64url");
27
+ }
28
+ export function publicFromRaw(raw) {
29
+ return createPublicKey({ key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") }, format: "jwk" });
30
+ }
31
+ export function privateFromRaw(raw) {
32
+ return createPrivateKey({ key: Buffer.concat([PKCS8_X25519_PREFIX, raw]), format: "der", type: "pkcs8" });
33
+ }
34
+ /** 持久化:raw 私钥 + 公钥(base64url 字符串)。 */
35
+ export function serializeKeyPair(kp) {
36
+ const jwk = kp.privateKey.export({ format: "jwk" });
37
+ return { publicRaw: kp.publicRaw.toString("base64url"), privateRaw: Buffer.from(jwk.d, "base64url").toString("base64url") };
38
+ }
39
+ export function deserializeKeyPair(publicRaw, privateRaw) {
40
+ return { privateKey: privateFromRaw(Buffer.from(privateRaw, "base64url")), publicRaw: Buffer.from(publicRaw, "base64url") };
41
+ }
42
+ /** X25519 共享密钥(32B)。 */
43
+ export function ecdh(privateKey, theirPublicRaw) {
44
+ return diffieHellman({ privateKey, publicKey: publicFromRaw(theirPublicRaw) });
45
+ }
46
+ /** HKDF 派生双向密钥。 */
47
+ export function deriveKeys(sharedSecret) {
48
+ const okm = Buffer.from(hkdfSync("sha256", sharedSecret, PROTOCOL_LABEL, Buffer.from("session"), 2 * KEY_LEN));
49
+ return {
50
+ initiatorToResponder: okm.subarray(0, KEY_LEN),
51
+ responderToInitiator: okm.subarray(KEY_LEN, 2 * KEY_LEN),
52
+ };
53
+ }
54
+ /** 指纹(pinning 展示):SHA-256 前 8 字节 hex 分组。 */
55
+ export function fingerprint(publicRaw) {
56
+ const h = createHash("sha256").update(publicRaw).digest("hex").slice(0, 16).toUpperCase();
57
+ return `${h.slice(0, 4)}-${h.slice(4, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}`;
58
+ }
59
+ /** 发起方(浏览器):临时密钥 × host 静态公钥 → 会话密钥;返回要发送的临时公钥。 */
60
+ export function initiatorHandshake(responderStaticPublicRaw) {
61
+ const eph = generateKeyPair();
62
+ const ss = ecdh(eph.privateKey, responderStaticPublicRaw);
63
+ return { ephemeralPublicRaw: eph.publicRaw, keys: deriveKeys(ss) };
64
+ }
65
+ /** 响应方(host):静态私钥 × 收到的发起方临时公钥 → 会话密钥。 */
66
+ export function responderHandshake(staticKeyPair, initiatorEphemeralPublicRaw) {
67
+ const ss = ecdh(staticKeyPair.privateKey, initiatorEphemeralPublicRaw);
68
+ return deriveKeys(ss);
69
+ }
70
+ /** AES-256-GCM 包:[12B nonce][ciphertext][16B tag]。显式 nonce(每方向独立计数,防错)。 */
71
+ export class Aead {
72
+ key;
73
+ counter = 0n;
74
+ constructor(key) {
75
+ this.key = key;
76
+ }
77
+ encrypt(plaintext, aad) {
78
+ const nonce = this.nextNonce();
79
+ const cipher = createCipheriv("aes-256-gcm", this.key, nonce);
80
+ cipher.setAAD(aad);
81
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
82
+ return Buffer.concat([nonce, ct, cipher.getAuthTag()]);
83
+ }
84
+ decrypt(packet, aad) {
85
+ if (packet.length < NONCE_LEN + TAG_LEN)
86
+ throw new Error("e2ee: packet too short");
87
+ const nonce = packet.subarray(0, NONCE_LEN);
88
+ const tag = packet.subarray(packet.length - TAG_LEN);
89
+ const ct = packet.subarray(NONCE_LEN, packet.length - TAG_LEN);
90
+ const decipher = createDecipheriv("aes-256-gcm", this.key, nonce);
91
+ decipher.setAAD(aad);
92
+ decipher.setAuthTag(tag);
93
+ return Buffer.concat([decipher.update(ct), decipher.final()]);
94
+ }
95
+ nextNonce() {
96
+ const n = Buffer.alloc(NONCE_LEN);
97
+ n.writeBigUInt64BE(this.counter, NONCE_LEN - 8);
98
+ this.counter += 1n;
99
+ return n;
100
+ }
101
+ }
102
+ //# sourceMappingURL=e2ee.js.map
package/dist/index.d.ts CHANGED
@@ -24,6 +24,8 @@ export { loginPageHtml } from "./login-page.ts";
24
24
  export { installService, uninstallService, serviceStatus, systemdUnit, launchdPlist, SERVICE_NAME, JOIN_SERVICE_NAME, HOST_SERVICE_NAME, HUB_SERVICE_NAME } from "./service.ts";
25
25
  export type { ServiceSpec } from "./service.ts";
26
26
  export declare const NAME = "rdsh-gateway";
27
- export { join, registerJoin, detectInsecure, selfRevoke } from "./join.ts";
28
- export type { JoinOptions, RegisterOutcome } from "./join.ts";
27
+ export { join, startJoin, registerJoin, detectInsecure, selfRevoke } from "./join.ts";
28
+ export type { JoinOptions, RegisterOutcome, JoinState, JoinHooks, StartJoinOptions, JoinHandle } from "./join.ts";
29
29
  export { readPersistedToken, clearPersistedToken } from "./token-store.ts";
30
+ export { acquireJoinLock, releaseJoinLock, readJoinLock, JOIN_LOCK_PATH } from "./lock.ts";
31
+ export type { JoinLock, JoinLockRole } from "./lock.ts";
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export { loadTls } from "./tls.js";
16
16
  export { loginPageHtml } from "./login-page.js";
17
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, registerJoin, detectInsecure, selfRevoke } from "./join.js";
19
+ export { join, startJoin, registerJoin, detectInsecure, selfRevoke } from "./join.js";
20
20
  export { readPersistedToken, clearPersistedToken } from "./token-store.js";
21
+ export { acquireJoinLock, releaseJoinLock, readJoinLock, JOIN_LOCK_PATH } from "./lock.js";
21
22
  //# sourceMappingURL=index.js.map
package/dist/join.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ProxyTarget } from "./proxy.ts";
2
+ import type { JoinLockRole } from "./lock.ts";
1
3
  export interface JoinOptions {
2
4
  hubUrl: string;
3
5
  /** join token(用户级,register 换 host token) */
@@ -10,10 +12,39 @@ export interface JoinOptions {
10
12
  /** 主机名(注册命名 / host.json) */
11
13
  name?: string;
12
14
  }
13
- /** 注册/接入结果:解析出的 host token + 是否需 insecure */
15
+ /** 注册/接入结果:解析出的 host token + 是否需 insecure + 生效的主机名(缺省=机器 hostname)。 */
14
16
  export interface RegisterOutcome {
15
17
  token: string;
16
18
  insecure: boolean;
19
+ name: string;
20
+ }
21
+ /** 隧道状态机(onState 事件值)。 */
22
+ export type JoinState = "connecting" | "connected" | "reconnecting" | "rejected" | "stopped";
23
+ /** join 核心事件钩子(插件面板实时状态 + 日志预留)。 */
24
+ export interface JoinHooks {
25
+ onState?(state: JoinState, detail?: {
26
+ message?: string;
27
+ delayMs?: number;
28
+ }): void;
29
+ onLog?(level: "info" | "warn" | "error", message: string): void;
30
+ }
31
+ /** no-spawn、外部 target 的 join 隧道启动参数(CLI 与插件共用)。 */
32
+ export interface StartJoinOptions {
33
+ hubUrl: string;
34
+ /** 已解析的 host token(registerJoin 结果) */
35
+ token: string;
36
+ insecure: boolean;
37
+ /** 转发目标(no-spawn:外部 dsh 的 loopback 地址) */
38
+ target: ProxyTarget;
39
+ /** pid 锁 role:cli / plugin */
40
+ role: JoinLockRole;
41
+ /** 锁文件路径(缺省 ~/.rdsh/join.lock;测试可注入临时路径) */
42
+ lockPath?: string;
43
+ hooks?: JoinHooks;
44
+ }
45
+ /** 可停止的 join 隧道句柄。 */
46
+ export interface JoinHandle {
47
+ stop(): Promise<void>;
17
48
  }
18
49
  /** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
19
50
  export declare function detectInsecure(hubUrl: string): Promise<boolean>;
@@ -21,4 +52,10 @@ export declare function detectInsecure(hubUrl: string): Promise<boolean>;
21
52
  export declare function selfRevoke(hubUrl: string, token: string, insecure: boolean): Promise<void>;
22
53
  /** 解析 host token(--token 注册 > 持久化复用)+ 自动检测证书;供 CLI 配置命令与 join() 复用。 */
23
54
  export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome>;
55
+ /**
56
+ * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
57
+ * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
58
+ */
59
+ export declare function startJoin(opts: StartJoinOptions): JoinHandle;
60
+ /** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
24
61
  export declare function join(opts: JoinOptions): Promise<void>;
package/dist/join.js CHANGED
@@ -5,14 +5,21 @@
5
5
  * → 帧循环(OPEN http/ws → 本地 dsh 转发 → 响应帧回传)→ 断线指数退避重连。
6
6
  *
7
7
  * 安全:只出站(不监听任何入站端口);hub 认证在层 1,gateway 侧只认隧道内来源。
8
+ *
9
+ * 06-dsh-plugin 重构(D13 钩子落地):`join()`(CLI 形态,spawn dsh + 信号退出)
10
+ * 拆出 `startJoin()`(no-spawn、外部 target、可停止、onState/onLog)——插件复用。
8
11
  */
9
12
  import { request as httpRequest } from "node:http";
10
13
  import { request as httpsRequest } from "node:https";
14
+ import { hostname as osHostname } from "node:os";
11
15
  import { WebSocket } from "ws";
12
- import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload } from "rdsh-tunnel";
16
+ import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload, FLAG_E2E } from "rdsh-tunnel";
13
17
  import { findDsh, spawnDsh } from "./spawn-dsh.js";
14
18
  import { rewriteHeadersForDsh } from "./proxy.js";
15
19
  import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
20
+ import { acquireJoinLock, releaseJoinLock } from "./lock.js";
21
+ import { responderHandshake, Aead } from "./e2ee.js";
22
+ import { loadOrCreateE2eeKeyPair } from "./e2ee-key-store.js";
16
23
  /** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
17
24
  function isCertError(err) {
18
25
  const code = err?.code ?? "";
@@ -77,10 +84,13 @@ export async function selfRevoke(hubUrl, token, insecure) {
77
84
  /** 解析 host token(--token 注册 > 持久化复用)+ 自动检测证书;供 CLI 配置命令与 join() 复用。 */
78
85
  export async function registerJoin(opts) {
79
86
  const insecure = opts.insecure === true || (await detectInsecure(opts.hubUrl));
87
+ // 主机名缺省 = 机器 hostname(CLI / service install / 插件三条路径统一;--name 可覆盖)
88
+ const name = opts.name !== undefined && opts.name.trim() !== "" ? opts.name.trim() : osHostname();
80
89
  let token;
81
90
  if (opts.token !== undefined) {
82
91
  // --token = join token(或旧 host token)→ register 端点换 host token
83
- const { hostToken } = await register(opts.hubUrl, opts.token, opts.name, insecure);
92
+ const e2eeKeyPair = loadOrCreateE2eeKeyPair();
93
+ const { hostToken } = await register(opts.hubUrl, opts.token, name, insecure, e2eeKeyPair.publicRaw.toString("base64url"));
84
94
  token = hostToken;
85
95
  persistToken(opts.hubUrl, token);
86
96
  }
@@ -96,11 +106,14 @@ export async function registerJoin(opts) {
96
106
  throw new Error("未接入:无持久化 session 且未提供 --token;先 `rdsh host join <hub>` 生成/粘贴 join token");
97
107
  }
98
108
  }
99
- return { token, insecure };
109
+ return { token, insecure, name };
100
110
  }
101
111
  /** 调 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 } });
112
+ async function register(hubUrl, joinToken, name, insecure, e2eePublicKey) {
113
+ const body = { token: joinToken, name };
114
+ if (e2eePublicKey !== undefined)
115
+ body.e2eePublicKey = e2eePublicKey;
116
+ const res = await hubRequest(hubUrl, "/api/hosts/register", { method: "POST", insecure, body });
104
117
  if (!res.ok) {
105
118
  const msg = res.body.error?.message ?? `HTTP ${res.status}`;
106
119
  throw new Error(`hub rejected register: ${msg}`);
@@ -111,206 +124,305 @@ async function register(hubUrl, joinToken, name, insecure) {
111
124
  }
112
125
  return { hostId: b.hostId, hostToken: b.hostToken };
113
126
  }
114
- export async function join(opts) {
127
+ /**
128
+ * 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
129
+ * 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
130
+ */
131
+ export function startJoin(opts) {
115
132
  const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
116
- const foundDsh = findDsh(opts.dshPath);
117
- if (foundDsh === null) {
118
- throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or pass --dsh <path>.");
133
+ const hooks = opts.hooks ?? {};
134
+ const log = (level, message) => {
135
+ hooks.onLog?.(level, message);
136
+ };
137
+ const setState = (state, detail) => {
138
+ hooks.onState?.(state, detail);
139
+ };
140
+ const lock = acquireJoinLock(opts.role, opts.lockPath);
141
+ if (!lock.ok) {
142
+ throw new Error(`join lock held by ${lock.heldBy.role} (pid ${lock.heldBy.pid}); stop it first`);
119
143
  }
120
- const dsh = await spawnDsh(foundDsh);
121
- const target = { host: "127.0.0.1", port: dsh.port };
122
- // 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
123
- const { token: initialToken, insecure } = await registerJoin(opts);
124
- let token = initialToken;
125
- console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
126
- console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
127
144
  const parser = new FrameParser();
128
- /** http 流:streamId → 本地请求(写请求体 / 结束)。 */
129
- const httpStreams = new Map();
130
- /** ws 流:streamId → 本地 ws 客户端(DATA 帧 → upstream)。 */
131
- const wsStreams = new Map();
132
145
  let shuttingDown = false;
133
146
  let reconnectDelay = RECONNECT_BASE_MS;
134
147
  let heartbeat;
135
- const shutdown = async (signal, code = 0) => {
136
- if (shuttingDown)
137
- return;
138
- shuttingDown = true;
139
- if (signal !== "")
140
- console.log(`\nrdsh: received ${signal}, shutting down...`);
141
- if (heartbeat !== undefined)
142
- clearInterval(heartbeat);
143
- await dsh.stop();
144
- process.exit(code);
145
- };
146
- process.on("SIGINT", () => void shutdown("SIGINT"));
147
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
148
- process.on("SIGHUP", () => void shutdown("SIGHUP"));
149
- // 常驻:进程靠信号退出(shutdown 里 process.exit);防止函数返回后
150
- // CLI 的 main().then(exit) 误退出服务进程
151
- const keepAlive = new Promise(() => { });
152
- function handleFrame(frame, client) {
153
- switch (frame.type) {
154
- case FRAME_TYPE.PING: {
155
- client.send(encodeFrame(FRAME_TYPE.PONG, frame.streamId, frame.payload));
148
+ let currentClient;
149
+ /** 发送一个隧道帧(走当前隧道 WS;flags 由调用方在 encodeFrame 时给定)。 */
150
+ function sendTunnelFrame(frame) {
151
+ if (currentClient !== undefined && currentClient.readyState === currentClient.OPEN) {
152
+ currentClient.send(frame);
153
+ }
154
+ }
155
+ /** 内层帧分发器(plain 与 raw 共用):OPEN http/ws + DATA → DSH 转发,响应帧经 `send` 回传。 */
156
+ function makeInnerDispatcher(send) {
157
+ const httpStreams = new Map();
158
+ const wsStreams = new Map();
159
+ function closeStream(streamId) {
160
+ const ws = wsStreams.get(streamId);
161
+ if (ws !== undefined) {
162
+ wsStreams.delete(streamId);
163
+ try {
164
+ ws.upstream.terminate();
165
+ }
166
+ catch {
167
+ /* 已关闭 */
168
+ }
156
169
  return;
157
170
  }
158
- case FRAME_TYPE.PONG:
171
+ const http = httpStreams.get(streamId);
172
+ if (http !== undefined) {
173
+ httpStreams.delete(streamId);
174
+ http.up.end();
175
+ }
176
+ }
177
+ function openWsStream(streamId, path, headers) {
178
+ const upstream = new WebSocket(`ws://${opts.target.host}:${opts.target.port}${path}`, {
179
+ headers: rewriteHeadersForDsh(headers, opts.target),
180
+ });
181
+ const queue = [];
182
+ wsStreams.set(streamId, { upstream, queue });
183
+ upstream.on("open", () => {
184
+ for (const q of queue)
185
+ upstream.send(q, { binary: false });
186
+ queue.length = 0;
187
+ });
188
+ upstream.on("message", (data) => {
189
+ const buf = Array.isArray(data)
190
+ ? Buffer.concat(data)
191
+ : Buffer.isBuffer(data)
192
+ ? data
193
+ : Buffer.from(data);
194
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
195
+ });
196
+ const cleanup = () => {
197
+ wsStreams.delete(streamId);
198
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
199
+ };
200
+ upstream.on("close", cleanup);
201
+ upstream.on("error", cleanup);
202
+ }
203
+ function handleOpen(frame) {
204
+ let kind;
205
+ let method = "GET";
206
+ let path = "/";
207
+ let headers = {};
208
+ try {
209
+ const p = parseJsonPayload(frame);
210
+ kind = p.kind;
211
+ if (typeof p.method === "string")
212
+ method = p.method;
213
+ if (typeof p.path === "string")
214
+ path = p.path;
215
+ if (typeof p.headers === "object" && p.headers !== null)
216
+ headers = p.headers;
217
+ }
218
+ catch {
219
+ send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
159
220
  return;
160
- case FRAME_TYPE.OPEN: {
161
- handleOpen(frame, client);
221
+ }
222
+ if (kind === "ws") {
223
+ openWsStream(frame.streamId, path, headers);
162
224
  return;
163
225
  }
164
- case FRAME_TYPE.DATA: {
165
- const ws = wsStreams.get(frame.streamId);
166
- if (ws !== undefined) {
167
- if (ws.upstream.readyState === ws.upstream.OPEN) {
168
- ws.upstream.send(frame.payload, { binary: false }); // DSH WS 为 text(JSON),保持文本帧
169
- }
170
- else {
171
- ws.queue.push(frame.payload);
226
+ if (kind !== "http") {
227
+ send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "unknown kind" })));
228
+ return;
229
+ }
230
+ const streamId = frame.streamId;
231
+ const up = httpRequest({
232
+ host: opts.target.host,
233
+ port: opts.target.port,
234
+ path,
235
+ method,
236
+ headers: rewriteHeadersForDsh(headers, opts.target),
237
+ }, (upRes) => {
238
+ send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
239
+ kind: "http",
240
+ status: upRes.statusCode ?? 502,
241
+ reason: upRes.statusMessage,
242
+ headers: normalizeRespHeaders(upRes.headers),
243
+ })));
244
+ upRes.on("data", (chunk) => {
245
+ send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
246
+ });
247
+ upRes.on("end", () => {
248
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
249
+ httpStreams.delete(streamId);
250
+ });
251
+ upRes.on("error", () => {
252
+ send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
253
+ httpStreams.delete(streamId);
254
+ });
255
+ });
256
+ up.on("error", () => {
257
+ send(encodeFrame(FRAME_TYPE.ERROR, streamId, jsonPayload({ code: "UPSTREAM_UNREACHABLE", message: "dsh not reachable" })));
258
+ httpStreams.delete(streamId);
259
+ });
260
+ httpStreams.set(streamId, { up });
261
+ }
262
+ function handleFrame(frame) {
263
+ switch (frame.type) {
264
+ case FRAME_TYPE.OPEN: {
265
+ handleOpen(frame);
266
+ return;
267
+ }
268
+ case FRAME_TYPE.DATA: {
269
+ const ws = wsStreams.get(frame.streamId);
270
+ if (ws !== undefined) {
271
+ if (ws.upstream.readyState === ws.upstream.OPEN)
272
+ ws.upstream.send(frame.payload, { binary: false }); // DSH WS 为 text(JSON)
273
+ else
274
+ ws.queue.push(frame.payload);
275
+ return;
172
276
  }
277
+ const http = httpStreams.get(frame.streamId);
278
+ if (http !== undefined)
279
+ http.up.write(frame.payload);
173
280
  return;
174
281
  }
175
- const http = httpStreams.get(frame.streamId);
176
- if (http !== undefined)
177
- http.up.write(frame.payload);
178
- return;
282
+ case FRAME_TYPE.CLOSE:
283
+ case FRAME_TYPE.ERROR: {
284
+ closeStream(frame.streamId);
285
+ return;
286
+ }
287
+ default:
288
+ return;
179
289
  }
180
- case FRAME_TYPE.CLOSE: {
181
- closeStream(frame.streamId);
182
- return;
290
+ }
291
+ function cleanup() {
292
+ for (const s of httpStreams.values()) {
293
+ try {
294
+ s.up.destroy();
295
+ }
296
+ catch {
297
+ /* 已断 */
298
+ }
183
299
  }
184
- case FRAME_TYPE.ERROR: {
185
- closeStream(frame.streamId);
186
- return;
300
+ httpStreams.clear();
301
+ for (const s of wsStreams.values()) {
302
+ try {
303
+ s.upstream.terminate();
304
+ }
305
+ catch {
306
+ /* 已断 */
307
+ }
187
308
  }
188
- default:
189
- return;
309
+ wsStreams.clear();
190
310
  }
311
+ return { handleFrame, cleanup };
191
312
  }
192
- function closeStream(streamId) {
193
- const ws = wsStreams.get(streamId);
194
- if (ws !== undefined) {
195
- wsStreams.delete(streamId);
196
- try {
197
- ws.upstream.terminate();
198
- }
199
- catch {
200
- /* 已关闭 */
313
+ const plainDispatcher = makeInnerDispatcher(sendTunnelFrame);
314
+ // host E2EE 静态密钥对(持久化 ~/.rdsh/e2ee-key.json;join 注册时上送指纹)
315
+ const hostE2eeKeypair = loadOrCreateE2eeKeyPair();
316
+ const rawStreams = new Map();
317
+ function startRawStream(streamId) {
318
+ const inner = makeInnerDispatcher((frame) => {
319
+ const raw = rawStreams.get(streamId);
320
+ if (raw?.encryptor !== null && raw?.encryptor !== undefined) {
321
+ const ct = raw.encryptor.encrypt(frame, Buffer.alloc(0));
322
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.DATA, streamId, ct, FLAG_E2E));
201
323
  }
202
- return;
203
- }
204
- const http = httpStreams.get(streamId);
205
- if (http !== undefined) {
206
- httpStreams.delete(streamId);
207
- http.up.end();
208
- }
324
+ });
325
+ rawStreams.set(streamId, {
326
+ handshakeBuf: Buffer.alloc(0),
327
+ keys: null,
328
+ decryptor: null,
329
+ encryptor: null,
330
+ innerParser: new FrameParser(),
331
+ inner,
332
+ });
209
333
  }
210
- function handleOpen(frame, client) {
211
- let kind;
212
- let method = "GET";
213
- let path = "/";
214
- let headers = {};
334
+ function handleRawData(streamId, state, chunk) {
215
335
  try {
216
- const p = parseJsonPayload(frame);
217
- kind = p.kind;
218
- if (typeof p.method === "string")
219
- method = p.method;
220
- if (typeof p.path === "string")
221
- path = p.path;
222
- if (typeof p.headers === "object" && p.headers !== null)
223
- headers = p.headers;
336
+ if (state.keys === null) {
337
+ // Noise 握手:缓冲到 32B(发起方临时公钥)→ 派生密钥
338
+ state.handshakeBuf = Buffer.concat([state.handshakeBuf, chunk]);
339
+ if (state.handshakeBuf.length < 32)
340
+ return;
341
+ const ephPub = state.handshakeBuf.subarray(0, 32);
342
+ state.handshakeBuf = state.handshakeBuf.subarray(32);
343
+ state.keys = responderHandshake(hostE2eeKeypair, ephPub);
344
+ state.decryptor = new Aead(state.keys.initiatorToResponder);
345
+ state.encryptor = new Aead(state.keys.responderToInitiator);
346
+ if (state.handshakeBuf.length === 0)
347
+ return;
348
+ chunk = state.handshakeBuf; // 剩余 = 首个密文分片
349
+ state.handshakeBuf = Buffer.alloc(0);
350
+ }
351
+ const dec = state.decryptor.decrypt(chunk, Buffer.alloc(0));
352
+ for (const f of state.innerParser.push(dec))
353
+ state.inner.handleFrame(f);
224
354
  }
225
355
  catch {
226
- client.send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "malformed open" })));
227
- return;
356
+ // 解密失败(篡改/错序)→ 结束该 raw
357
+ rawStreams.delete(streamId);
358
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 1, message: "e2ee decrypt failed" })));
228
359
  }
229
- if (kind === "ws") {
230
- openWsStream(frame, client, path, headers);
231
- return;
232
- }
233
- if (kind !== "http") {
234
- client.send(encodeFrame(FRAME_TYPE.ERROR, frame.streamId, jsonPayload({ code: "BAD_OPEN", message: "unknown kind" })));
235
- return;
236
- }
237
- const streamId = frame.streamId;
238
- // http 转发:本地 dsh(loopback http)
239
- const up = httpRequest({
240
- host: target.host,
241
- port: target.port,
242
- path,
243
- method,
244
- headers: rewriteHeadersForDsh(headers, target),
245
- }, (upRes) => {
246
- client.send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
247
- kind: "http",
248
- status: upRes.statusCode ?? 502,
249
- reason: upRes.statusMessage,
250
- headers: normalizeRespHeaders(upRes.headers),
251
- })));
252
- upRes.on("data", (chunk) => {
253
- if (client.readyState === client.OPEN) {
254
- client.send(encodeFrame(FRAME_TYPE.DATA, streamId, chunk));
360
+ }
361
+ /** 隧道级帧分发:PING/PONG + OPEN(http/ws/raw)+ DATA/CLOSE/ERROR(plain 或 raw 路由)。 */
362
+ function handleFrame(frame) {
363
+ switch (frame.type) {
364
+ case FRAME_TYPE.PING: {
365
+ sendTunnelFrame(encodeFrame(FRAME_TYPE.PONG, frame.streamId, frame.payload));
366
+ return;
367
+ }
368
+ case FRAME_TYPE.PONG:
369
+ return;
370
+ case FRAME_TYPE.OPEN: {
371
+ let kind;
372
+ try {
373
+ const p = parseJsonPayload(frame);
374
+ kind = typeof p.kind === "string" ? p.kind : undefined;
255
375
  }
256
- });
257
- upRes.on("end", () => {
258
- if (client.readyState === client.OPEN) {
259
- client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
376
+ catch {
377
+ /* 交给 plain dispatcher 报 BAD_OPEN */
260
378
  }
261
- httpStreams.delete(streamId);
262
- });
263
- upRes.on("error", () => {
264
- if (client.readyState === client.OPEN) {
265
- client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 502, message: "upstream error" })));
379
+ if (kind === "raw") {
380
+ startRawStream(frame.streamId);
381
+ return;
266
382
  }
267
- httpStreams.delete(streamId);
268
- });
269
- });
270
- up.on("error", () => {
271
- if (client.readyState === client.OPEN) {
272
- client.send(encodeFrame(FRAME_TYPE.ERROR, streamId, jsonPayload({ code: "UPSTREAM_UNREACHABLE", message: "dsh not reachable" })));
383
+ plainDispatcher.handleFrame(frame);
384
+ return;
273
385
  }
274
- httpStreams.delete(streamId);
275
- });
276
- httpStreams.set(streamId, { up });
277
- }
278
- function openWsStream(frame, client, path, headers) {
279
- const streamId = frame.streamId;
280
- const upstream = new WebSocket(`ws://${target.host}:${target.port}${path}`, {
281
- headers: rewriteHeadersForDsh(headers, target),
282
- });
283
- const queue = [];
284
- wsStreams.set(streamId, { upstream, queue });
285
- upstream.on("open", () => {
286
- for (const q of queue)
287
- upstream.send(q, { binary: false });
288
- queue.length = 0;
289
- });
290
- upstream.on("message", (data, isBinary) => {
291
- const buf = Array.isArray(data)
292
- ? Buffer.concat(data)
293
- : Buffer.isBuffer(data)
294
- ? data
295
- : Buffer.from(data);
296
- if (client.readyState === client.OPEN) {
297
- client.send(encodeFrame(FRAME_TYPE.DATA, streamId, buf));
386
+ case FRAME_TYPE.DATA: {
387
+ const raw = rawStreams.get(frame.streamId);
388
+ if (raw !== undefined) {
389
+ handleRawData(frame.streamId, raw, frame.payload);
390
+ return;
391
+ }
392
+ plainDispatcher.handleFrame(frame);
393
+ return;
298
394
  }
299
- });
300
- const cleanup = () => {
301
- wsStreams.delete(streamId);
302
- if (client.readyState === client.OPEN) {
303
- client.send(encodeFrame(FRAME_TYPE.CLOSE, streamId, jsonPayload({ code: 0 })));
395
+ case FRAME_TYPE.CLOSE:
396
+ case FRAME_TYPE.ERROR: {
397
+ if (rawStreams.has(frame.streamId)) {
398
+ rawStreams.delete(frame.streamId);
399
+ return;
400
+ }
401
+ plainDispatcher.handleFrame(frame);
402
+ return;
304
403
  }
305
- };
306
- upstream.on("close", cleanup);
307
- upstream.on("error", cleanup);
404
+ default:
405
+ return;
406
+ }
407
+ }
408
+ /** 清空本地 http/ws/raw 流 + heartbeat(断线/停止时)。 */
409
+ function cleanupStreams() {
410
+ if (heartbeat !== undefined) {
411
+ clearInterval(heartbeat);
412
+ heartbeat = undefined;
413
+ }
414
+ plainDispatcher.cleanup();
415
+ for (const raw of rawStreams.values())
416
+ raw.inner.cleanup();
417
+ rawStreams.clear();
308
418
  }
309
419
  function connect() {
310
420
  if (shuttingDown)
311
421
  return;
312
- const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(token)}`;
313
- const client = new WebSocket(url, { rejectUnauthorized: !insecure });
422
+ const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(opts.token)}`;
423
+ const client = new WebSocket(url, { rejectUnauthorized: !opts.insecure });
424
+ currentClient = client;
425
+ setState("connecting", { message: `connecting to ${opts.hubUrl}` });
314
426
  // 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
315
427
  // 需手动 terminate → 触发 close → 决定「重配对」还是「普通重连」。
316
428
  let tokenRejected = false;
@@ -326,7 +438,8 @@ export async function join(opts) {
326
438
  });
327
439
  client.on("open", () => {
328
440
  reconnectDelay = RECONNECT_BASE_MS;
329
- console.log("rdsh join: tunnel established (heartbeat 30s)");
441
+ setState("connected");
442
+ log("info", "tunnel established (heartbeat 30s)");
330
443
  if (heartbeat !== undefined)
331
444
  clearInterval(heartbeat);
332
445
  heartbeat = setInterval(() => {
@@ -352,42 +465,25 @@ export async function join(opts) {
352
465
  return;
353
466
  }
354
467
  for (const frame of frames)
355
- handleFrame(frame, client);
468
+ handleFrame(frame);
356
469
  });
357
470
  client.on("close", () => {
358
- if (heartbeat !== undefined) {
359
- clearInterval(heartbeat);
360
- heartbeat = undefined;
361
- }
362
- for (const s of httpStreams.values()) {
363
- try {
364
- s.up.destroy();
365
- }
366
- catch {
367
- /* 已断 */
368
- }
369
- }
370
- httpStreams.clear();
371
- for (const s of wsStreams.values()) {
372
- try {
373
- s.upstream.terminate();
374
- }
375
- catch {
376
- /* 已断 */
377
- }
378
- }
379
- wsStreams.clear();
471
+ cleanupStreams();
380
472
  if (shuttingDown)
381
473
  return;
382
474
  if (tokenRejected) {
383
- // token 被拒(吊销/删除)= 永久失败,无法自动恢复(已移除配对码重配)
384
- // → 删旧 session + fail-fast,让 systemd/脚本拿到非零退出码与明确报错。
475
+ // token 被拒(吊销/删除)= 永久失败,无法自动恢复
476
+ // → 删旧 session + 释放锁 + 停(fail-fast),不重连。
385
477
  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);
478
+ const msg = "host token rejected by hub (revoked or removed); re-join with a new join token";
479
+ log("error", msg);
480
+ setState("rejected", { message: msg });
481
+ shuttingDown = true;
482
+ releaseJoinLock(opts.lockPath);
388
483
  return;
389
484
  }
390
- console.log(`rdsh join: tunnel lost — reconnecting in ${Math.round(reconnectDelay / 1000)}s...`);
485
+ setState("reconnecting", { delayMs: reconnectDelay });
486
+ log("info", `tunnel lost — reconnecting in ${Math.round(reconnectDelay / 1000)}s...`);
391
487
  setTimeout(connect, reconnectDelay + Math.random() * 500);
392
488
  reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
393
489
  });
@@ -401,7 +497,71 @@ export async function join(opts) {
401
497
  });
402
498
  }
403
499
  connect();
404
- await keepAlive;
500
+ return {
501
+ async stop() {
502
+ if (shuttingDown)
503
+ return;
504
+ shuttingDown = true;
505
+ cleanupStreams();
506
+ if (currentClient !== undefined) {
507
+ try {
508
+ currentClient.terminate();
509
+ }
510
+ catch {
511
+ /* 已关闭 */
512
+ }
513
+ }
514
+ releaseJoinLock(opts.lockPath);
515
+ setState("stopped");
516
+ },
517
+ };
518
+ }
519
+ /** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
520
+ export async function join(opts) {
521
+ const foundDsh = findDsh(opts.dshPath);
522
+ if (foundDsh === null) {
523
+ throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or pass --dsh <path>.");
524
+ }
525
+ const dsh = await spawnDsh(foundDsh);
526
+ const target = { host: "127.0.0.1", port: dsh.port };
527
+ // 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
528
+ const { token, insecure } = await registerJoin(opts);
529
+ console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
530
+ console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
531
+ const handle = startJoin({
532
+ hubUrl: opts.hubUrl,
533
+ token,
534
+ insecure,
535
+ target,
536
+ role: "cli",
537
+ hooks: {
538
+ onLog: (level, message) => {
539
+ (level === "error" ? console.error : console.log)(`rdsh join: ${message}`);
540
+ },
541
+ onState: (state, detail) => {
542
+ if (state === "rejected") {
543
+ console.error(`rdsh join: ${detail?.message ?? "rejected"}`);
544
+ }
545
+ },
546
+ },
547
+ });
548
+ let shuttingDown = false;
549
+ const shutdown = async (signal, code = 0) => {
550
+ if (shuttingDown)
551
+ return;
552
+ shuttingDown = true;
553
+ if (signal !== "")
554
+ console.log(`\nrdsh: received ${signal}, shutting down...`);
555
+ await handle.stop();
556
+ await dsh.stop();
557
+ process.exit(code);
558
+ };
559
+ process.on("SIGINT", () => void shutdown("SIGINT"));
560
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
561
+ process.on("SIGHUP", () => void shutdown("SIGHUP"));
562
+ // 常驻:进程靠信号退出(shutdown 里 process.exit);防止函数返回后
563
+ // CLI 的 main().then(exit) 误退出服务进程
564
+ await new Promise(() => { });
405
565
  }
406
566
  function normalizeRespHeaders(headers) {
407
567
  const out = {};
package/dist/lock.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export declare const JOIN_LOCK_PATH: string;
2
+ export type JoinLockRole = "cli" | "plugin";
3
+ export interface JoinLock {
4
+ pid: number;
5
+ role: JoinLockRole;
6
+ }
7
+ /**
8
+ * 读锁。不存在/损坏/过短 → null;pid 已死(stale)→ 清除文件并返回 null。
9
+ * 供面板「外部托管」态判定(role=cli)与 acquire 前检测复用。
10
+ */
11
+ export declare function readJoinLock(path?: string): JoinLock | null;
12
+ export type AcquireResult = {
13
+ ok: true;
14
+ } | {
15
+ ok: false;
16
+ heldBy: JoinLock;
17
+ };
18
+ /**
19
+ * 获取锁:写入 `{pid: process.pid, role}`(目录 0700、文件 0600)。
20
+ * 已有**他人**(不同 pid)持有的活锁 → 拒绝(防同机双隧道)。
21
+ */
22
+ export declare function acquireJoinLock(role: JoinLockRole, path?: string): AcquireResult;
23
+ /** 释放锁:仅当锁是自己 pid 持有才删(不误删他人锁)。 */
24
+ export declare function releaseJoinLock(path?: string): void;
package/dist/lock.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * lock.ts — join pid 锁:CLI 与插件共享「单身份铁律」(同机单隧道,D5 档2)。
3
+ *
4
+ * 锁文件 `~/.rdsh/join.lock`(0600)记 `{pid, role}`;stale(pid 已死)自动视为无锁并清除。
5
+ * role = "cli"(rdsh CLI / systemd 服务持有)| "plugin"(dsh 插件持有)——面板据此显示「外部托管」。
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+ const RDSH_DIR = join(homedir(), ".rdsh");
11
+ export const JOIN_LOCK_PATH = join(RDSH_DIR, "join.lock");
12
+ /** 探测 pid 是否存活:signal 0 不真正发信号;EPERM = 存活但无权限,ESRCH = 不存在。 */
13
+ function isAlive(pid) {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (err) {
19
+ return err.code === "EPERM";
20
+ }
21
+ }
22
+ /**
23
+ * 读锁。不存在/损坏/过短 → null;pid 已死(stale)→ 清除文件并返回 null。
24
+ * 供面板「外部托管」态判定(role=cli)与 acquire 前检测复用。
25
+ */
26
+ export function readJoinLock(path = JOIN_LOCK_PATH) {
27
+ try {
28
+ if (!existsSync(path))
29
+ return null;
30
+ const raw = JSON.parse(readFileSync(path, "utf8"));
31
+ if (typeof raw.pid !== "number" || (raw.role !== "cli" && raw.role !== "plugin"))
32
+ return null;
33
+ if (!isAlive(raw.pid)) {
34
+ try {
35
+ rmSync(path, { force: true });
36
+ }
37
+ catch {
38
+ /* 忽略 */
39
+ }
40
+ return null;
41
+ }
42
+ return { pid: raw.pid, role: raw.role };
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /**
49
+ * 获取锁:写入 `{pid: process.pid, role}`(目录 0700、文件 0600)。
50
+ * 已有**他人**(不同 pid)持有的活锁 → 拒绝(防同机双隧道)。
51
+ */
52
+ export function acquireJoinLock(role, path = JOIN_LOCK_PATH) {
53
+ const held = readJoinLock(path);
54
+ if (held !== null && held.pid !== process.pid) {
55
+ return { ok: false, heldBy: held };
56
+ }
57
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
58
+ writeFileSync(path, JSON.stringify({ pid: process.pid, role }), { mode: 0o600 });
59
+ return { ok: true };
60
+ }
61
+ /** 释放锁:仅当锁是自己 pid 持有才删(不误删他人锁)。 */
62
+ export function releaseJoinLock(path = JOIN_LOCK_PATH) {
63
+ try {
64
+ const held = readJoinLock(path);
65
+ if (held !== null && held.pid === process.pid) {
66
+ rmSync(path, { force: true });
67
+ }
68
+ }
69
+ catch {
70
+ /* 忽略 */
71
+ }
72
+ }
73
+ //# sourceMappingURL=lock.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rdsh-gateway",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "ws": "^8.18.0",
24
- "rdsh-tunnel": "0.1.0"
24
+ "rdsh-tunnel": "0.2.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/ws": "^8.5.0"