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/ssh-client.js DELETED
@@ -1,401 +0,0 @@
1
- import fs from "node:fs";
2
- import net from "node:net";
3
- import { pipeline } from "node:stream/promises";
4
- import { Client } from "ssh2";
5
-
6
- const SOCKS5_VERSION = 0x05;
7
- const SOCKS5_CONNECT_COMMAND = 0x01;
8
- const SOCKS5_RESERVED = 0x00;
9
- const SOCKS5_AUTH_NONE = 0x00;
10
- const SOCKS5_AUTH_PASSWORD = 0x02;
11
- const SOCKS5_AUTH_UNACCEPTABLE = 0xff;
12
- const SOCKS5_ADDRESS_IPV4 = 0x01;
13
- const SOCKS5_ADDRESS_DOMAIN = 0x03;
14
- const SOCKS5_ADDRESS_IPV6 = 0x04;
15
- const SOCKS5_REPLY_SUCCESS = 0x00;
16
-
17
- function getCompiledPattern(pattern, label) {
18
- if (pattern instanceof RegExp) {
19
- return pattern;
20
- }
21
- if (pattern?.regex instanceof RegExp) {
22
- return pattern.regex;
23
- }
24
- try {
25
- return new RegExp(pattern?.pattern || pattern);
26
- } catch (error) {
27
- throw new Error(`${label} 正则非法: ${pattern?.pattern || pattern},${error.message}`);
28
- }
29
- }
30
-
31
- function compilePatterns(patterns, label) {
32
- return (patterns || []).map((pattern) => {
33
- try {
34
- return getCompiledPattern(pattern, label);
35
- } catch (error) {
36
- throw new Error(error.message);
37
- }
38
- });
39
- }
40
-
41
- export function validateCommand(connection, command) {
42
- const whitelist = compilePatterns(connection.commandWhitelist, "白名单");
43
- const blacklist = compilePatterns(connection.commandBlacklist, "黑名单");
44
-
45
- if (whitelist.length > 0 && !whitelist.some((item) => item.test(command))) {
46
- throw new Error("命令未命中白名单,拒绝执行");
47
- }
48
- if (blacklist.length > 0 && blacklist.some((item) => item.test(command))) {
49
- throw new Error("命令命中黑名单,拒绝执行");
50
- }
51
- }
52
-
53
- function parseSocksProxy(proxy) {
54
- const value = proxy.includes("://") ? proxy : `socks5://${proxy}`;
55
- let parsed;
56
- try {
57
- parsed = new URL(value);
58
- } catch (error) {
59
- throw new Error(`socksProxy 格式非法: ${proxy},${error.message}`);
60
- }
61
- if (parsed.protocol !== "socks5:") {
62
- throw new Error("socksProxy 仅支持 socks5:// 协议");
63
- }
64
- if (!parsed.hostname || !parsed.port) {
65
- throw new Error("socksProxy 必须包含代理主机和端口");
66
- }
67
- const port = Number(parsed.port);
68
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
69
- throw new Error("socksProxy 端口非法");
70
- }
71
- const username = decodeURIComponent(parsed.username);
72
- const password = decodeURIComponent(parsed.password);
73
- if ((username && !password) || (!username && password)) {
74
- throw new Error("socksProxy 用户名和密码必须同时提供");
75
- }
76
- return {
77
- host: parsed.hostname,
78
- port,
79
- username: username || undefined,
80
- password: password || undefined
81
- };
82
- }
83
-
84
- function readExactly(socket, length) {
85
- return new Promise((resolve, reject) => {
86
- let buffer = Buffer.alloc(0);
87
- const cleanup = () => {
88
- socket.removeListener("data", onData);
89
- socket.removeListener("error", onError);
90
- socket.removeListener("close", onClose);
91
- };
92
- const onError = (error) => {
93
- cleanup();
94
- reject(error);
95
- };
96
- const onClose = () => {
97
- cleanup();
98
- reject(new Error("SOCKS5 代理连接提前关闭"));
99
- };
100
- const onData = (chunk) => {
101
- buffer = Buffer.concat([buffer, chunk]);
102
- if (buffer.length < length) {
103
- return;
104
- }
105
- const result = buffer.subarray(0, length);
106
- const rest = buffer.subarray(length);
107
- cleanup();
108
- if (rest.length > 0) {
109
- socket.unshift(rest);
110
- }
111
- resolve(result);
112
- };
113
- socket.on("data", onData);
114
- socket.once("error", onError);
115
- socket.once("close", onClose);
116
- });
117
- }
118
-
119
- function writeAll(socket, data) {
120
- return new Promise((resolve, reject) => {
121
- socket.write(data, (error) => {
122
- if (error) {
123
- reject(error);
124
- return;
125
- }
126
- resolve();
127
- });
128
- });
129
- }
130
-
131
- function connectTcp(host, port) {
132
- return new Promise((resolve, reject) => {
133
- const socket = net.createConnection({ host, port });
134
- const cleanup = () => {
135
- socket.removeListener("error", onError);
136
- };
137
- const onError = (error) => {
138
- cleanup();
139
- reject(error);
140
- };
141
- socket.once("connect", () => {
142
- cleanup();
143
- resolve(socket);
144
- });
145
- socket.once("error", onError);
146
- });
147
- }
148
-
149
- function encodeTargetAddress(host) {
150
- const ipVersion = net.isIP(host);
151
- if (ipVersion === 4) {
152
- return Buffer.concat([Buffer.from([SOCKS5_ADDRESS_IPV4]), Buffer.from(host.split(".").map(Number))]);
153
- }
154
- const hostBuffer = Buffer.from(host, "utf8");
155
- if (hostBuffer.length > 255) {
156
- throw new Error("SOCKS5 目标主机名过长");
157
- }
158
- return Buffer.concat([Buffer.from([SOCKS5_ADDRESS_DOMAIN, hostBuffer.length]), hostBuffer]);
159
- }
160
-
161
- async function authenticateSocksProxy(socket, proxyConfig) {
162
- const methods = proxyConfig.username ? [SOCKS5_AUTH_PASSWORD] : [SOCKS5_AUTH_NONE];
163
- await writeAll(socket, Buffer.from([SOCKS5_VERSION, methods.length, ...methods]));
164
- const response = await readExactly(socket, 2);
165
- if (response[0] !== SOCKS5_VERSION) {
166
- throw new Error("SOCKS5 代理响应版本非法");
167
- }
168
- if (response[1] === SOCKS5_AUTH_UNACCEPTABLE) {
169
- throw new Error("SOCKS5 代理不接受当前认证方式");
170
- }
171
- if (response[1] === SOCKS5_AUTH_NONE) {
172
- return;
173
- }
174
- if (response[1] !== SOCKS5_AUTH_PASSWORD || !proxyConfig.username) {
175
- throw new Error("SOCKS5 代理返回了不支持的认证方式");
176
- }
177
- const usernameBuffer = Buffer.from(proxyConfig.username, "utf8");
178
- const passwordBuffer = Buffer.from(proxyConfig.password, "utf8");
179
- if (usernameBuffer.length > 255 || passwordBuffer.length > 255) {
180
- throw new Error("SOCKS5 用户名或密码过长");
181
- }
182
- await writeAll(socket, Buffer.concat([
183
- Buffer.from([0x01, usernameBuffer.length]),
184
- usernameBuffer,
185
- Buffer.from([passwordBuffer.length]),
186
- passwordBuffer
187
- ]));
188
- const authResponse = await readExactly(socket, 2);
189
- if (authResponse[1] !== 0x00) {
190
- throw new Error("SOCKS5 代理认证失败");
191
- }
192
- }
193
-
194
- async function readSocksConnectResponse(socket) {
195
- const header = await readExactly(socket, 4);
196
- if (header[0] !== SOCKS5_VERSION) {
197
- throw new Error("SOCKS5 代理响应版本非法");
198
- }
199
- if (header[1] !== SOCKS5_REPLY_SUCCESS) {
200
- throw new Error(`SOCKS5 代理连接目标失败,响应码 ${header[1]}`);
201
- }
202
- if (header[2] !== SOCKS5_RESERVED) {
203
- throw new Error("SOCKS5 代理响应保留字段非法");
204
- }
205
- if (header[3] === SOCKS5_ADDRESS_IPV4) {
206
- await readExactly(socket, 4);
207
- } else if (header[3] === SOCKS5_ADDRESS_IPV6) {
208
- await readExactly(socket, 16);
209
- } else if (header[3] === SOCKS5_ADDRESS_DOMAIN) {
210
- const length = (await readExactly(socket, 1))[0];
211
- await readExactly(socket, length);
212
- } else {
213
- throw new Error("SOCKS5 代理响应地址类型非法");
214
- }
215
- await readExactly(socket, 2);
216
- }
217
-
218
- async function connectSocksProxy(connection) {
219
- const proxyConfig = parseSocksProxy(connection.socksProxy);
220
- const socket = await connectTcp(proxyConfig.host, proxyConfig.port);
221
- try {
222
- await authenticateSocksProxy(socket, proxyConfig);
223
- const targetAddress = encodeTargetAddress(connection.host);
224
- const targetPort = Buffer.alloc(2);
225
- targetPort.writeUInt16BE(connection.port);
226
- await writeAll(socket, Buffer.concat([
227
- Buffer.from([SOCKS5_VERSION, SOCKS5_CONNECT_COMMAND, SOCKS5_RESERVED]),
228
- targetAddress,
229
- targetPort
230
- ]));
231
- await readSocksConnectResponse(socket);
232
- return socket;
233
- } catch (error) {
234
- socket.destroy();
235
- throw error;
236
- }
237
- }
238
-
239
- async function createConnectConfig(connection) {
240
- const connectConfig = {
241
- host: connection.host,
242
- port: connection.port,
243
- username: connection.username
244
- };
245
- if (connection.socksProxy) {
246
- connectConfig.sock = await connectSocksProxy(connection);
247
- }
248
- if (connection.agent) {
249
- connectConfig.agent = connection.agent;
250
- } else if (connection.privateKey) {
251
- connectConfig.privateKey = fs.readFileSync(connection.privateKey, "utf8");
252
- if (connection.passphrase) {
253
- connectConfig.passphrase = connection.passphrase;
254
- }
255
- } else if (connection.password) {
256
- connectConfig.password = connection.password;
257
- } else {
258
- throw new Error(`连接 ${connection.name} 缺少可用认证信息`);
259
- }
260
- return connectConfig;
261
- }
262
-
263
- export async function connectSshClient(connection) {
264
- const client = new Client();
265
- const connectConfig = await createConnectConfig(connection);
266
- await new Promise((resolve, reject) => {
267
- client.once("ready", resolve);
268
- client.once("error", reject);
269
- client.connect(connectConfig);
270
- });
271
- return client;
272
- }
273
-
274
- export async function withConnection(connection, handler) {
275
- const client = await connectSshClient(connection);
276
- try {
277
- return await handler(client);
278
- } finally {
279
- client.end();
280
- }
281
- }
282
-
283
- export async function executeRemoteCommand(connection, command, directory, timeout = 30000) {
284
- validateCommand(connection, command);
285
- const remoteCommand = directory ? `cd -- ${JSON.stringify(directory)} && ${command}` : command;
286
- return withConnection(connection, (client) => executeRemoteCommandWithClient(client, connection, remoteCommand, timeout));
287
- }
288
-
289
- export async function executeRemoteCommandWithClient(client, connection, remoteCommand, timeout = 30000) {
290
- return new Promise((resolve, reject) => {
291
- let stdout = "";
292
- let stderr = "";
293
- let exitCode;
294
- let exitSignal;
295
- let settled = false;
296
- let commandStream;
297
- const settle = (callback, value) => {
298
- if (settled) {
299
- return;
300
- }
301
- settled = true;
302
- clearTimeout(timer);
303
- callback(value);
304
- };
305
- const timer = setTimeout(() => {
306
- if (commandStream) {
307
- commandStream.close();
308
- }
309
- settle(reject, new Error(`命令执行超时,超过 ${timeout}ms`));
310
- }, timeout);
311
-
312
- client.exec(remoteCommand, { pty: connection.pty ?? true }, (err, stream) => {
313
- if (err) {
314
- settle(reject, err);
315
- return;
316
- }
317
- commandStream = stream;
318
-
319
- stream.on("data", (chunk) => {
320
- stdout += chunk.toString();
321
- });
322
- stream.stderr.on("data", (chunk) => {
323
- stderr += chunk.toString();
324
- });
325
- stream.on("exit", (code, signal) => {
326
- exitCode = code;
327
- exitSignal = signal;
328
- });
329
- stream.on("close", (code, signal) => {
330
- if (settled) {
331
- return;
332
- }
333
- exitCode = exitCode ?? code;
334
- exitSignal = exitSignal ?? signal;
335
- if ((exitCode ?? 0) !== 0 || exitSignal) {
336
- const parts = [];
337
- if (stdout.trim()) {
338
- parts.push(stdout.trimEnd());
339
- }
340
- if (stderr.trim()) {
341
- parts.push(`[stderr]\n${stderr.trimEnd()}`);
342
- }
343
- if (exitCode !== undefined) {
344
- parts.push(`[exit code] ${exitCode}`);
345
- }
346
- if (exitSignal) {
347
- parts.push(`[signal] ${exitSignal}`);
348
- }
349
- settle(reject, new Error(parts.join("\n") || "命令执行失败"));
350
- return;
351
- }
352
- settle(resolve, stdout.trimEnd());
353
- });
354
- stream.on("error", (streamError) => {
355
- settle(reject, streamError);
356
- });
357
- });
358
- });
359
- }
360
-
361
- export async function uploadFile(connection, localPath, remotePath) {
362
- return withConnection(connection, (client) => uploadFileWithClient(client, localPath, remotePath));
363
- }
364
-
365
- export async function uploadFileWithClient(client, localPath, remotePath) {
366
- const sftp = await new Promise((resolve, reject) => {
367
- client.sftp((err, sftp) => {
368
- if (err) {
369
- reject(err);
370
- return;
371
- }
372
- resolve(sftp);
373
- });
374
- });
375
- try {
376
- await pipeline(fs.createReadStream(localPath), sftp.createWriteStream(remotePath));
377
- } finally {
378
- sftp.end();
379
- }
380
- }
381
-
382
- export async function downloadFile(connection, remotePath, localPath) {
383
- return withConnection(connection, (client) => downloadFileWithClient(client, remotePath, localPath));
384
- }
385
-
386
- export async function downloadFileWithClient(client, remotePath, localPath) {
387
- const sftp = await new Promise((resolve, reject) => {
388
- client.sftp((err, sftp) => {
389
- if (err) {
390
- reject(err);
391
- return;
392
- }
393
- resolve(sftp);
394
- });
395
- });
396
- try {
397
- await pipeline(sftp.createReadStream(remotePath), fs.createWriteStream(localPath));
398
- } finally {
399
- sftp.end();
400
- }
401
- }
package/src/ssh-daemon.js DELETED
@@ -1,261 +0,0 @@
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
- let boundConfigPath;
23
- let daemonToken;
24
-
25
- function parseArgs(argv) {
26
- const socketIndex = argv.indexOf("--socket");
27
- const configIndex = argv.indexOf("--config");
28
- const tokenFileIndex = argv.indexOf("--token-file");
29
- const parsedSocketPath = socketIndex === -1 ? undefined : argv[socketIndex + 1];
30
- const configPath = configIndex === -1 ? undefined : argv[configIndex + 1];
31
- const tokenFilePath = tokenFileIndex === -1 ? undefined : argv[tokenFileIndex + 1];
32
- if (!parsedSocketPath) {
33
- throw new Error("daemon 缺少 --socket 参数");
34
- }
35
- if (!configPath) {
36
- throw new Error("daemon 缺少 --config 参数");
37
- }
38
- if (!tokenFilePath) {
39
- throw new Error("daemon 缺少 --token-file 参数");
40
- }
41
- return {
42
- socketPath: parsedSocketPath,
43
- configPath: path.resolve(configPath),
44
- tokenFilePath: path.resolve(tokenFilePath)
45
- };
46
- }
47
-
48
- function writeResponse(socket, response) {
49
- socket.write(`${JSON.stringify(response)}\n`, () => socket.end());
50
- }
51
-
52
- function buildConnectionKey(configPath, connection) {
53
- const auth = connection.agent
54
- ? { type: "agent", value: connection.agent }
55
- : connection.privateKey
56
- ? { type: "privateKey", value: connection.privateKey, passphrase: connection.passphrase }
57
- : { type: "password", value: connection.password };
58
- const raw = JSON.stringify({
59
- configPath: path.resolve(configPath),
60
- name: connection.name,
61
- host: connection.host,
62
- port: connection.port,
63
- username: connection.username,
64
- socksProxy: connection.socksProxy,
65
- auth
66
- });
67
- return crypto.createHash("sha256").update(raw).digest("hex");
68
- }
69
-
70
- function removeEntry(key) {
71
- const entry = connections.get(key);
72
- if (!entry) {
73
- return;
74
- }
75
- connections.delete(key);
76
- if (entry.client) {
77
- entry.client.end();
78
- }
79
- }
80
-
81
- function scheduleExitCheck() {
82
- clearTimeout(exitTimer);
83
- if (activeRequests > 0) {
84
- return;
85
- }
86
- const now = Date.now();
87
- let waitMs = DEFAULT_CACHE_TTL_MS;
88
- if (connections.size === 0) {
89
- waitMs = Math.max(100, DEFAULT_CACHE_TTL_MS - (now - lastActivityAt));
90
- }
91
- for (const entry of connections.values()) {
92
- waitMs = Math.min(waitMs, Math.max(100, entry.ttlMs - (now - entry.lastUsedAt)));
93
- }
94
- exitTimer = setTimeout(() => {
95
- if (activeRequests > 0) {
96
- scheduleExitCheck();
97
- return;
98
- }
99
- const checkedAt = Date.now();
100
- for (const [key, entry] of connections.entries()) {
101
- if (checkedAt - entry.lastUsedAt >= entry.ttlMs) {
102
- removeEntry(key);
103
- }
104
- }
105
- if (connections.size === 0 && checkedAt - lastActivityAt >= DEFAULT_CACHE_TTL_MS) {
106
- shutdown();
107
- return;
108
- }
109
- if (connections.size === 0) {
110
- shutdown();
111
- return;
112
- }
113
- scheduleExitCheck();
114
- }, waitMs);
115
- }
116
-
117
- async function getPoolEntry(configPath, connection, ttlMs) {
118
- const key = buildConnectionKey(configPath, connection);
119
- let entry = connections.get(key);
120
- if (!entry) {
121
- entry = {
122
- key,
123
- client: undefined,
124
- clientPromise: undefined,
125
- queue: Promise.resolve(),
126
- lastUsedAt: Date.now(),
127
- ttlMs
128
- };
129
- entry.clientPromise = connectSshClient(connection)
130
- .then((client) => {
131
- entry.client = client;
132
- client.on("error", () => removeEntry(key));
133
- client.on("close", () => removeEntry(key));
134
- return client;
135
- })
136
- .catch((error) => {
137
- connections.delete(key);
138
- throw error;
139
- });
140
- connections.set(key, entry);
141
- }
142
- entry.ttlMs = ttlMs;
143
- entry.lastUsedAt = Date.now();
144
- await entry.clientPromise;
145
- return entry;
146
- }
147
-
148
- async function runSerialized(entry, operation) {
149
- const run = async () => {
150
- const client = await entry.clientPromise;
151
- return operation(client);
152
- };
153
- const resultPromise = entry.queue.then(run, run);
154
- entry.queue = resultPromise.catch(() => undefined);
155
- return resultPromise;
156
- }
157
-
158
- async function executeRequest(request) {
159
- if (request.token !== daemonToken) {
160
- throw new Error("SSH 缓存进程认证失败");
161
- }
162
- const requestConfigPath = path.resolve(request.configPath);
163
- if (requestConfigPath !== boundConfigPath) {
164
- throw new Error("SSH 缓存进程拒绝访问非绑定配置文件");
165
- }
166
- const ttlMs = normalizeCacheTtl(request.cacheTtlMs);
167
- const configs = loadConfig(boundConfigPath);
168
- const connection = findConnection(configs, request.connectionName);
169
- if (request.operation === "execute") {
170
- validateCommand(connection, request.command);
171
- }
172
- const entry = await getPoolEntry(boundConfigPath, connection, ttlMs);
173
-
174
- try {
175
- if (request.operation === "execute") {
176
- const remoteCommand = request.directory ? `cd -- ${JSON.stringify(request.directory)} && ${request.command}` : request.command;
177
- const stdout = await runSerialized(entry, (client) => {
178
- return executeRemoteCommandWithClient(client, connection, remoteCommand, request.timeout);
179
- });
180
- return { stdout };
181
- }
182
- if (request.operation === "upload") {
183
- const localPath = validateLocalPath(configs, request.localPath, request.cwd);
184
- await runSerialized(entry, (client) => uploadFileWithClient(client, localPath, request.remotePath));
185
- return {};
186
- }
187
- if (request.operation === "download") {
188
- const localPath = validateLocalPath(configs, request.localPath, request.cwd);
189
- fs.mkdirSync(path.dirname(localPath), { recursive: true });
190
- await runSerialized(entry, (client) => downloadFileWithClient(client, request.remotePath, localPath));
191
- return {};
192
- }
193
- throw new Error(`不支持的 daemon 操作: ${request.operation}`);
194
- } finally {
195
- entry.lastUsedAt = Date.now();
196
- lastActivityAt = entry.lastUsedAt;
197
- }
198
- }
199
-
200
- function handleSocket(socket) {
201
- socket.setEncoding("utf8");
202
- let buffer = "";
203
- socket.on("data", (chunk) => {
204
- buffer += chunk;
205
- const lineEnd = buffer.indexOf("\n");
206
- if (lineEnd === -1) {
207
- return;
208
- }
209
- const line = buffer.slice(0, lineEnd);
210
- activeRequests += 1;
211
- Promise.resolve()
212
- .then(() => JSON.parse(line))
213
- .then((request) => executeRequest(request))
214
- .then((result) => writeResponse(socket, { ok: true, ...result }))
215
- .catch((error) => writeResponse(socket, { ok: false, message: error.message }))
216
- .finally(() => {
217
- activeRequests -= 1;
218
- lastActivityAt = Date.now();
219
- scheduleExitCheck();
220
- });
221
- });
222
- }
223
-
224
- function shutdown() {
225
- clearTimeout(exitTimer);
226
- for (const key of Array.from(connections.keys())) {
227
- removeEntry(key);
228
- }
229
- if (server) {
230
- server.close(() => {
231
- try {
232
- unlinkSocketPath(socketPath);
233
- } catch (error) {
234
- if (error.code !== "ENOENT") {
235
- process.stderr.write(`${error.message}\n`);
236
- }
237
- }
238
- process.exit(0);
239
- });
240
- return;
241
- }
242
- process.exit(0);
243
- }
244
-
245
- try {
246
- const parsedArgs = parseArgs(process.argv.slice(2));
247
- socketPath = parsedArgs.socketPath;
248
- boundConfigPath = parsedArgs.configPath;
249
- daemonToken = fs.readFileSync(parsedArgs.tokenFilePath, "utf8").trim();
250
- unlinkSocketPath(socketPath);
251
- server = net.createServer(handleSocket);
252
- server.listen(socketPath, () => {
253
- chmodSocketPath(socketPath);
254
- scheduleExitCheck();
255
- });
256
- process.on("SIGTERM", shutdown);
257
- process.on("SIGINT", shutdown);
258
- } catch (error) {
259
- process.stderr.write(`${error.message}\n`);
260
- process.exit(1);
261
- }