rdsh-gateway 0.2.3 → 0.4.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 +12 -3
- package/dist/config.js +64 -8
- package/dist/index.d.ts +9 -5
- package/dist/index.js +5 -3
- package/dist/join.d.ts +51 -2
- package/dist/join.js +182 -115
- package/dist/lock.d.ts +24 -0
- package/dist/lock.js +73 -0
- package/dist/service.d.ts +27 -7
- package/dist/service.js +59 -32
- package/dist/token-store.js +1 -1
- package/package.json +1 -1
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
|
|
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 —
|
|
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/
|
|
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
|
|
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 ??
|
|
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,18 @@ 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,
|
|
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, startJoin, registerJoin, detectInsecure, selfRevoke } from "./join.ts";
|
|
28
|
+
export type { JoinOptions, RegisterOutcome, JoinState, JoinHooks, StartJoinOptions, JoinHandle } from "./join.ts";
|
|
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
|
@@ -9,12 +9,14 @@ 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,
|
|
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, startJoin, registerJoin, detectInsecure, selfRevoke } from "./join.js";
|
|
20
|
+
export { readPersistedToken, clearPersistedToken } from "./token-store.js";
|
|
21
|
+
export { acquireJoinLock, releaseJoinLock, readJoinLock, JOIN_LOCK_PATH } from "./lock.js";
|
|
20
22
|
//# sourceMappingURL=index.js.map
|
package/dist/join.d.ts
CHANGED
|
@@ -1,11 +1,60 @@
|
|
|
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) */
|
|
4
6
|
token?: string;
|
|
5
7
|
/** 清除持久化 token 并强制重新配对 */
|
|
6
8
|
reset?: boolean;
|
|
7
9
|
dshPath?: string;
|
|
8
|
-
/** 跳过 TLS 证书校验(自签 hub
|
|
10
|
+
/** 跳过 TLS 证书校验(自签 hub 用;正式证书无需,缺省自动检测) */
|
|
9
11
|
insecure?: boolean;
|
|
12
|
+
/** 主机名(注册命名 / host.json) */
|
|
13
|
+
name?: string;
|
|
10
14
|
}
|
|
15
|
+
/** 注册/接入结果:解析出的 host token + 是否需 insecure。 */
|
|
16
|
+
export interface RegisterOutcome {
|
|
17
|
+
token: string;
|
|
18
|
+
insecure: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** 隧道状态机(onState 事件值)。 */
|
|
21
|
+
export type JoinState = "connecting" | "connected" | "reconnecting" | "rejected" | "stopped";
|
|
22
|
+
/** join 核心事件钩子(插件面板实时状态 + 日志预留)。 */
|
|
23
|
+
export interface JoinHooks {
|
|
24
|
+
onState?(state: JoinState, detail?: {
|
|
25
|
+
message?: string;
|
|
26
|
+
delayMs?: number;
|
|
27
|
+
}): void;
|
|
28
|
+
onLog?(level: "info" | "warn" | "error", message: string): void;
|
|
29
|
+
}
|
|
30
|
+
/** no-spawn、外部 target 的 join 隧道启动参数(CLI 与插件共用)。 */
|
|
31
|
+
export interface StartJoinOptions {
|
|
32
|
+
hubUrl: string;
|
|
33
|
+
/** 已解析的 host token(registerJoin 结果) */
|
|
34
|
+
token: string;
|
|
35
|
+
insecure: boolean;
|
|
36
|
+
/** 转发目标(no-spawn:外部 dsh 的 loopback 地址) */
|
|
37
|
+
target: ProxyTarget;
|
|
38
|
+
/** pid 锁 role:cli / plugin */
|
|
39
|
+
role: JoinLockRole;
|
|
40
|
+
/** 锁文件路径(缺省 ~/.rdsh/join.lock;测试可注入临时路径) */
|
|
41
|
+
lockPath?: string;
|
|
42
|
+
hooks?: JoinHooks;
|
|
43
|
+
}
|
|
44
|
+
/** 可停止的 join 隧道句柄。 */
|
|
45
|
+
export interface JoinHandle {
|
|
46
|
+
stop(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
|
|
49
|
+
export declare function detectInsecure(hubUrl: string): Promise<boolean>;
|
|
50
|
+
/** 调用 hub self-revoke 注销本机(host 持自己的 host token)。`rdsh host leave` 使用。 */
|
|
51
|
+
export declare function selfRevoke(hubUrl: string, token: string, insecure: boolean): Promise<void>;
|
|
52
|
+
/** 解析 host token(--token 注册 > 持久化复用)+ 自动检测证书;供 CLI 配置命令与 join() 复用。 */
|
|
53
|
+
export declare function registerJoin(opts: JoinOptions): Promise<RegisterOutcome>;
|
|
54
|
+
/**
|
|
55
|
+
* 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
|
|
56
|
+
* 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
|
|
57
|
+
*/
|
|
58
|
+
export declare function startJoin(opts: StartJoinOptions): JoinHandle;
|
|
59
|
+
/** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
|
|
11
60
|
export declare function join(opts: JoinOptions): Promise<void>;
|
package/dist/join.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* join.ts — `rdsh join <hub-url>`:出站隧道客户端(公网模式,M3)。
|
|
3
3
|
*
|
|
4
|
-
* 流程:spawn dsh(复用)→
|
|
4
|
+
* 流程:spawn dsh(复用)→ 注册(join token → host token)→ WSS 隧道(?token=)
|
|
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";
|
|
@@ -13,36 +16,29 @@ import { FrameParser, FRAME_TYPE, encodeFrame, jsonPayload, parseJsonPayload } f
|
|
|
13
16
|
import { findDsh, spawnDsh } from "./spawn-dsh.js";
|
|
14
17
|
import { rewriteHeadersForDsh } from "./proxy.js";
|
|
15
18
|
import { clearPersistedToken, persistToken, readPersistedToken } from "./token-store.js";
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
import { acquireJoinLock, releaseJoinLock } from "./lock.js";
|
|
20
|
+
/** 判断错误是否为 TLS 证书类错误(自签/过期/域名不匹配)。 */
|
|
21
|
+
function isCertError(err) {
|
|
22
|
+
const code = err?.code ?? "";
|
|
23
|
+
return (code.includes("CERT_") ||
|
|
24
|
+
code.includes("TLS") ||
|
|
25
|
+
code.includes("SELF_SIGNED") ||
|
|
26
|
+
code.includes("UNABLE_TO_VERIFY") ||
|
|
27
|
+
code.includes("DEPTH_ZERO"));
|
|
28
|
+
}
|
|
29
|
+
/** 探测 hub 是否需 insecure:以严格校验握手一次;证书错误 → true(需 insecure)。 */
|
|
30
|
+
export async function detectInsecure(hubUrl) {
|
|
31
|
+
try {
|
|
32
|
+
await hubRequest(hubUrl, "/api/auth/login", { method: "GET", insecure: false });
|
|
33
|
+
return false; // TLS 握手成功
|
|
27
34
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
console.log(`rdsh join: sign in to ${hubUrl} and enter this code (10 min) to bind this host.`);
|
|
31
|
-
console.log(`rdsh join: waiting for binding...`);
|
|
32
|
-
const deadline = Date.now() + BIND_TIMEOUT_MS;
|
|
33
|
-
while (Date.now() < deadline) {
|
|
34
|
-
await sleep(PENDING_POLL_MS);
|
|
35
|
-
const res = await hubRequest(hubUrl, `/api/hosts/pending/${pending.pendingId}`, { method: "GET", insecure });
|
|
36
|
-
if (!res.ok)
|
|
37
|
-
continue;
|
|
38
|
-
const body = res.body;
|
|
39
|
-
if (body.status === "bound" && typeof body.token === "string") {
|
|
40
|
-
console.log("rdsh join: bound — establishing tunnel...");
|
|
41
|
-
return body.token;
|
|
42
|
-
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
return isCertError(err);
|
|
43
37
|
}
|
|
44
|
-
throw new Error("binding timed out (10 min): re-run rdsh join to get a new code");
|
|
45
38
|
}
|
|
39
|
+
const HEARTBEAT_MS = 30_000;
|
|
40
|
+
const RECONNECT_BASE_MS = 1_000;
|
|
41
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
46
42
|
/** hub HTTP 调用(node:https 支持自签跳过校验 —— undici fetch 不受 NODE_TLS_REJECT_UNAUTHORIZED 影响)。 */
|
|
47
43
|
function hubRequest(baseUrl, path, opts) {
|
|
48
44
|
const url = new URL(baseUrl + path);
|
|
@@ -71,22 +67,26 @@ function hubRequest(baseUrl, path, opts) {
|
|
|
71
67
|
res.on("error", reject);
|
|
72
68
|
});
|
|
73
69
|
req.on("error", reject);
|
|
74
|
-
req.end(opts.method === "POST" ?
|
|
70
|
+
req.end(opts.method === "POST" ? JSON.stringify(opts.body ?? {}) : undefined);
|
|
75
71
|
});
|
|
76
72
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
|
|
73
|
+
/** 调用 hub self-revoke 注销本机(host 持自己的 host token)。`rdsh host leave` 使用。 */
|
|
74
|
+
export async function selfRevoke(hubUrl, token, insecure) {
|
|
75
|
+
const res = await hubRequest(hubUrl, "/api/hosts/self-revoke", { method: "POST", insecure, body: { token } });
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const msg = res.body.error?.message ?? `HTTP ${res.status}`;
|
|
78
|
+
throw new Error(`hub rejected self-revoke: ${msg}`);
|
|
82
79
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
}
|
|
81
|
+
/** 解析 host token(--token 注册 > 持久化复用)+ 自动检测证书;供 CLI 配置命令与 join() 复用。 */
|
|
82
|
+
export async function registerJoin(opts) {
|
|
83
|
+
const insecure = opts.insecure === true || (await detectInsecure(opts.hubUrl));
|
|
87
84
|
let token;
|
|
88
85
|
if (opts.token !== undefined) {
|
|
89
|
-
token =
|
|
86
|
+
// --token = join token(或旧 host token)→ register 端点换 host token
|
|
87
|
+
const { hostToken } = await register(opts.hubUrl, opts.token, opts.name, insecure);
|
|
88
|
+
token = hostToken;
|
|
89
|
+
persistToken(opts.hubUrl, token);
|
|
90
90
|
}
|
|
91
91
|
else {
|
|
92
92
|
if (opts.reset === true)
|
|
@@ -97,12 +97,41 @@ export async function join(opts) {
|
|
|
97
97
|
console.log("rdsh join: reusing persisted host token");
|
|
98
98
|
}
|
|
99
99
|
else {
|
|
100
|
-
token
|
|
101
|
-
persistToken(opts.hubUrl, token);
|
|
100
|
+
throw new Error("未接入:无持久化 session 且未提供 --token;先 `rdsh host join <hub>` 生成/粘贴 join token");
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
return { token, insecure };
|
|
104
|
+
}
|
|
105
|
+
/** 调 register 端点:join token → host token(对旧 host token 幂等返回同一 token)。 */
|
|
106
|
+
async function register(hubUrl, joinToken, name, insecure) {
|
|
107
|
+
const res = await hubRequest(hubUrl, "/api/hosts/register", { method: "POST", insecure, body: { token: joinToken, name } });
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
const msg = res.body.error?.message ?? `HTTP ${res.status}`;
|
|
110
|
+
throw new Error(`hub rejected register: ${msg}`);
|
|
111
|
+
}
|
|
112
|
+
const b = res.body;
|
|
113
|
+
if (typeof b.hostId !== "string" || typeof b.hostToken !== "string") {
|
|
114
|
+
throw new Error("hub register returned malformed response");
|
|
115
|
+
}
|
|
116
|
+
return { hostId: b.hostId, hostToken: b.hostToken };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 启动 join 隧道(no-spawn):转发到外部 `opts.target`,不 spawn dsh、不 process.exit。
|
|
120
|
+
* 获取 pid 锁(opts.role);返回 `JoinHandle`,`stop()` 干净停止(关 WS/清 heartbeat/释放锁)。
|
|
121
|
+
*/
|
|
122
|
+
export function startJoin(opts) {
|
|
123
|
+
const hubWsBase = opts.hubUrl.replace(/^https/, "wss").replace(/^http/, "ws");
|
|
124
|
+
const hooks = opts.hooks ?? {};
|
|
125
|
+
const log = (level, message) => {
|
|
126
|
+
hooks.onLog?.(level, message);
|
|
127
|
+
};
|
|
128
|
+
const setState = (state, detail) => {
|
|
129
|
+
hooks.onState?.(state, detail);
|
|
130
|
+
};
|
|
131
|
+
const lock = acquireJoinLock(opts.role, opts.lockPath);
|
|
132
|
+
if (!lock.ok) {
|
|
133
|
+
throw new Error(`join lock held by ${lock.heldBy.role} (pid ${lock.heldBy.pid}); stop it first`);
|
|
134
|
+
}
|
|
106
135
|
const parser = new FrameParser();
|
|
107
136
|
/** http 流:streamId → 本地请求(写请求体 / 结束)。 */
|
|
108
137
|
const httpStreams = new Map();
|
|
@@ -111,23 +140,7 @@ export async function join(opts) {
|
|
|
111
140
|
let shuttingDown = false;
|
|
112
141
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
113
142
|
let heartbeat;
|
|
114
|
-
|
|
115
|
-
if (shuttingDown)
|
|
116
|
-
return;
|
|
117
|
-
shuttingDown = true;
|
|
118
|
-
if (signal !== "")
|
|
119
|
-
console.log(`\nrdsh: received ${signal}, shutting down...`);
|
|
120
|
-
if (heartbeat !== undefined)
|
|
121
|
-
clearInterval(heartbeat);
|
|
122
|
-
await dsh.stop();
|
|
123
|
-
process.exit(code);
|
|
124
|
-
};
|
|
125
|
-
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
126
|
-
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
127
|
-
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
128
|
-
// 常驻:进程靠信号退出(shutdown 里 process.exit);防止函数返回后
|
|
129
|
-
// CLI 的 main().then(exit) 误退出服务进程
|
|
130
|
-
const keepAlive = new Promise(() => { });
|
|
143
|
+
let currentClient;
|
|
131
144
|
function handleFrame(frame, client) {
|
|
132
145
|
switch (frame.type) {
|
|
133
146
|
case FRAME_TYPE.PING: {
|
|
@@ -216,11 +229,11 @@ export async function join(opts) {
|
|
|
216
229
|
const streamId = frame.streamId;
|
|
217
230
|
// http 转发:本地 dsh(loopback http)
|
|
218
231
|
const up = httpRequest({
|
|
219
|
-
host: target.host,
|
|
220
|
-
port: target.port,
|
|
232
|
+
host: opts.target.host,
|
|
233
|
+
port: opts.target.port,
|
|
221
234
|
path,
|
|
222
235
|
method,
|
|
223
|
-
headers: rewriteHeadersForDsh(headers, target),
|
|
236
|
+
headers: rewriteHeadersForDsh(headers, opts.target),
|
|
224
237
|
}, (upRes) => {
|
|
225
238
|
client.send(encodeFrame(FRAME_TYPE.OPEN, streamId, jsonPayload({
|
|
226
239
|
kind: "http",
|
|
@@ -256,8 +269,8 @@ export async function join(opts) {
|
|
|
256
269
|
}
|
|
257
270
|
function openWsStream(frame, client, path, headers) {
|
|
258
271
|
const streamId = frame.streamId;
|
|
259
|
-
const upstream = new WebSocket(`ws://${target.host}:${target.port}${path}`, {
|
|
260
|
-
headers: rewriteHeadersForDsh(headers, target),
|
|
272
|
+
const upstream = new WebSocket(`ws://${opts.target.host}:${opts.target.port}${path}`, {
|
|
273
|
+
headers: rewriteHeadersForDsh(headers, opts.target),
|
|
261
274
|
});
|
|
262
275
|
const queue = [];
|
|
263
276
|
wsStreams.set(streamId, { upstream, queue });
|
|
@@ -285,28 +298,38 @@ export async function join(opts) {
|
|
|
285
298
|
upstream.on("close", cleanup);
|
|
286
299
|
upstream.on("error", cleanup);
|
|
287
300
|
}
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
token = await bind(opts.hubUrl, opts.insecure === true);
|
|
294
|
-
persistToken(opts.hubUrl, token);
|
|
301
|
+
/** 清空本地 http/ws 流 + heartbeat(断线/停止时)。 */
|
|
302
|
+
function cleanupStreams() {
|
|
303
|
+
if (heartbeat !== undefined) {
|
|
304
|
+
clearInterval(heartbeat);
|
|
305
|
+
heartbeat = undefined;
|
|
295
306
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
307
|
+
for (const s of httpStreams.values()) {
|
|
308
|
+
try {
|
|
309
|
+
s.up.destroy();
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
/* 已断 */
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
httpStreams.clear();
|
|
316
|
+
for (const s of wsStreams.values()) {
|
|
317
|
+
try {
|
|
318
|
+
s.upstream.terminate();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
/* 已断 */
|
|
322
|
+
}
|
|
301
323
|
}
|
|
302
|
-
|
|
303
|
-
connect();
|
|
324
|
+
wsStreams.clear();
|
|
304
325
|
}
|
|
305
326
|
function connect() {
|
|
306
327
|
if (shuttingDown)
|
|
307
328
|
return;
|
|
308
|
-
const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(token)}`;
|
|
309
|
-
const client = new WebSocket(url, { rejectUnauthorized: opts.insecure
|
|
329
|
+
const url = `${hubWsBase}/tunnel?token=${encodeURIComponent(opts.token)}`;
|
|
330
|
+
const client = new WebSocket(url, { rejectUnauthorized: !opts.insecure });
|
|
331
|
+
currentClient = client;
|
|
332
|
+
setState("connecting", { message: `connecting to ${opts.hubUrl}` });
|
|
310
333
|
// 401/403 = token 被拒(吊销/不存在)。监听此事件后 ws 不再自动 abort,
|
|
311
334
|
// 需手动 terminate → 触发 close → 决定「重配对」还是「普通重连」。
|
|
312
335
|
let tokenRejected = false;
|
|
@@ -322,7 +345,8 @@ export async function join(opts) {
|
|
|
322
345
|
});
|
|
323
346
|
client.on("open", () => {
|
|
324
347
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
325
|
-
|
|
348
|
+
setState("connected");
|
|
349
|
+
log("info", "tunnel established (heartbeat 30s)");
|
|
326
350
|
if (heartbeat !== undefined)
|
|
327
351
|
clearInterval(heartbeat);
|
|
328
352
|
heartbeat = setInterval(() => {
|
|
@@ -351,43 +375,22 @@ export async function join(opts) {
|
|
|
351
375
|
handleFrame(frame, client);
|
|
352
376
|
});
|
|
353
377
|
client.on("close", () => {
|
|
354
|
-
|
|
355
|
-
clearInterval(heartbeat);
|
|
356
|
-
heartbeat = undefined;
|
|
357
|
-
}
|
|
358
|
-
for (const s of httpStreams.values()) {
|
|
359
|
-
try {
|
|
360
|
-
s.up.destroy();
|
|
361
|
-
}
|
|
362
|
-
catch {
|
|
363
|
-
/* 已断 */
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
httpStreams.clear();
|
|
367
|
-
for (const s of wsStreams.values()) {
|
|
368
|
-
try {
|
|
369
|
-
s.upstream.terminate();
|
|
370
|
-
}
|
|
371
|
-
catch {
|
|
372
|
-
/* 已断 */
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
wsStreams.clear();
|
|
378
|
+
cleanupStreams();
|
|
376
379
|
if (shuttingDown)
|
|
377
380
|
return;
|
|
378
381
|
if (tokenRejected) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
void shutdown("", 1);
|
|
382
|
+
// token 被拒(吊销/删除)= 永久失败,无法自动恢复
|
|
383
|
+
// → 删旧 session + 释放锁 + 停(fail-fast),不重连。
|
|
384
|
+
clearPersistedToken(opts.hubUrl);
|
|
385
|
+
const msg = "host token rejected by hub (revoked or removed); re-join with a new join token";
|
|
386
|
+
log("error", msg);
|
|
387
|
+
setState("rejected", { message: msg });
|
|
388
|
+
shuttingDown = true;
|
|
389
|
+
releaseJoinLock(opts.lockPath);
|
|
388
390
|
return;
|
|
389
391
|
}
|
|
390
|
-
|
|
392
|
+
setState("reconnecting", { delayMs: reconnectDelay });
|
|
393
|
+
log("info", `tunnel lost — reconnecting in ${Math.round(reconnectDelay / 1000)}s...`);
|
|
391
394
|
setTimeout(connect, reconnectDelay + Math.random() * 500);
|
|
392
395
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
393
396
|
});
|
|
@@ -401,7 +404,71 @@ export async function join(opts) {
|
|
|
401
404
|
});
|
|
402
405
|
}
|
|
403
406
|
connect();
|
|
404
|
-
|
|
407
|
+
return {
|
|
408
|
+
async stop() {
|
|
409
|
+
if (shuttingDown)
|
|
410
|
+
return;
|
|
411
|
+
shuttingDown = true;
|
|
412
|
+
cleanupStreams();
|
|
413
|
+
if (currentClient !== undefined) {
|
|
414
|
+
try {
|
|
415
|
+
currentClient.terminate();
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
/* 已关闭 */
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
releaseJoinLock(opts.lockPath);
|
|
422
|
+
setState("stopped");
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/** `rdsh host serve`(join 模式)的 CLI 封装:spawn dsh + 信号退出 + startJoin(role:cli)。 */
|
|
427
|
+
export async function join(opts) {
|
|
428
|
+
const foundDsh = findDsh(opts.dshPath);
|
|
429
|
+
if (foundDsh === null) {
|
|
430
|
+
throw new Error("cannot find 'dsh' in PATH. Install DeepSeek Harness first, or pass --dsh <path>.");
|
|
431
|
+
}
|
|
432
|
+
const dsh = await spawnDsh(foundDsh);
|
|
433
|
+
const target = { host: "127.0.0.1", port: dsh.port };
|
|
434
|
+
// 解析 host token(含证书自动检测 + 持久化);进程重启后复用,避免重复配对。
|
|
435
|
+
const { token, insecure } = await registerJoin(opts);
|
|
436
|
+
console.log(`rdsh join: dsh web on 127.0.0.1:${dsh.port}`);
|
|
437
|
+
console.log(`rdsh join: connecting to ${opts.hubUrl}...`);
|
|
438
|
+
const handle = startJoin({
|
|
439
|
+
hubUrl: opts.hubUrl,
|
|
440
|
+
token,
|
|
441
|
+
insecure,
|
|
442
|
+
target,
|
|
443
|
+
role: "cli",
|
|
444
|
+
hooks: {
|
|
445
|
+
onLog: (level, message) => {
|
|
446
|
+
(level === "error" ? console.error : console.log)(`rdsh join: ${message}`);
|
|
447
|
+
},
|
|
448
|
+
onState: (state, detail) => {
|
|
449
|
+
if (state === "rejected") {
|
|
450
|
+
console.error(`rdsh join: ${detail?.message ?? "rejected"}`);
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
let shuttingDown = false;
|
|
456
|
+
const shutdown = async (signal, code = 0) => {
|
|
457
|
+
if (shuttingDown)
|
|
458
|
+
return;
|
|
459
|
+
shuttingDown = true;
|
|
460
|
+
if (signal !== "")
|
|
461
|
+
console.log(`\nrdsh: received ${signal}, shutting down...`);
|
|
462
|
+
await handle.stop();
|
|
463
|
+
await dsh.stop();
|
|
464
|
+
process.exit(code);
|
|
465
|
+
};
|
|
466
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
467
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
468
|
+
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
469
|
+
// 常驻:进程靠信号退出(shutdown 里 process.exit);防止函数返回后
|
|
470
|
+
// CLI 的 main().then(exit) 误退出服务进程
|
|
471
|
+
await new Promise(() => { });
|
|
405
472
|
}
|
|
406
473
|
function normalizeRespHeaders(headers) {
|
|
407
474
|
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/dist/service.d.ts
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
|
+
/** serve/hub 共用的服务名。 */
|
|
1
2
|
export declare const SERVICE_NAME = "rdsh";
|
|
2
|
-
/**
|
|
3
|
-
export declare
|
|
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,
|
|
6
|
-
/** 安装并启动服务(用户级)。
|
|
7
|
-
export declare function installService(
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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 模板。
|
|
26
|
-
export function systemdUnit(execStart,
|
|
27
|
-
const
|
|
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} ${
|
|
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,
|
|
44
|
-
const args =
|
|
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.
|
|
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
|
-
/** 安装并启动服务(用户级)。
|
|
78
|
-
export async function installService(
|
|
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(
|
|
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",
|
|
85
|
-
return `installed systemd user 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(
|
|
89
|
-
await run("launchctl", ["load",
|
|
90
|
-
return `installed 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",
|
|
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",
|
|
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",
|
|
115
|
-
await rm(
|
|
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 ${
|
|
144
|
+
return `removed ${systemdUnitPath(name)}`;
|
|
118
145
|
}
|
|
119
|
-
await run("launchctl", ["unload",
|
|
120
|
-
await rm(
|
|
121
|
-
return `removed ${
|
|
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
|
package/dist/token-store.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* token-store.ts — `rdsh join` host token 持久化(~/.rdsh/join-<host>[-<port>].token,0600)。
|
|
3
3
|
*
|
|
4
4
|
* 目的:进程重启/崩溃恢复后复用已绑定的 host token,避免每次重新配对;
|
|
5
|
-
* 被 hub 拒绝(吊销/重置)时由 join
|
|
5
|
+
* 被 hub 拒绝(吊销/重置)时由 join 删除该文件并提示重新接入(无自动重配)。
|
|
6
6
|
*
|
|
7
7
|
* 安全:明文 token 只落 gateway 本地(0600),hub 侧仍只存 SHA-256 摘要。
|
|
8
8
|
*/
|