agent-ssh-cli 0.1.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,133 @@
1
+ import { spawn } from "node:child_process";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { getSocketPath, unlinkSocketPath } from "./daemon-paths.js";
6
+
7
+ const DAEMON_START_TIMEOUT_MS = 3000;
8
+ const DAEMON_REQUEST_TIMEOUT_MS = 86400000;
9
+
10
+ function getDaemonEntryPath() {
11
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "ssh-daemon.js");
12
+ }
13
+
14
+ function isMissingSocketError(error) {
15
+ return ["ENOENT", "ECONNREFUSED"].includes(error.code);
16
+ }
17
+
18
+ function connectSocket(socketPath, timeoutMs = DAEMON_REQUEST_TIMEOUT_MS) {
19
+ return new Promise((resolve, reject) => {
20
+ const socket = net.createConnection(socketPath);
21
+ const cleanup = () => {
22
+ clearTimeout(timer);
23
+ socket.removeListener("error", onError);
24
+ };
25
+ const onError = (error) => {
26
+ cleanup();
27
+ reject(error);
28
+ };
29
+ const timer = setTimeout(() => {
30
+ socket.removeListener("error", onError);
31
+ socket.destroy();
32
+ reject(new Error("连接 SSH 缓存进程超时"));
33
+ }, timeoutMs);
34
+ socket.once("connect", () => {
35
+ cleanup();
36
+ resolve(socket);
37
+ });
38
+ socket.once("error", onError);
39
+ });
40
+ }
41
+
42
+ function spawnDaemon(socketPath) {
43
+ const child = spawn(process.execPath, [getDaemonEntryPath(), "--socket", socketPath], {
44
+ detached: true,
45
+ stdio: "ignore"
46
+ });
47
+ child.unref();
48
+ }
49
+
50
+ async function waitForDaemon(socketPath) {
51
+ const startAt = Date.now();
52
+ let lastError;
53
+ while (Date.now() - startAt < DAEMON_START_TIMEOUT_MS) {
54
+ try {
55
+ const socket = await connectSocket(socketPath, 500);
56
+ socket.end();
57
+ return;
58
+ } catch (error) {
59
+ lastError = error;
60
+ await new Promise((resolve) => setTimeout(resolve, 100));
61
+ }
62
+ }
63
+ throw new Error(`启动 SSH 缓存进程失败: ${lastError?.message || "未知错误"}`);
64
+ }
65
+
66
+ async function ensureDaemon(socketPath) {
67
+ try {
68
+ const socket = await connectSocket(socketPath, 500);
69
+ socket.end();
70
+ return;
71
+ } catch (error) {
72
+ if (!isMissingSocketError(error)) {
73
+ throw error;
74
+ }
75
+ if (error.code === "ECONNREFUSED") {
76
+ unlinkSocketPath(socketPath);
77
+ }
78
+ }
79
+ spawnDaemon(socketPath);
80
+ await waitForDaemon(socketPath);
81
+ }
82
+
83
+ export async function requestDaemon(configPath, request) {
84
+ const socketPath = getSocketPath(configPath);
85
+ await ensureDaemon(socketPath);
86
+ const socket = await connectSocket(socketPath);
87
+ socket.setEncoding("utf8");
88
+
89
+ return new Promise((resolve, reject) => {
90
+ let buffer = "";
91
+ let settled = false;
92
+ const cleanup = () => {
93
+ socket.removeAllListeners();
94
+ socket.end();
95
+ };
96
+ const settle = (callback, value) => {
97
+ if (settled) {
98
+ return;
99
+ }
100
+ settled = true;
101
+ cleanup();
102
+ callback(value);
103
+ };
104
+
105
+ socket.on("data", (chunk) => {
106
+ buffer += chunk;
107
+ const lineEnd = buffer.indexOf("\n");
108
+ if (lineEnd === -1) {
109
+ return;
110
+ }
111
+ const line = buffer.slice(0, lineEnd);
112
+ let response;
113
+ try {
114
+ response = JSON.parse(line);
115
+ } catch (error) {
116
+ settle(reject, new Error(`SSH 缓存进程响应非法: ${error.message}`));
117
+ return;
118
+ }
119
+ if (!response.ok) {
120
+ settle(reject, new Error(response.message || "SSH 缓存进程执行失败"));
121
+ return;
122
+ }
123
+ settle(resolve, response);
124
+ });
125
+ socket.on("error", (error) => settle(reject, error));
126
+ socket.on("close", () => {
127
+ if (!settled) {
128
+ settle(reject, new Error("SSH 缓存进程提前关闭连接"));
129
+ }
130
+ });
131
+ socket.write(`${JSON.stringify(request)}\n`);
132
+ });
133
+ }
@@ -0,0 +1,57 @@
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 unlinkSocketPath(socketPath) {
33
+ if (isWindowsPlatform()) {
34
+ return;
35
+ }
36
+ try {
37
+ fs.unlinkSync(socketPath);
38
+ } catch (error) {
39
+ if (error.code !== "ENOENT") {
40
+ throw error;
41
+ }
42
+ }
43
+ }
44
+
45
+ export function chmodSocketPath(socketPath) {
46
+ if (!isWindowsPlatform()) {
47
+ fs.chmodSync(socketPath, 0o600);
48
+ }
49
+ }
50
+
51
+ export function normalizeCacheTtl(value) {
52
+ const ttl = value === undefined ? DEFAULT_CACHE_TTL_MS : Number(value);
53
+ if (!Number.isInteger(ttl) || ttl <= 0) {
54
+ throw new Error("cache-ttl 必须是正整数毫秒值");
55
+ }
56
+ return ttl;
57
+ }
@@ -0,0 +1,215 @@
1
+ import fs from "node:fs";
2
+ import { Client } from "ssh2";
3
+
4
+ function compilePatterns(patterns, label) {
5
+ return (patterns || []).map((pattern) => {
6
+ try {
7
+ return new RegExp(pattern);
8
+ } catch (error) {
9
+ throw new Error(`${label} 正则非法: ${pattern},${error.message}`);
10
+ }
11
+ });
12
+ }
13
+
14
+ export function validateCommand(connection, command) {
15
+ const whitelist = compilePatterns(connection.commandWhitelist, "白名单");
16
+ const blacklist = compilePatterns(connection.commandBlacklist, "黑名单");
17
+
18
+ if (whitelist.length > 0 && !whitelist.some((item) => item.test(command))) {
19
+ throw new Error("命令未命中白名单,拒绝执行");
20
+ }
21
+ if (blacklist.length > 0 && blacklist.some((item) => item.test(command))) {
22
+ throw new Error("命令命中黑名单,拒绝执行");
23
+ }
24
+ }
25
+
26
+ function createConnectConfig(connection) {
27
+ const connectConfig = {
28
+ host: connection.host,
29
+ port: connection.port,
30
+ username: connection.username
31
+ };
32
+ if (connection.agent) {
33
+ connectConfig.agent = connection.agent;
34
+ } else if (connection.privateKey) {
35
+ connectConfig.privateKey = fs.readFileSync(connection.privateKey, "utf8");
36
+ if (connection.passphrase) {
37
+ connectConfig.passphrase = connection.passphrase;
38
+ }
39
+ } else if (connection.password) {
40
+ connectConfig.password = connection.password;
41
+ } else {
42
+ throw new Error(`连接 ${connection.name} 缺少可用认证信息`);
43
+ }
44
+ return connectConfig;
45
+ }
46
+
47
+ export async function connectSshClient(connection) {
48
+ const client = new Client();
49
+ const connectConfig = createConnectConfig(connection);
50
+ await new Promise((resolve, reject) => {
51
+ client.once("ready", resolve);
52
+ client.once("error", reject);
53
+ client.connect(connectConfig);
54
+ });
55
+ return client;
56
+ }
57
+
58
+ export async function withConnection(connection, handler) {
59
+ const client = await connectSshClient(connection);
60
+ try {
61
+ return await handler(client);
62
+ } finally {
63
+ client.end();
64
+ }
65
+ }
66
+
67
+ export async function executeRemoteCommand(connection, command, directory, timeout = 30000) {
68
+ validateCommand(connection, command);
69
+ const remoteCommand = directory ? `cd -- ${JSON.stringify(directory)} && ${command}` : command;
70
+ return withConnection(connection, (client) => executeRemoteCommandWithClient(client, connection, remoteCommand, timeout));
71
+ }
72
+
73
+ export async function executeRemoteCommandWithClient(client, connection, remoteCommand, timeout = 30000) {
74
+ return new Promise((resolve, reject) => {
75
+ let stdout = "";
76
+ let stderr = "";
77
+ let exitCode;
78
+ let exitSignal;
79
+ let settled = false;
80
+ const timer = setTimeout(() => {
81
+ settled = true;
82
+ reject(new Error(`命令执行超时,超过 ${timeout}ms`));
83
+ }, timeout);
84
+
85
+ client.exec(remoteCommand, { pty: connection.pty ?? true }, (err, stream) => {
86
+ if (err) {
87
+ clearTimeout(timer);
88
+ reject(err);
89
+ return;
90
+ }
91
+
92
+ stream.on("data", (chunk) => {
93
+ stdout += chunk.toString();
94
+ });
95
+ stream.stderr.on("data", (chunk) => {
96
+ stderr += chunk.toString();
97
+ });
98
+ stream.on("exit", (code, signal) => {
99
+ exitCode = code;
100
+ exitSignal = signal;
101
+ });
102
+ stream.on("close", (code, signal) => {
103
+ clearTimeout(timer);
104
+ if (settled) {
105
+ return;
106
+ }
107
+ settled = true;
108
+ exitCode = exitCode ?? code;
109
+ exitSignal = exitSignal ?? signal;
110
+ if ((exitCode ?? 0) !== 0 || exitSignal) {
111
+ const parts = [];
112
+ if (stdout.trim()) {
113
+ parts.push(stdout.trimEnd());
114
+ }
115
+ if (stderr.trim()) {
116
+ parts.push(`[stderr]\n${stderr.trimEnd()}`);
117
+ }
118
+ if (exitCode !== undefined) {
119
+ parts.push(`[exit code] ${exitCode}`);
120
+ }
121
+ if (exitSignal) {
122
+ parts.push(`[signal] ${exitSignal}`);
123
+ }
124
+ reject(new Error(parts.join("\n") || "命令执行失败"));
125
+ return;
126
+ }
127
+ resolve(stdout.trimEnd());
128
+ });
129
+ stream.on("error", (streamError) => {
130
+ clearTimeout(timer);
131
+ if (settled) {
132
+ return;
133
+ }
134
+ settled = true;
135
+ reject(streamError);
136
+ });
137
+ });
138
+ });
139
+ }
140
+
141
+ export async function uploadFile(connection, localPath, remotePath) {
142
+ return withConnection(connection, (client) => uploadFileWithClient(client, localPath, remotePath));
143
+ }
144
+
145
+ export async function uploadFileWithClient(client, localPath, remotePath) {
146
+ return new Promise((resolve, reject) => {
147
+ client.sftp((err, sftp) => {
148
+ if (err) {
149
+ reject(err);
150
+ return;
151
+ }
152
+ const readStream = fs.createReadStream(localPath);
153
+ const writeStream = sftp.createWriteStream(remotePath);
154
+ let closed = false;
155
+ const cleanup = () => {
156
+ if (!closed) {
157
+ closed = true;
158
+ sftp.end();
159
+ }
160
+ };
161
+
162
+ readStream.on("error", (readError) => {
163
+ cleanup();
164
+ reject(readError);
165
+ });
166
+ writeStream.on("error", (writeError) => {
167
+ cleanup();
168
+ reject(writeError);
169
+ });
170
+ writeStream.on("close", () => {
171
+ cleanup();
172
+ resolve();
173
+ });
174
+ readStream.pipe(writeStream);
175
+ });
176
+ });
177
+ }
178
+
179
+ export async function downloadFile(connection, remotePath, localPath) {
180
+ return withConnection(connection, (client) => downloadFileWithClient(client, remotePath, localPath));
181
+ }
182
+
183
+ export async function downloadFileWithClient(client, remotePath, localPath) {
184
+ return new Promise((resolve, reject) => {
185
+ client.sftp((err, sftp) => {
186
+ if (err) {
187
+ reject(err);
188
+ return;
189
+ }
190
+ const readStream = sftp.createReadStream(remotePath);
191
+ const writeStream = fs.createWriteStream(localPath);
192
+ let closed = false;
193
+ const cleanup = () => {
194
+ if (!closed) {
195
+ closed = true;
196
+ sftp.end();
197
+ }
198
+ };
199
+
200
+ readStream.on("error", (readError) => {
201
+ cleanup();
202
+ reject(readError);
203
+ });
204
+ writeStream.on("error", (writeError) => {
205
+ cleanup();
206
+ reject(writeError);
207
+ });
208
+ writeStream.on("close", () => {
209
+ cleanup();
210
+ resolve();
211
+ });
212
+ readStream.pipe(writeStream);
213
+ });
214
+ });
215
+ }
@@ -0,0 +1,236 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { findConnection, loadConfig, validateLocalPath } from "./config.js";
7
+ import { chmodSocketPath, DEFAULT_CACHE_TTL_MS, normalizeCacheTtl, unlinkSocketPath } from "./daemon-paths.js";
8
+ import {
9
+ connectSshClient,
10
+ downloadFileWithClient,
11
+ executeRemoteCommandWithClient,
12
+ uploadFileWithClient,
13
+ validateCommand
14
+ } from "./ssh-client.js";
15
+
16
+ const connections = new Map();
17
+ let activeRequests = 0;
18
+ let lastActivityAt = Date.now();
19
+ let exitTimer;
20
+ let server;
21
+ let socketPath;
22
+
23
+ function parseArgs(argv) {
24
+ const socketIndex = argv.indexOf("--socket");
25
+ const value = socketIndex === -1 ? undefined : argv[socketIndex + 1];
26
+ if (!value) {
27
+ throw new Error("daemon 缺少 --socket 参数");
28
+ }
29
+ return {
30
+ socketPath: value
31
+ };
32
+ }
33
+
34
+ function writeResponse(socket, response) {
35
+ socket.write(`${JSON.stringify(response)}\n`, () => socket.end());
36
+ }
37
+
38
+ function buildConnectionKey(configPath, connection) {
39
+ const auth = connection.agent
40
+ ? { type: "agent", value: connection.agent }
41
+ : connection.privateKey
42
+ ? { type: "privateKey", value: connection.privateKey, passphrase: connection.passphrase }
43
+ : { type: "password", value: connection.password };
44
+ const raw = JSON.stringify({
45
+ configPath: path.resolve(configPath),
46
+ name: connection.name,
47
+ host: connection.host,
48
+ port: connection.port,
49
+ username: connection.username,
50
+ auth
51
+ });
52
+ return crypto.createHash("sha256").update(raw).digest("hex");
53
+ }
54
+
55
+ function removeEntry(key) {
56
+ const entry = connections.get(key);
57
+ if (!entry) {
58
+ return;
59
+ }
60
+ connections.delete(key);
61
+ if (entry.client) {
62
+ entry.client.end();
63
+ }
64
+ }
65
+
66
+ function scheduleExitCheck() {
67
+ clearTimeout(exitTimer);
68
+ if (activeRequests > 0) {
69
+ return;
70
+ }
71
+ const now = Date.now();
72
+ let waitMs = DEFAULT_CACHE_TTL_MS;
73
+ if (connections.size === 0) {
74
+ waitMs = Math.max(100, DEFAULT_CACHE_TTL_MS - (now - lastActivityAt));
75
+ }
76
+ for (const entry of connections.values()) {
77
+ waitMs = Math.min(waitMs, Math.max(100, entry.ttlMs - (now - entry.lastUsedAt)));
78
+ }
79
+ exitTimer = setTimeout(() => {
80
+ if (activeRequests > 0) {
81
+ scheduleExitCheck();
82
+ return;
83
+ }
84
+ const checkedAt = Date.now();
85
+ for (const [key, entry] of connections.entries()) {
86
+ if (checkedAt - entry.lastUsedAt >= entry.ttlMs) {
87
+ removeEntry(key);
88
+ }
89
+ }
90
+ if (connections.size === 0 && checkedAt - lastActivityAt >= DEFAULT_CACHE_TTL_MS) {
91
+ shutdown();
92
+ return;
93
+ }
94
+ if (connections.size === 0) {
95
+ shutdown();
96
+ return;
97
+ }
98
+ scheduleExitCheck();
99
+ }, waitMs);
100
+ }
101
+
102
+ async function getPoolEntry(configPath, connection, ttlMs) {
103
+ const key = buildConnectionKey(configPath, connection);
104
+ let entry = connections.get(key);
105
+ if (!entry) {
106
+ entry = {
107
+ key,
108
+ client: undefined,
109
+ clientPromise: undefined,
110
+ queue: Promise.resolve(),
111
+ lastUsedAt: Date.now(),
112
+ ttlMs
113
+ };
114
+ entry.clientPromise = connectSshClient(connection)
115
+ .then((client) => {
116
+ entry.client = client;
117
+ client.on("error", () => removeEntry(key));
118
+ client.on("close", () => removeEntry(key));
119
+ return client;
120
+ })
121
+ .catch((error) => {
122
+ connections.delete(key);
123
+ throw error;
124
+ });
125
+ connections.set(key, entry);
126
+ }
127
+ entry.ttlMs = ttlMs;
128
+ entry.lastUsedAt = Date.now();
129
+ await entry.clientPromise;
130
+ return entry;
131
+ }
132
+
133
+ async function runSerialized(entry, operation) {
134
+ const run = async () => {
135
+ const client = await entry.clientPromise;
136
+ return operation(client);
137
+ };
138
+ const resultPromise = entry.queue.then(run, run);
139
+ entry.queue = resultPromise.catch(() => undefined);
140
+ return resultPromise;
141
+ }
142
+
143
+ async function executeRequest(request) {
144
+ const ttlMs = normalizeCacheTtl(request.cacheTtlMs);
145
+ const configs = loadConfig(request.configPath);
146
+ const connection = findConnection(configs, request.connectionName);
147
+ if (request.operation === "execute") {
148
+ validateCommand(connection, request.command);
149
+ }
150
+ const entry = await getPoolEntry(request.configPath, connection, ttlMs);
151
+
152
+ try {
153
+ if (request.operation === "execute") {
154
+ const remoteCommand = request.directory ? `cd -- ${JSON.stringify(request.directory)} && ${request.command}` : request.command;
155
+ const stdout = await runSerialized(entry, (client) => {
156
+ return executeRemoteCommandWithClient(client, connection, remoteCommand, request.timeout);
157
+ });
158
+ return { stdout };
159
+ }
160
+ if (request.operation === "upload") {
161
+ const localPath = validateLocalPath(configs, request.localPath, request.cwd);
162
+ await runSerialized(entry, (client) => uploadFileWithClient(client, localPath, request.remotePath));
163
+ return {};
164
+ }
165
+ if (request.operation === "download") {
166
+ const localPath = validateLocalPath(configs, request.localPath, request.cwd);
167
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
168
+ await runSerialized(entry, (client) => downloadFileWithClient(client, request.remotePath, localPath));
169
+ return {};
170
+ }
171
+ throw new Error(`不支持的 daemon 操作: ${request.operation}`);
172
+ } finally {
173
+ entry.lastUsedAt = Date.now();
174
+ lastActivityAt = entry.lastUsedAt;
175
+ }
176
+ }
177
+
178
+ function handleSocket(socket) {
179
+ socket.setEncoding("utf8");
180
+ let buffer = "";
181
+ socket.on("data", (chunk) => {
182
+ buffer += chunk;
183
+ const lineEnd = buffer.indexOf("\n");
184
+ if (lineEnd === -1) {
185
+ return;
186
+ }
187
+ const line = buffer.slice(0, lineEnd);
188
+ activeRequests += 1;
189
+ Promise.resolve()
190
+ .then(() => JSON.parse(line))
191
+ .then((request) => executeRequest(request))
192
+ .then((result) => writeResponse(socket, { ok: true, ...result }))
193
+ .catch((error) => writeResponse(socket, { ok: false, message: error.message }))
194
+ .finally(() => {
195
+ activeRequests -= 1;
196
+ lastActivityAt = Date.now();
197
+ scheduleExitCheck();
198
+ });
199
+ });
200
+ }
201
+
202
+ function shutdown() {
203
+ clearTimeout(exitTimer);
204
+ for (const key of Array.from(connections.keys())) {
205
+ removeEntry(key);
206
+ }
207
+ if (server) {
208
+ server.close(() => {
209
+ try {
210
+ unlinkSocketPath(socketPath);
211
+ } catch (error) {
212
+ if (error.code !== "ENOENT") {
213
+ process.stderr.write(`${error.message}\n`);
214
+ }
215
+ }
216
+ process.exit(0);
217
+ });
218
+ return;
219
+ }
220
+ process.exit(0);
221
+ }
222
+
223
+ try {
224
+ ({ socketPath } = parseArgs(process.argv.slice(2)));
225
+ unlinkSocketPath(socketPath);
226
+ server = net.createServer(handleSocket);
227
+ server.listen(socketPath, () => {
228
+ chmodSocketPath(socketPath);
229
+ scheduleExitCheck();
230
+ });
231
+ process.on("SIGTERM", shutdown);
232
+ process.on("SIGINT", shutdown);
233
+ } catch (error) {
234
+ process.stderr.write(`${error.message}\n`);
235
+ process.exit(1);
236
+ }