@hunan36/dsh-ssh-helper 0.0.0-stage → 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.
package/host/shell.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * POSIX shell quoting, remote path validation and backup layout helpers.
3
+ *
4
+ * Every remote command built by the plugin must pass user-controlled values
5
+ * through {@link shQuote}; every remote directory must pass
6
+ * {@link validateRemotePath}. The module is intentionally free of I/O so it can
7
+ * be unit-tested and reused by the discovery, resource and update services.
8
+ *
9
+ * @module host/shell
10
+ */
11
+
12
+ import path from 'node:path'
13
+
14
+ /** Maximum accepted remote path length. */
15
+ const MAX_PATH_LENGTH = 1024
16
+ /** Maximum accepted sanitized path segment length. */
17
+ const MAX_SEGMENT_LENGTH = 64
18
+
19
+ /** System roots that may never be used as an app or backup directory. */
20
+ const BLOCKED_EXACT = new Set([
21
+ '/',
22
+ '/bin',
23
+ '/boot',
24
+ '/dev',
25
+ '/etc',
26
+ '/lib',
27
+ '/lib64',
28
+ '/proc',
29
+ '/run',
30
+ '/sbin',
31
+ '/sys',
32
+ '/usr',
33
+ '/var',
34
+ '/var/lib',
35
+ '/var/log',
36
+ '/var/run',
37
+ '/home',
38
+ ])
39
+
40
+ /** System roots whose whole subtree is off limits. */
41
+ const BLOCKED_PREFIXES = [
42
+ '/bin/',
43
+ '/boot/',
44
+ '/dev/',
45
+ '/etc/',
46
+ '/lib/',
47
+ '/lib64/',
48
+ '/proc/',
49
+ '/run/',
50
+ '/sbin/',
51
+ '/sys/',
52
+ '/usr/',
53
+ ]
54
+
55
+ /**
56
+ * Build one error carrying the stable `invalid-path` code.
57
+ * @param {string} message - Chinese human-readable message.
58
+ * @returns {Error & { code: string }} tagged error.
59
+ */
60
+ function invalidPath(message) {
61
+ const error = new Error(message)
62
+ error.code = 'invalid-path'
63
+ return error
64
+ }
65
+
66
+ /**
67
+ * Quote one value for a POSIX shell as a single-quoted literal.
68
+ * @param {unknown} value - value to quote (coerced with `String`).
69
+ * @returns {string} safely quoted shell word.
70
+ */
71
+ export function shQuote(value) {
72
+ const text = typeof value === 'string' ? value : String(value ?? '')
73
+ return `'${text.replace(/'/g, "'\\''")}'`
74
+ }
75
+
76
+ /**
77
+ * Validate and normalize one absolute POSIX path.
78
+ *
79
+ * Rejects non-strings, empty values, relative paths, `..` segments, control
80
+ * characters, over-long values and the blocked system roots listed in
81
+ * CONTRACT.md. Returns a normalized absolute path with redundant separators and
82
+ * trailing slashes removed.
83
+ *
84
+ * @param {unknown} raw - candidate remote path.
85
+ * @returns {string} normalized absolute path.
86
+ * @throws {Error & { code: 'invalid-path' }} when the path is not acceptable.
87
+ */
88
+ export function validateRemotePath(raw) {
89
+ if (typeof raw !== 'string') throw invalidPath('远程路径无效')
90
+ if (/[\u0000-\u001f\u007f]/.test(raw)) throw invalidPath('远程路径不能包含控制字符')
91
+ const text = raw.trim()
92
+ if (text === '') throw invalidPath('远程路径不能为空')
93
+ if (text.length > MAX_PATH_LENGTH) throw invalidPath(`远程路径不能超过 ${MAX_PATH_LENGTH} 个字符`)
94
+ if (!text.startsWith('/')) throw invalidPath('远程路径必须是绝对路径')
95
+ if (text.split('/').some((segment) => segment === '..')) throw invalidPath('远程路径不能包含 ".." 片段')
96
+
97
+ let normalized = path.posix.normalize(text)
98
+ if (normalized.length > 1 && normalized.endsWith('/')) normalized = normalized.replace(/\/+$/, '')
99
+ if (!normalized.startsWith('/')) throw invalidPath('远程路径必须是绝对路径')
100
+ if (normalized === '/' || BLOCKED_EXACT.has(normalized)) throw invalidPath(`不允许使用系统目录 ${normalized}`)
101
+ for (const prefix of BLOCKED_PREFIXES) {
102
+ if (normalized.startsWith(prefix)) throw invalidPath(`不允许使用系统目录 ${normalized}`)
103
+ }
104
+ return normalized
105
+ }
106
+
107
+ /**
108
+ * Collect human-readable warnings for one remote path. The path itself is
109
+ * assumed to be valid.
110
+ * @param {string} remotePath - normalized absolute path.
111
+ * @param {{ appDir?: string }} [options] - when `appDir` is provided, a path
112
+ * inside it is reported as a backup-inside-app warning.
113
+ * @returns {string[]} warnings (English, model-facing).
114
+ */
115
+ export function remotePathWarnings(remotePath, options = {}) {
116
+ const warnings = []
117
+ if (typeof remotePath !== 'string' || remotePath === '') return warnings
118
+ const normalized = path.posix.normalize(remotePath)
119
+ if (normalized === '/tmp' || normalized.startsWith('/tmp/')) {
120
+ warnings.push('Path is under /tmp, which may be cleared on reboot.')
121
+ }
122
+ if (normalized === '/root') {
123
+ warnings.push('Path is /root itself; the app and its backups would mix with the user home.')
124
+ }
125
+ const depth = normalized.split('/').filter(Boolean).length
126
+ if (depth < 2) warnings.push('Path is very shallow; a mistake could affect unrelated services.')
127
+ if (typeof options.appDir === 'string' && options.appDir !== '') {
128
+ const appDir = path.posix.normalize(options.appDir)
129
+ if (normalized === appDir || normalized.startsWith(`${appDir}/`)) {
130
+ warnings.push('Backup directory is inside the app directory; a backup could include itself.')
131
+ }
132
+ }
133
+ return warnings
134
+ }
135
+
136
+ /**
137
+ * Sanitize one value into a safe path segment matching `[A-Za-z0-9._-]`.
138
+ * @param {unknown} value - raw value.
139
+ * @returns {string} safe segment, never empty and at most 64 characters.
140
+ */
141
+ export function safeSegment(value) {
142
+ const text = typeof value === 'string' ? value.trim() : ''
143
+ let result = text.replace(/[^A-Za-z0-9._-]+/g, '-')
144
+ result = result.replace(/^[.-]+/, '').replace(/[.-]+$/, '')
145
+ if (result === '' || result === '.' || result === '..') result = 'item'
146
+ return result.slice(0, MAX_SEGMENT_LENGTH)
147
+ }
148
+
149
+ /**
150
+ * Format a compact UTC timestamp such as `20261106T131500Z`.
151
+ * @param {Date | number | string | undefined} value - timestamp source.
152
+ * @returns {string} compact UTC stamp.
153
+ */
154
+ function compactStamp(value) {
155
+ /** @type {Date | undefined} */
156
+ let date
157
+ if (value instanceof Date) date = value
158
+ else if (typeof value === 'number' && Number.isFinite(value)) date = new Date(value)
159
+ else if (typeof value === 'string') {
160
+ if (/^\d{8}T\d{6}Z$/.test(value)) return value
161
+ const parsed = new Date(value)
162
+ if (!Number.isNaN(parsed.getTime())) date = parsed
163
+ }
164
+ if (date === undefined || Number.isNaN(date.getTime())) date = new Date()
165
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
166
+ }
167
+
168
+ /**
169
+ * Compute the backup parent, directory and archive path for one resource.
170
+ *
171
+ * The archive path is `<dir>/<safeName>-<compactStamp>.tar.gz`; the default
172
+ * directory is `<appParent>/.ssh-helper-backups/<safeName>`.
173
+ *
174
+ * @param {{ appDir: string, backupDir?: string, name?: string, timestamp?: Date | number | string }} options - resource data.
175
+ * @returns {{ parent: string, baseName: string, dir: string, file: string }} layout.
176
+ * @throws {Error & { code: 'invalid-path' }} when a path is not acceptable.
177
+ */
178
+ export function backupLayout({ appDir, backupDir, name, timestamp } = {}) {
179
+ const app = validateRemotePath(appDir)
180
+ const parent = path.posix.dirname(app)
181
+ const baseName = path.posix.basename(app)
182
+ const segment = safeSegment(name === undefined || name === null || name === '' ? baseName : name)
183
+ const dir =
184
+ backupDir === undefined || backupDir === null || backupDir === ''
185
+ ? path.posix.join(parent, '.ssh-helper-backups', segment)
186
+ : validateRemotePath(backupDir)
187
+ const stamp = compactStamp(timestamp)
188
+ return {
189
+ parent,
190
+ baseName,
191
+ dir,
192
+ file: path.posix.join(dir, `${segment}-${stamp}.tar.gz`),
193
+ }
194
+ }
package/host/ssh.js ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * SSH execution through the pure-JavaScript `ssh2` client.
3
+ *
4
+ * `exec` runs one command and reports stdout/stderr/exit code with bounded
5
+ * capture; `probe` checks reachability and identity. Host keys are accepted as
6
+ * provided by the server (documented v1 policy). Every failure is mapped to one
7
+ * of the fixed codes `timeout`, `network`, `auth` or `unknown` with a fixed
8
+ * Chinese message that never contains a password, private key or passphrase.
9
+ *
10
+ * @module host/ssh
11
+ */
12
+
13
+ import ssh2 from 'ssh2'
14
+
15
+ const { Client } = ssh2
16
+
17
+ /** Stdout capture cap (512 KiB). */
18
+ const STDOUT_LIMIT = 512 * 1024
19
+ /** Stderr capture cap (256 KiB). */
20
+ const STDERR_LIMIT = 256 * 1024
21
+ /** Default command timeout (60 s). */
22
+ const DEFAULT_TIMEOUT_MS = 60_000
23
+ /** Default probe timeout (20 s). */
24
+ const DEFAULT_PROBE_TIMEOUT_MS = 20_000
25
+ /** Minimum accepted timeout (1 s). */
26
+ const MIN_TIMEOUT_MS = 1_000
27
+ /** Maximum accepted timeout (10 min). */
28
+ const MAX_TIMEOUT_MS = 600_000
29
+ /** Default connect timeout (15 s). */
30
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
31
+ /** Keepalive interval (10 s). */
32
+ const KEEPALIVE_INTERVAL_MS = 10_000
33
+ /** Default SFTP upload timeout (5 min). */
34
+ const DEFAULT_UPLOAD_TIMEOUT_MS = 300_000
35
+ /** Maximum accepted SFTP upload timeout (1 h). */
36
+ const MAX_UPLOAD_TIMEOUT_MS = 3_600_000
37
+ /** SFTP parallel chunk count used by `fastPut`. */
38
+ const UPLOAD_CONCURRENCY = 16
39
+ /** SFTP chunk size used by `fastPut` (32 KiB). */
40
+ const UPLOAD_CHUNK_SIZE = 32 * 1024
41
+ /** Probe command: kernel/arch plus the remote user name. */
42
+ const PROBE_COMMAND = "uname -srm; printf '\\n'; id -un"
43
+
44
+ /**
45
+ * Create one tagged error.
46
+ * @param {string} code - machine-readable error code.
47
+ * @param {string} message - fixed Chinese message (never contains secrets).
48
+ * @param {unknown} [cause] - underlying failure kept off the public message.
49
+ * @returns {Error & { code: string }} tagged error.
50
+ */
51
+ function sshError(code, message, cause) {
52
+ const error = new Error(message)
53
+ error.code = code
54
+ if (cause !== undefined) error.cause = cause
55
+ return error
56
+ }
57
+
58
+ /** @param {unknown} value @returns {boolean} whether the value is a plain object. */
59
+ function isObject(value) {
60
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
61
+ }
62
+
63
+ /**
64
+ * Clamp an optional numeric option.
65
+ * @param {unknown} value - caller value.
66
+ * @param {number} fallback - default.
67
+ * @param {number} min - lower bound.
68
+ * @param {number} max - upper bound.
69
+ * @returns {number} clamped integer.
70
+ */
71
+ function clampNumber(value, fallback, min, max) {
72
+ if (value === undefined || value === null || value === '') return fallback
73
+ const numeric = Number(value)
74
+ if (!Number.isFinite(numeric)) return fallback
75
+ return Math.min(max, Math.max(min, Math.round(numeric)))
76
+ }
77
+
78
+ /**
79
+ * Validate the connection fields of an account record.
80
+ * @param {unknown} record - full record (may contain credentials).
81
+ * @returns {{ host: string, port: number, username: string, authType: 'password' | 'key' }} normalized fields.
82
+ */
83
+ function normalizeRecord(record) {
84
+ if (!isObject(record)) throw sshError('invalid-input', '连接配置不完整')
85
+ const host = typeof record.host === 'string' ? record.host.trim() : ''
86
+ if (host === '') throw sshError('invalid-input', '主机地址不能为空')
87
+ const username = typeof record.username === 'string' ? record.username.trim() : ''
88
+ if (username === '') throw sshError('invalid-input', '用户名不能为空')
89
+ const port = record.port === undefined || record.port === null ? 22 : Number(record.port)
90
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw sshError('invalid-input', '端口必须是 1-65535 之间的整数')
91
+ if (record.authType === 'password') {
92
+ if (typeof record.password !== 'string' || record.password === '') throw sshError('invalid-input', '缺少密码凭据')
93
+ return { host, port, username, authType: 'password' }
94
+ }
95
+ if (record.authType === 'key') {
96
+ if (typeof record.privateKey !== 'string' || record.privateKey === '') throw sshError('invalid-input', '缺少私钥凭据')
97
+ return { host, port, username, authType: 'key' }
98
+ }
99
+ throw sshError('invalid-input', '认证方式无效')
100
+ }
101
+
102
+ /**
103
+ * Translate an SFTP failure into a friendly tagged error.
104
+ *
105
+ * SFTP errors carry numeric `code` values from the protocol, so they are mapped
106
+ * separately from connection errors. Messages never contain credentials.
107
+ *
108
+ * @param {unknown} error - thrown value from ssh2.
109
+ * @returns {Error & { code: string }} fixed-message error.
110
+ */
111
+ function mapSftpError(error) {
112
+ const raw = error && typeof error.message === 'string' ? error.message : ''
113
+ const code = error && typeof error.code === 'number' ? error.code : undefined
114
+ if (code === 2 || /no such file/i.test(raw)) {
115
+ return sshError('upload', '远端路径不存在或无法创建目标文件', error)
116
+ }
117
+ if (code === 3 || /permission denied/i.test(raw)) {
118
+ return sshError('upload', '远端拒绝写入:当前 SSH 用户没有目标目录的写权限', error)
119
+ }
120
+ if (code === 4 || /no space left/i.test(raw)) {
121
+ return sshError('upload', '远端磁盘空间不足', error)
122
+ }
123
+ if (code === 8 || /not support/i.test(raw)) {
124
+ return sshError('upload', '远端 SFTP 不支持该操作', error)
125
+ }
126
+ if (/(local|ENOENT)/i.test(raw)) {
127
+ return sshError('upload', '本地文件不存在或无法读取', error)
128
+ }
129
+ return sshError('upload', 'SFTP 上传失败,请检查本地文件与远端目录', error)
130
+ }
131
+
132
+ /**
133
+ * Translate an ssh2/Node connection failure into a friendly tagged error.
134
+ * @param {unknown} error - thrown value from ssh2.
135
+ * @returns {Error & { code: string }} fixed-message error.
136
+ */
137
+ function mapSshError(error) {
138
+ const raw = error && typeof error.message === 'string' ? error.message : ''
139
+ const code = error && typeof error.code === 'string' ? error.code : ''
140
+ const level = error && typeof error.level === 'string' ? error.level : ''
141
+ if (
142
+ level === 'client-authentication' ||
143
+ /all configured authentication methods failed|permission denied|authentication failed/i.test(raw)
144
+ ) {
145
+ return sshError('auth', '认证失败,请检查用户名和凭据', error)
146
+ }
147
+ if (code === 'ECONNREFUSED') return sshError('network', '连接被拒绝:目标主机的端口没有在监听', error)
148
+ if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return sshError('network', '无法解析主机名,请检查服务器地址', error)
149
+ if (code === 'ENETUNREACH' || code === 'EHOSTUNREACH') return sshError('network', '网络不可达,请检查网络连接', error)
150
+ if (code === 'ECONNRESET' || /reset|socket hang up/i.test(raw)) return sshError('network', '连接被服务器重置', error)
151
+ if (code === 'ETIMEDOUT' || /timed? ?out|timeout/i.test(raw)) return sshError('timeout', '连接超时,请检查服务器地址和网络', error)
152
+ return sshError('unknown', 'SSH 连接失败,请检查主机、端口和认证信息', error)
153
+ }
154
+
155
+ /**
156
+ * Build the ssh2 connection configuration. Credentials never leave this
157
+ * function; host keys are accepted as-is for v1.
158
+ * @param {object} record - full record.
159
+ * @param {{ host: string, port: number, username: string, authType: 'password' | 'key' }} fields - normalized fields.
160
+ * @param {number} connectTimeoutMs - ssh2 ready timeout.
161
+ * @returns {object} ssh2 connect configuration.
162
+ */
163
+ function connectionConfig(record, fields, connectTimeoutMs) {
164
+ const config = {
165
+ host: fields.host,
166
+ port: fields.port,
167
+ username: fields.username,
168
+ readyTimeout: connectTimeoutMs,
169
+ keepaliveInterval: KEEPALIVE_INTERVAL_MS,
170
+ hostVerifier: () => true,
171
+ }
172
+ if (fields.authType === 'key') {
173
+ config.privateKey = record.privateKey
174
+ if (typeof record.passphrase === 'string' && record.passphrase !== '') config.passphrase = record.passphrase
175
+ } else {
176
+ config.password = record.password
177
+ }
178
+ return config
179
+ }
180
+
181
+ /**
182
+ * Bounded byte capture for one output stream.
183
+ * @param {number} limit - maximum captured bytes.
184
+ * @returns {{ push: (chunk: unknown) => void, text: () => string, isTruncated: () => boolean }} capture handle.
185
+ */
186
+ function createCapture(limit) {
187
+ const chunks = []
188
+ let bytes = 0
189
+ let truncated = false
190
+ return {
191
+ push(chunk) {
192
+ if (truncated) return
193
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
194
+ if (buffer.length === 0) return
195
+ const remaining = limit - bytes
196
+ if (remaining <= 0) {
197
+ truncated = true
198
+ return
199
+ }
200
+ const slice = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer
201
+ chunks.push(slice)
202
+ bytes += slice.length
203
+ if (slice.length < buffer.length) truncated = true
204
+ },
205
+ text() {
206
+ return Buffer.concat(chunks).toString('utf8')
207
+ },
208
+ isTruncated() {
209
+ return truncated
210
+ },
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Create the SSH client used by the API, tools, discovery and update services.
216
+ * @returns {{
217
+ * exec: (record: object, command: string, options?: { timeoutMs?: number, connectTimeoutMs?: number }) => Promise<object>,
218
+ * probe: (record: object, options?: { timeoutMs?: number, connectTimeoutMs?: number }) => Promise<object>,
219
+ * upload: (record: object, options: { localPath: string, remotePath: string, timeoutMs?: number, connectTimeoutMs?: number, onProgress?: Function }) => Promise<object>,
220
+ * }} client operations.
221
+ */
222
+ export function createSshClient() {
223
+ /**
224
+ * Run one shell command over SSH.
225
+ * @param {object} record - full record including credentials.
226
+ * @param {string} command - shell command to run remotely.
227
+ * @param {{ timeoutMs?: number, connectTimeoutMs?: number }} [options] - timeout overrides.
228
+ * @returns {Promise<{ exitCode: number | null, signal: string | null, stdout: string, stderr: string, durationMs: number, truncated: boolean }>} execution result.
229
+ */
230
+ async function exec(record, command, options = {}) {
231
+ const fields = normalizeRecord(record)
232
+ if (typeof command !== 'string' || command.trim() === '') throw sshError('invalid-input', '命令不能为空')
233
+
234
+ const timeoutMs = clampNumber(options.timeoutMs, DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
235
+ const connectTimeoutMs = clampNumber(
236
+ options.connectTimeoutMs,
237
+ Math.min(DEFAULT_CONNECT_TIMEOUT_MS, timeoutMs),
238
+ MIN_TIMEOUT_MS,
239
+ MAX_TIMEOUT_MS,
240
+ )
241
+ const config = connectionConfig(record, fields, connectTimeoutMs)
242
+ const startedAt = Date.now()
243
+ const client = new Client()
244
+ /** @type {NodeJS.Timeout | undefined} */
245
+ let timer
246
+
247
+ const execution = new Promise((resolve, reject) => {
248
+ let settled = false
249
+ /**
250
+ * Settle the execution once.
251
+ * @param {Error | null} error - failure.
252
+ * @param {object} [value] - success value.
253
+ */
254
+ const finish = (error, value) => {
255
+ if (settled) return
256
+ settled = true
257
+ if (timer !== undefined) clearTimeout(timer)
258
+ if (error) reject(error)
259
+ else resolve(value)
260
+ }
261
+ const stdout = createCapture(STDOUT_LIMIT)
262
+ const stderr = createCapture(STDERR_LIMIT)
263
+
264
+ timer = setTimeout(() => {
265
+ finish(sshError('timeout', `SSH 操作超时(${Math.round(timeoutMs / 1000)} 秒)`))
266
+ try {
267
+ client.end()
268
+ } catch {
269
+ /* already closed */
270
+ }
271
+ }, timeoutMs)
272
+
273
+ client.on('ready', () => {
274
+ client.exec(command, (error, channel) => {
275
+ if (error) {
276
+ finish(mapSshError(error))
277
+ return
278
+ }
279
+ if (!channel) {
280
+ finish(sshError('unknown', 'SSH 连接失败,请检查主机、端口和认证信息'))
281
+ return
282
+ }
283
+ channel.on('data', (chunk) => {
284
+ if (!settled) stdout.push(chunk)
285
+ })
286
+ channel.on('error', (streamError) => finish(mapSshError(streamError)))
287
+ if (channel.stderr && typeof channel.stderr.on === 'function') {
288
+ channel.stderr.on('data', (chunk) => {
289
+ if (!settled) stderr.push(chunk)
290
+ })
291
+ channel.stderr.on('error', (streamError) => finish(mapSshError(streamError)))
292
+ }
293
+ channel.on('close', (code, signal) => {
294
+ finish(null, {
295
+ exitCode: typeof code === 'number' ? code : null,
296
+ signal: typeof signal === 'string' ? signal : null,
297
+ stdout: stdout.text(),
298
+ stderr: stderr.text(),
299
+ durationMs: Date.now() - startedAt,
300
+ truncated: stdout.isTruncated() || stderr.isTruncated(),
301
+ })
302
+ })
303
+ })
304
+ })
305
+ client.on('error', (error) => finish(mapSshError(error)))
306
+ client.on('close', () => finish(mapSshError(new Error('连接已关闭'))))
307
+
308
+ try {
309
+ client.connect(config)
310
+ } catch (error) {
311
+ finish(mapSshError(error))
312
+ }
313
+ })
314
+
315
+ try {
316
+ return await execution
317
+ } finally {
318
+ try {
319
+ client.end()
320
+ } catch {
321
+ /* already closed */
322
+ }
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Check that one account is reachable and readable.
328
+ * @param {object} record - full record including credentials.
329
+ * @param {{ timeoutMs?: number, connectTimeoutMs?: number }} [options] - timeout overrides.
330
+ * @returns {Promise<{ success: true, latencyMs: number, output: string }>} probe result.
331
+ */
332
+ async function probe(record, options = {}) {
333
+ const timeoutMs = clampNumber(options.timeoutMs, DEFAULT_PROBE_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
334
+ const connectTimeoutMs = clampNumber(
335
+ options.connectTimeoutMs,
336
+ Math.min(DEFAULT_CONNECT_TIMEOUT_MS, timeoutMs),
337
+ MIN_TIMEOUT_MS,
338
+ MAX_TIMEOUT_MS,
339
+ )
340
+ const result = await exec(record, PROBE_COMMAND, { timeoutMs, connectTimeoutMs })
341
+ return { success: true, latencyMs: result.durationMs, output: result.stdout.trim() }
342
+ }
343
+
344
+ /**
345
+ * Upload one local file over SFTP.
346
+ *
347
+ * This is the only remote write this module performs; it deliberately takes an
348
+ * already-validated local path and remote path and never decides policy. The
349
+ * caller (`host/upload.js`) is responsible for approval and verification.
350
+ *
351
+ * @param {object} record - full record including credentials.
352
+ * @param {{ localPath: string, remotePath: string, timeoutMs?: number, connectTimeoutMs?: number, onProgress?: (sent: number, total: number) => void }} options - upload options.
353
+ * @returns {Promise<{ bytes: number, durationMs: number, bytesPerSecond: number }>} upload result.
354
+ */
355
+ async function upload(record, options = {}) {
356
+ const fields = normalizeRecord(record)
357
+ const localPath = typeof options.localPath === 'string' ? options.localPath : ''
358
+ const remotePath = typeof options.remotePath === 'string' ? options.remotePath : ''
359
+ if (localPath === '') throw sshError('invalid-input', '本地文件路径不能为空')
360
+ if (remotePath === '') throw sshError('invalid-input', '远程路径不能为空')
361
+
362
+ const timeoutMs = clampNumber(options.timeoutMs, DEFAULT_UPLOAD_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_UPLOAD_TIMEOUT_MS)
363
+ const connectTimeoutMs = clampNumber(
364
+ options.connectTimeoutMs,
365
+ Math.min(DEFAULT_CONNECT_TIMEOUT_MS, timeoutMs),
366
+ MIN_TIMEOUT_MS,
367
+ MAX_TIMEOUT_MS,
368
+ )
369
+ const config = connectionConfig(record, fields, connectTimeoutMs)
370
+ const startedAt = Date.now()
371
+ const client = new Client()
372
+ /** @type {NodeJS.Timeout | undefined} */
373
+ let timer
374
+
375
+ const transfer = new Promise((resolve, reject) => {
376
+ let settled = false
377
+ /**
378
+ * Settle the transfer once.
379
+ * @param {Error | null} error - failure.
380
+ * @param {object} [value] - success value.
381
+ */
382
+ const finish = (error, value) => {
383
+ if (settled) return
384
+ settled = true
385
+ if (timer !== undefined) clearTimeout(timer)
386
+ if (error) reject(error)
387
+ else resolve(value)
388
+ }
389
+
390
+ timer = setTimeout(() => {
391
+ finish(sshError('timeout', `SFTP 上传超时(${Math.round(timeoutMs / 1000)} 秒)`))
392
+ try {
393
+ client.end()
394
+ } catch {
395
+ /* already closed */
396
+ }
397
+ }, timeoutMs)
398
+
399
+ client.on('ready', () => {
400
+ client.sftp((error, sftp) => {
401
+ if (error) {
402
+ finish(mapSftpError(error))
403
+ return
404
+ }
405
+ if (!sftp) {
406
+ finish(sshError('unknown', '无法启动 SFTP 子系统,请确认服务器已启用 SFTP'))
407
+ return
408
+ }
409
+ /** @type {number} */
410
+ let lastSent = 0
411
+ sftp.fastPut(
412
+ localPath,
413
+ remotePath,
414
+ { concurrency: UPLOAD_CONCURRENCY, chunkSize: UPLOAD_CHUNK_SIZE },
415
+ (putError, bytes) => {
416
+ if (putError) {
417
+ finish(mapSftpError(putError))
418
+ return
419
+ }
420
+ if (typeof options.onProgress === 'function' && typeof bytes === 'number' && bytes > 0) {
421
+ try {
422
+ options.onProgress(bytes, bytes)
423
+ } catch {
424
+ /* progress reporting is best effort */
425
+ }
426
+ }
427
+ lastSent = typeof bytes === 'number' && bytes > 0 ? bytes : lastSent
428
+ const durationMs = Math.max(1, Date.now() - startedAt)
429
+ finish(null, {
430
+ bytes: lastSent,
431
+ durationMs,
432
+ bytesPerSecond: Math.round((lastSent / durationMs) * 1000),
433
+ })
434
+ },
435
+ )
436
+ })
437
+ })
438
+ client.on('error', (error) => finish(mapSshError(error)))
439
+ client.on('close', () => finish(sshError('network', '连接已关闭')))
440
+
441
+ try {
442
+ client.connect(config)
443
+ } catch (error) {
444
+ finish(mapSshError(error))
445
+ }
446
+ })
447
+
448
+ try {
449
+ return await transfer
450
+ } finally {
451
+ try {
452
+ client.end()
453
+ } catch {
454
+ /* already closed */
455
+ }
456
+ }
457
+ }
458
+
459
+ return { exec, probe, upload }
460
+ }