agent-ssh-cli 0.1.2 → 0.2.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/src/config.js DELETED
@@ -1,177 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import process from "node:process";
5
- import { fileURLToPath } from "node:url";
6
-
7
- const DEFAULT_CONFIG_DIR = ".agent-ssh-cli";
8
- const DEFAULT_CONFIG_FILE = "config.json";
9
- const configCache = new Map();
10
-
11
- function isNonEmptyString(value) {
12
- return typeof value === "string" && value.trim() !== "";
13
- }
14
-
15
- function ensureRegexArray(patterns, fieldName, index) {
16
- if (patterns === undefined) {
17
- return [];
18
- }
19
- if (!Array.isArray(patterns)) {
20
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须是字符串数组`);
21
- }
22
- return patterns.map((pattern) => {
23
- if (!isNonEmptyString(pattern)) {
24
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须只包含非空字符串`);
25
- }
26
- try {
27
- return {
28
- pattern,
29
- regex: new RegExp(pattern)
30
- };
31
- } catch (error) {
32
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 含有非法正则: ${pattern},${error.message}`);
33
- }
34
- });
35
- }
36
-
37
- function ensureStringArray(values, fieldName, index) {
38
- if (values === undefined) {
39
- return [];
40
- }
41
- if (!Array.isArray(values)) {
42
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须是字符串数组`);
43
- }
44
- return values.map((value) => {
45
- if (!isNonEmptyString(value)) {
46
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须只包含非空字符串`);
47
- }
48
- return value;
49
- });
50
- }
51
-
52
- function normalizeConfigEntry(entry, index) {
53
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
54
- throw new Error(`ssh-config.json 第 ${index + 1} 项必须是对象`);
55
- }
56
- if (!isNonEmptyString(entry.name)) {
57
- throw new Error(`ssh-config.json 第 ${index + 1} 项缺少合法的 name`);
58
- }
59
- if (!isNonEmptyString(entry.host)) {
60
- throw new Error(`ssh-config.json 第 ${index + 1} 项缺少合法的 host`);
61
- }
62
- if (!isNonEmptyString(entry.username)) {
63
- throw new Error(`ssh-config.json 第 ${index + 1} 项缺少合法的 username`);
64
- }
65
- const port = Number(entry.port || 22);
66
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
67
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 port 非法`);
68
- }
69
- if (entry.pty !== undefined && typeof entry.pty !== "boolean") {
70
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 pty 必须是布尔值`);
71
- }
72
- const hasPassword = isNonEmptyString(entry.password);
73
- const hasPrivateKey = isNonEmptyString(entry.privateKey);
74
- const hasAgent = isNonEmptyString(entry.agent);
75
- const authMethodCount = [hasPassword, hasPrivateKey, hasAgent].filter(Boolean).length;
76
- if (authMethodCount === 0) {
77
- throw new Error(`ssh-config.json 第 ${index + 1} 项必须配置 password、privateKey 或 agent 其中之一`);
78
- }
79
- if (authMethodCount > 1) {
80
- throw new Error(`ssh-config.json 第 ${index + 1} 项同时配置了多个认证方式,只允许保留一种`);
81
- }
82
- if (entry.passphrase !== undefined && !isNonEmptyString(entry.passphrase)) {
83
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 passphrase 必须是非空字符串`);
84
- }
85
- if (entry.socksProxy !== undefined && !isNonEmptyString(entry.socksProxy)) {
86
- throw new Error(`ssh-config.json 第 ${index + 1} 项的 socksProxy 必须是非空字符串`);
87
- }
88
- return {
89
- name: entry.name,
90
- host: entry.host,
91
- port,
92
- username: entry.username,
93
- password: hasPassword ? entry.password : undefined,
94
- privateKey: hasPrivateKey ? entry.privateKey : undefined,
95
- passphrase: entry.passphrase,
96
- agent: hasAgent ? entry.agent : undefined,
97
- socksProxy: entry.socksProxy,
98
- pty: entry.pty,
99
- allowedLocalPaths: ensureStringArray(entry.allowedLocalPaths, "allowedLocalPaths", index),
100
- commandWhitelist: ensureRegexArray(entry.commandWhitelist, "commandWhitelist", index),
101
- commandBlacklist: ensureRegexArray(entry.commandBlacklist, "commandBlacklist", index)
102
- };
103
- }
104
-
105
- export function getProjectRoot() {
106
- return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
107
- }
108
-
109
- export function getDefaultConfigPath() {
110
- if (isNonEmptyString(process.env.AGENT_SSH_CONFIG)) {
111
- return path.resolve(process.env.AGENT_SSH_CONFIG);
112
- }
113
- return path.join(os.homedir(), DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILE);
114
- }
115
-
116
- export function loadConfig(configPath = getDefaultConfigPath()) {
117
- const resolvedConfigPath = path.resolve(configPath);
118
- const stat = fs.statSync(resolvedConfigPath);
119
- const cached = configCache.get(resolvedConfigPath);
120
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
121
- return cached.configs;
122
- }
123
- const raw = fs.readFileSync(resolvedConfigPath, "utf8");
124
- let parsed;
125
- try {
126
- parsed = JSON.parse(raw);
127
- } catch (error) {
128
- throw new Error(`ssh-config.json 解析失败: ${error.message}`);
129
- }
130
- if (!Array.isArray(parsed)) {
131
- throw new Error("ssh-config.json 必须是数组格式");
132
- }
133
- const configs = parsed.map((item, index) => normalizeConfigEntry(item, index));
134
- if (configs.length === 0) {
135
- throw new Error("ssh-config.json 不能为空");
136
- }
137
- const seenNames = new Set();
138
- for (const config of configs) {
139
- if (seenNames.has(config.name)) {
140
- throw new Error(`ssh-config.json 存在重复的连接名: ${config.name}`);
141
- }
142
- seenNames.add(config.name);
143
- }
144
- configCache.set(resolvedConfigPath, {
145
- mtimeMs: stat.mtimeMs,
146
- size: stat.size,
147
- configs
148
- });
149
- return configs;
150
- }
151
-
152
- export function findConnection(configs, connectionName) {
153
- const name = connectionName || configs[0]?.name;
154
- const config = configs.find((item) => item.name === name);
155
- if (!config) {
156
- throw new Error(`未找到连接配置: ${name}`);
157
- }
158
- return config;
159
- }
160
-
161
- export function validateLocalPath(configs, localPath, baseCwd = process.cwd()) {
162
- const resolvedCwd = path.resolve(baseCwd);
163
- const resolvedPath = path.resolve(resolvedCwd, localPath);
164
- const allowedRoots = new Set([resolvedCwd, getProjectRoot()]);
165
- for (const config of configs) {
166
- for (const allowedPath of config.allowedLocalPaths || []) {
167
- allowedRoots.add(path.resolve(allowedPath));
168
- }
169
- }
170
- const isAllowed = Array.from(allowedRoots).some((allowedRoot) => {
171
- return resolvedPath === allowedRoot || resolvedPath.startsWith(`${allowedRoot}${path.sep}`);
172
- });
173
- if (!isAllowed) {
174
- throw new Error("本地路径不允许访问,必须位于当前工作目录、项目目录或显式允许的路径内");
175
- }
176
- return resolvedPath;
177
- }
@@ -1,153 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import crypto from "node:crypto";
3
- import fs from "node:fs";
4
- import net from "node:net";
5
- import path from "node:path";
6
- import { fileURLToPath } from "node:url";
7
- import { getSocketPath, getTokenPath, unlinkSocketPath } from "./daemon-paths.js";
8
-
9
- const DAEMON_START_TIMEOUT_MS = 3000;
10
- const DAEMON_REQUEST_TIMEOUT_MS = 86400000;
11
-
12
- function getDaemonEntryPath() {
13
- return path.join(path.dirname(fileURLToPath(import.meta.url)), "ssh-daemon.js");
14
- }
15
-
16
- function isMissingSocketError(error) {
17
- return ["ENOENT", "ECONNREFUSED"].includes(error.code);
18
- }
19
-
20
- function connectSocket(socketPath, timeoutMs = DAEMON_REQUEST_TIMEOUT_MS) {
21
- return new Promise((resolve, reject) => {
22
- const socket = net.createConnection(socketPath);
23
- const cleanup = () => {
24
- clearTimeout(timer);
25
- socket.removeListener("error", onError);
26
- };
27
- const onError = (error) => {
28
- cleanup();
29
- reject(error);
30
- };
31
- const timer = setTimeout(() => {
32
- socket.removeListener("error", onError);
33
- socket.destroy();
34
- reject(new Error("连接 SSH 缓存进程超时"));
35
- }, timeoutMs);
36
- socket.once("connect", () => {
37
- cleanup();
38
- resolve(socket);
39
- });
40
- socket.once("error", onError);
41
- });
42
- }
43
-
44
- function readToken(tokenPath) {
45
- return fs.readFileSync(tokenPath, "utf8").trim();
46
- }
47
-
48
- function createToken(tokenPath) {
49
- const token = crypto.randomBytes(32).toString("hex");
50
- fs.writeFileSync(tokenPath, token, { mode: 0o600 });
51
- fs.chmodSync(tokenPath, 0o600);
52
- return token;
53
- }
54
-
55
- function spawnDaemon(socketPath, configPath, tokenPath) {
56
- const child = spawn(process.execPath, [getDaemonEntryPath(), "--socket", socketPath, "--config", path.resolve(configPath), "--token-file", tokenPath], {
57
- detached: true,
58
- stdio: "ignore"
59
- });
60
- child.unref();
61
- }
62
-
63
- async function waitForDaemon(socketPath) {
64
- const startAt = Date.now();
65
- let lastError;
66
- while (Date.now() - startAt < DAEMON_START_TIMEOUT_MS) {
67
- try {
68
- const socket = await connectSocket(socketPath, 500);
69
- socket.end();
70
- return;
71
- } catch (error) {
72
- lastError = error;
73
- await new Promise((resolve) => setTimeout(resolve, 100));
74
- }
75
- }
76
- throw new Error(`启动 SSH 缓存进程失败: ${lastError?.message || "未知错误"}`);
77
- }
78
-
79
- async function ensureDaemon(socketPath, configPath, tokenPath) {
80
- try {
81
- const socket = await connectSocket(socketPath, 500);
82
- socket.end();
83
- try {
84
- return readToken(tokenPath);
85
- } catch (error) {
86
- unlinkSocketPath(socketPath);
87
- }
88
- } catch (error) {
89
- if (!isMissingSocketError(error)) {
90
- throw error;
91
- }
92
- if (error.code === "ECONNREFUSED") {
93
- unlinkSocketPath(socketPath);
94
- }
95
- }
96
- const token = createToken(tokenPath);
97
- spawnDaemon(socketPath, configPath, tokenPath);
98
- await waitForDaemon(socketPath);
99
- return token;
100
- }
101
-
102
- export async function requestDaemon(configPath, request) {
103
- const socketPath = getSocketPath(configPath);
104
- const tokenPath = getTokenPath(configPath);
105
- const token = await ensureDaemon(socketPath, configPath, tokenPath);
106
- const socket = await connectSocket(socketPath);
107
- socket.setEncoding("utf8");
108
-
109
- return new Promise((resolve, reject) => {
110
- let buffer = "";
111
- let settled = false;
112
- const cleanup = () => {
113
- socket.removeAllListeners();
114
- socket.end();
115
- };
116
- const settle = (callback, value) => {
117
- if (settled) {
118
- return;
119
- }
120
- settled = true;
121
- cleanup();
122
- callback(value);
123
- };
124
-
125
- socket.on("data", (chunk) => {
126
- buffer += chunk;
127
- const lineEnd = buffer.indexOf("\n");
128
- if (lineEnd === -1) {
129
- return;
130
- }
131
- const line = buffer.slice(0, lineEnd);
132
- let response;
133
- try {
134
- response = JSON.parse(line);
135
- } catch (error) {
136
- settle(reject, new Error(`SSH 缓存进程响应非法: ${error.message}`));
137
- return;
138
- }
139
- if (!response.ok) {
140
- settle(reject, new Error(response.message || "SSH 缓存进程执行失败"));
141
- return;
142
- }
143
- settle(resolve, response);
144
- });
145
- socket.on("error", (error) => settle(reject, error));
146
- socket.on("close", () => {
147
- if (!settled) {
148
- settle(reject, new Error("SSH 缓存进程提前关闭连接"));
149
- }
150
- });
151
- socket.write(`${JSON.stringify({ ...request, token })}\n`);
152
- });
153
- }
@@ -1,62 +0,0 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import process from "node:process";
6
-
7
- export const DEFAULT_CACHE_TTL_MS = 180000;
8
-
9
- export function isWindowsPlatform() {
10
- return process.platform === "win32";
11
- }
12
-
13
- export function getDaemonDir() {
14
- const uid = typeof process.getuid === "function" ? process.getuid() : "nouid";
15
- const daemonDir = path.join(os.tmpdir(), `agent-ssh-cli-${uid}`);
16
- fs.mkdirSync(daemonDir, { recursive: true, mode: 0o700 });
17
- fs.chmodSync(daemonDir, 0o700);
18
- return daemonDir;
19
- }
20
-
21
- export function getSocketPath(configPath) {
22
- const digest = crypto.createHash("sha256").update(path.resolve(configPath)).digest("hex").slice(0, 24);
23
- if (isWindowsPlatform()) {
24
- // Windows 下 Node IPC 使用 named pipe,不是文件系统 socket。
25
- const userKey = process.env.USERPROFILE || process.env.USERNAME || os.homedir() || "nouser";
26
- const userDigest = crypto.createHash("sha256").update(userKey).digest("hex").slice(0, 12);
27
- return `\\\\.\\pipe\\agent-ssh-cli-${userDigest}-${digest}`;
28
- }
29
- return path.join(getDaemonDir(), `${digest}.sock`);
30
- }
31
-
32
- export function getTokenPath(configPath) {
33
- const digest = crypto.createHash("sha256").update(path.resolve(configPath)).digest("hex").slice(0, 24);
34
- return path.join(getDaemonDir(), `${digest}.token`);
35
- }
36
-
37
- export function unlinkSocketPath(socketPath) {
38
- if (isWindowsPlatform()) {
39
- return;
40
- }
41
- try {
42
- fs.unlinkSync(socketPath);
43
- } catch (error) {
44
- if (error.code !== "ENOENT") {
45
- throw error;
46
- }
47
- }
48
- }
49
-
50
- export function chmodSocketPath(socketPath) {
51
- if (!isWindowsPlatform()) {
52
- fs.chmodSync(socketPath, 0o600);
53
- }
54
- }
55
-
56
- export function normalizeCacheTtl(value) {
57
- const ttl = value === undefined ? DEFAULT_CACHE_TTL_MS : Number(value);
58
- if (!Number.isInteger(ttl) || ttl <= 0) {
59
- throw new Error("cache-ttl 必须是正整数毫秒值");
60
- }
61
- return ttl;
62
- }