agent-ssh-cli 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -11,7 +11,8 @@
11
11
  <a href="https://github.com/sleepinginsummer/agent-ssh-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License MIT"></a>
12
12
  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-%3E%3D18-339933?logo=node.js&logoColor=white" alt="Node.js >=18"></a>
13
13
  <a href="https://www.npmjs.com/"><img src="https://img.shields.io/badge/npm-%3E%3D8-CB3837?logo=npm&logoColor=white" alt="npm >=8"></a>
14
- <a href="https://github.com/sleepinginsummer/agent-ssh-cli/releases"><img src="https://img.shields.io/badge/release-v0.1.0-blue" alt="release v0.1.0"></a>
14
+ <a href="https://github.com/sleepinginsummer/agent-ssh-cli"><img src="https://img.shields.io/badge/Windows-MacOS-0078D6?labelColor=0078D6&color=C0C0C0" alt="Windows/MacOS"></a>
15
+ <a href="https://github.com/sleepinginsummer/agent-ssh-cli/releases"><img src="https://img.shields.io/badge/release-v0.1.2-blue" alt="release v0.1.2"></a>
15
16
  <a href="https://github.com/sleepinginsummer/agent-ssh-cli/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen" alt="PRs welcome"></a>
16
17
  </p>
17
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-ssh-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "基于 CLI 的 SSH 代理工具,按 ssh-mcp-server 的能力一一映射",
5
5
  "type": "module",
