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 +2 -1
- package/package.json +1 -1
- package/src/cli.js +14 -4
- package/src/config.js +17 -2
- package/src/daemon-client.js +28 -8
- package/src/daemon-paths.js +5 -0
- package/src/ssh-client.js +252 -66
- package/src/ssh-daemon.js +31 -6
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
|
|
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
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
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
const
|
|
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
|
|
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
|
|
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
|
|
package/src/daemon-client.js
CHANGED
|
@@ -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
|
|
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], {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/daemon-paths.js
CHANGED
|
@@ -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
|
|
34
|
+
return getCompiledPattern(pattern, label);
|
|
8
35
|
} catch (error) {
|
|
9
|
-
throw new Error(
|
|
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
|
|
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
|
-
|
|
296
|
+
let commandStream;
|
|
297
|
+
const settle = (callback, value) => {
|
|
298
|
+
if (settled) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
81
301
|
settled = true;
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
349
|
+
settle(reject, new Error(parts.join("\n") || "命令执行失败"));
|
|
125
350
|
return;
|
|
126
351
|
}
|
|
127
|
-
resolve
|
|
352
|
+
settle(resolve, stdout.trimEnd());
|
|
128
353
|
});
|
|
129
354
|
stream.on("error", (streamError) => {
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
26
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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, () => {
|