6
6
  "engines": {
package/src/cli.js CHANGED
@@ -242,10 +242,20 @@ function parseExecuteArgs(argv) {
242
242
  return parsed;
243
243
  }
244
244
  const args = [...parsed.args];
245
- const connectionName = resolveValue(args, ["--connection", "-c"], "connectionName");
246
- const command = resolveValue(args, ["--command"], "command");
247
- const directory = resolveValue(args, ["--directory", "-d"], "directory");
248
- const timeoutValue = resolveValue(args, ["--timeout", "-t"], "timeout");
245
+ const connectionNameOption = takeOption(args, ["--connection", "-c"]);
246
+ const commandOption = takeOption(args, ["--command"]);
247
+ const directoryOption = takeOption(args, ["--directory", "-d"]);
248
+ const timeoutOption = takeOption(args, ["--timeout", "-t"]);
249
+ const connectionNamePositional = takePositional(args, "connectionName");
250
+ const commandPositional = takePositional(args, "command");
251
+
252
+ ensureNoMixedInput(connectionNameOption.present ? connectionNameOption.value : undefined, connectionNamePositional, "connectionName");
253
+ ensureNoMixedInput(commandOption.present ? commandOption.value : undefined, commandPositional, "command");
254
+
255
+ const connectionName = connectionNameOption.present ? connectionNameOption.value : connectionNamePositional;
256
+ const command = commandOption.present ? commandOption.value : commandPositional;
257
+ const directory = directoryOption.present ? directoryOption.value : undefined;
258
+ const timeoutValue = timeoutOption.present ? timeoutOption.value : undefined;
249
259
  for (let index = 0; index < args.length; index += 1) {
250
260
  if (args[index]?.startsWith("--")) {
251
261
  throw new Error(`不支持的参数: ${args[index]}`);
package/src/config.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  const DEFAULT_CONFIG_DIR = ".agent-ssh-cli";
8
8
  const DEFAULT_CONFIG_FILE = "config.json";
9
+ const configCache = new Map();
9
10
 
10
11
  function isNonEmptyString(value) {
11
12
  return typeof value === "string" && value.trim() !== "";
@@ -23,7 +24,10 @@ function ensureRegexArray(patterns, fieldName, index) {
23
24
  throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须只包含非空字符串`);
24
25
  }
25
26
  try {
26
- return new RegExp(pattern), pattern;
27
+ return {
28
+ pattern,
29
+ regex: new RegExp(pattern)
30
+ };
27
31
  } catch (error) {
28
32
  throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 含有非法正则: ${pattern},${error.message}`);
29
33
  }
@@ -110,7 +114,13 @@ export function getDefaultConfigPath() {
110
114
  }
111
115
 
112
116
  export function loadConfig(configPath = getDefaultConfigPath()) {
113
- const raw = fs.readFileSync(configPath, "utf8");
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");
114
124
  let parsed;
115
125
  try {
116
126
  parsed = JSON.parse(raw);
@@ -131,6 +141,11 @@ export function loadConfig(configPath = getDefaultConfigPath()) {
131
141
  }
132
142
  seenNames.add(config.name);
133
143
  }
144
+ configCache.set(resolvedConfigPath, {
145
+ mtimeMs: stat.mtimeMs,
146
+ size: stat.size,
147
+ configs
148
+ });
134
149
  return configs;
135
150
  }
136
151
 
@@ -1,8 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
2
4
  import net from "node:net";
3
5
  import path from "node:path";
4
6
  import { fileURLToPath } from "node:url";
5
- import { getSocketPath, unlinkSocketPath } from "./daemon-paths.js";
7
+ import { getSocketPath, getTokenPath, unlinkSocketPath } from "./daemon-paths.js";
6
8
 
7
9
  const DAEMON_START_TIMEOUT_MS = 3000;
8
10
  const DAEMON_REQUEST_TIMEOUT_MS = 86400000;
@@ -39,8 +41,19 @@ function connectSocket(socketPath, timeoutMs = DAEMON_REQUEST_TIMEOUT_MS) {
39
41
  });
40
42
  }
41
43
 
42
- function spawnDaemon(socketPath) {
43
- const child = spawn(process.execPath, [getDaemonEntryPath(), "--socket", socketPath], {
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], {
44
57
  detached: true,
45
58
  stdio: "ignore"
46
59
  });
@@ -63,11 +76,15 @@ async function waitForDaemon(socketPath) {
63
76
  throw new Error(`启动 SSH 缓存进程失败: ${lastError?.message || "未知错误"}`);
64
77
  }
65
78
 
66
- async function ensureDaemon(socketPath) {
79
+ async function ensureDaemon(socketPath, configPath, tokenPath) {
67
80
  try {
68
81
  const socket = await connectSocket(socketPath, 500);
69
82
  socket.end();
70
- return;
83
+ try {
84
+ return readToken(tokenPath);
85
+ } catch (error) {
86
+ unlinkSocketPath(socketPath);
87
+ }
71
88
  } catch (error) {
72
89
  if (!isMissingSocketError(error)) {
73
90
  throw error;
@@ -76,13 +93,16 @@ async function ensureDaemon(socketPath) {
76
93
  unlinkSocketPath(socketPath);
77
94
  }
78
95
  }
79
- spawnDaemon(socketPath);
96
+ const token = createToken(tokenPath);
97
+ spawnDaemon(socketPath, configPath, tokenPath);
80
98
  await waitForDaemon(socketPath);
99
+ return token;
81
100
  }
82
101
 
83
102
  export async function requestDaemon(configPath, request) {
84
103
  const socketPath = getSocketPath(configPath);
85
- await ensureDaemon(socketPath);
104
+ const tokenPath = getTokenPath(configPath);
105
+ const token = await ensureDaemon(socketPath, configPath, tokenPath);
86
106
  const socket = await connectSocket(socketPath);
87
107
  socket.setEncoding("utf8");
88
108
 
@@ -128,6 +148,6 @@ export async function requestDaemon(configPath, request) {
128
148
  settle(reject, new Error("SSH 缓存进程提前关闭连接"));
129
149
  }
130
150
  });
131
- socket.write(`${JSON.stringify(request)}\n`);
151
+ socket.write(`${JSON.stringify({ ...request, token })}\n`);
132
152
  });
133
153
  }
@@ -29,6 +29,11 @@ export function getSocketPath(configPath) {
29
29
  return path.join(getDaemonDir(), `${digest}.sock`);
30
30
  }
31
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
+
32
37
  export function unlinkSocketPath(socketPath) {
33
38
  if (isWindowsPlatform()) {
34
39
  return;
package/src/ssh-client.js CHANGED
@@ -1,12 +1,39 @@
1
1
  import fs from "node:fs";
2
+ import net from "node:net";
3
+ import { pipeline } from "node:stream/promises";
2
4
  import { Client } from "ssh2";
3
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
+
4
31
  function compilePatterns(patterns, label) {
5
32
  return (patterns || []).map((pattern) => {
6
33
  try {
7
- return new RegExp(pattern);
34
+ return getCompiledPattern(pattern, label);
8
35
  } catch (error) {
9
- throw new Error(`${label} 正则非法: ${pattern},${error.message}`);
36
+ throw new Error(error.message);
10
37
  }
11
38
  });
12
39
  }
@@ -23,12 +50,201 @@ export function validateCommand(connection, command) {
23
50
  }
24
51
  }
25
52
 
26
- function createConnectConfig(connection) {
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) {
27
240
  const connectConfig = {
28
241
  host: connection.host,
29
242
  port: connection.port,
30
243
  username: connection.username
31
244
  };
245
+ if (connection.socksProxy) {
246
+ connectConfig.sock = await connectSocksProxy(connection);
247
+ }
32
248
  if (connection.agent) {
33
249
  connectConfig.agent = connection.agent;
34
250
  } else if (connection.privateKey) {
@@ -46,7 +262,7 @@ function createConnectConfig(connection) {
46
262
 
47
263
  export async function connectSshClient(connection) {
48
264
  const client = new Client();
49
- const connectConfig = createConnectConfig(connection);
265
+ const connectConfig = await createConnectConfig(connection);
50
266
  await new Promise((resolve, reject) => {
51
267
  client.once("ready", resolve);
52
268
  client.once("error", reject);
@@ -77,17 +293,28 @@ export async function executeRemoteCommandWithClient(client, connection, remoteC
77
293
  let exitCode;
78
294
  let exitSignal;
79
295
  let settled = false;
80
- const timer = setTimeout(() => {
296
+ let commandStream;
297
+ const settle = (callback, value) => {
298
+ if (settled) {
299
+ return;
300
+ }
81
301
  settled = true;
82
- reject(new Error(`命令执行超时,超过 ${timeout}ms`));
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`));
83
310
  }, timeout);
84
311
 
85
312
  client.exec(remoteCommand, { pty: connection.pty ?? true }, (err, stream) => {
86
313
  if (err) {
87
- clearTimeout(timer);
88
- reject(err);
314
+ settle(reject, err);
89
315
  return;
90
316
  }
317
+ commandStream = stream;
91
318
 
92
319
  stream.on("data", (chunk) => {
93
320
  stdout += chunk.toString();
@@ -100,11 +327,9 @@ export async function executeRemoteCommandWithClient(client, connection, remoteC
100
327
  exitSignal = signal;
101
328
  });
102
329
  stream.on("close", (code, signal) => {
103
- clearTimeout(timer);
104
330
  if (settled) {
105
331
  return;
106
332
  }
107
- settled = true;
108
333
  exitCode = exitCode ?? code;
109
334
  exitSignal = exitSignal ?? signal;
110
335
  if ((exitCode ?? 0) !== 0 || exitSignal) {
@@ -121,18 +346,13 @@ export async function executeRemoteCommandWithClient(client, connection, remoteC
121
346
  if (exitSignal) {
122
347
  parts.push(`[signal] ${exitSignal}`);
123
348
  }
124
- reject(new Error(parts.join("\n") || "命令执行失败"));
349
+ settle(reject, new Error(parts.join("\n") || "命令执行失败"));
125
350
  return;
126
351
  }
127
- resolve(stdout.trimEnd());
352
+ settle(resolve, stdout.trimEnd());
128
353
  });
129
354
  stream.on("error", (streamError) => {
130
- clearTimeout(timer);
131
- if (settled) {
132
- return;
133
- }
134
- settled = true;
135
- reject(streamError);
355
+ settle(reject, streamError);
136
356
  });
137
357
  });
138
358
  });
@@ -143,37 +363,20 @@ export async function uploadFile(connection, localPath, remotePath) {
143
363
  }
144
364
 
145
365
  export async function uploadFileWithClient(client, localPath, remotePath) {
146
- return new Promise((resolve, reject) => {
366
+ const sftp = await new Promise((resolve, reject) => {
147
367
  client.sftp((err, sftp) => {
148
368
  if (err) {
149
369
  reject(err);
150
370
  return;
151
371
  }
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);
372
+ resolve(sftp);
175
373
  });
176
374
  });
375
+ try {
376
+ await pipeline(fs.createReadStream(localPath), sftp.createWriteStream(remotePath));
377
+ } finally {
378
+ sftp.end();
379
+ }
177
380
  }
178
381
 
179
382
  export async function downloadFile(connection, remotePath, localPath) {
@@ -181,35 +384,18 @@ export async function downloadFile(connection, remotePath, localPath) {
181
384
  }
182
385
 
183
386
  export async function downloadFileWithClient(client, remotePath, localPath) {
184
- return new Promise((resolve, reject) => {
387
+ const sftp = await new Promise((resolve, reject) => {
185
388
  client.sftp((err, sftp) => {
186
389
  if (err) {
187
390
  reject(err);
188
391
  return;
189
392
  }
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);
393
+ resolve(sftp);
213
394
  });
214
395
  });
396
+ try {
397
+ await pipeline(sftp.createReadStream(remotePath), fs.createWriteStream(localPath));
398
+ } finally {
399
+ sftp.end();
400
+ }
215
401
  }
package/src/ssh-daemon.js CHANGED
@@ -19,15 +19,29 @@ let lastActivityAt = Date.now();
19
19
  let exitTimer;
20
20
  let server;
21
21
  let socketPath;
22
+ let boundConfigPath;
23
+ let daemonToken;
22
24
 
23
25
  function parseArgs(argv) {
24
26
  const socketIndex = argv.indexOf("--socket");
25
- const value = socketIndex === -1 ? undefined : argv[socketIndex + 1];
26
- if (!value) {
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) {
27
33
  throw new Error("daemon 缺少 --socket 参数");
28
34
  }
35
+ if (!configPath) {
36
+ throw new Error("daemon 缺少 --config 参数");
37
+ }
38
+ if (!tokenFilePath) {
39
+ throw new Error("daemon 缺少 --token-file 参数");
40
+ }
29
41
  return {
30
- socketPath: value
42
+ socketPath: parsedSocketPath,
43
+ configPath: path.resolve(configPath),
44
+ tokenFilePath: path.resolve(tokenFilePath)
31
45
  };
32
46
  }
33
47
 
@@ -47,6 +61,7 @@ function buildConnectionKey(configPath, connection) {
47
61
  host: connection.host,
48
62
  port: connection.port,
49
63
  username: connection.username,
64
+ socksProxy: connection.socksProxy,
50
65
  auth
51
66
  });
52
67
  return crypto.createHash("sha256").update(raw).digest("hex");
@@ -141,13 +156,20 @@ async function runSerialized(entry, operation) {
141
156
  }
142
157
 
143
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
+ }
144
166
  const ttlMs = normalizeCacheTtl(request.cacheTtlMs);
145
- const configs = loadConfig(request.configPath);
167
+ const configs = loadConfig(boundConfigPath);
146
168
  const connection = findConnection(configs, request.connectionName);
147
169
  if (request.operation === "execute") {
148
170
  validateCommand(connection, request.command);
149
171
  }
150
- const entry = await getPoolEntry(request.configPath, connection, ttlMs);
172
+ const entry = await getPoolEntry(boundConfigPath, connection, ttlMs);
151
173
 
152
174
  try {
153
175
  if (request.operation === "execute") {
@@ -221,7 +243,10 @@ function shutdown() {
221
243
  }
222
244
 
223
245
  try {
224
- ({ socketPath } = parseArgs(process.argv.slice(2)));
246
+ const parsedArgs = parseArgs(process.argv.slice(2));
247
+ socketPath = parsedArgs.socketPath;
248
+ boundConfigPath = parsedArgs.configPath;
249
+ daemonToken = fs.readFileSync(parsedArgs.tokenFilePath, "utf8").trim();
225
250
  unlinkSocketPath(socketPath);
226
251
  server = net.createServer(handleSocket);
227
252
  server.listen(socketPath, () => {