@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/LICENSE +21 -0
- package/README.md +330 -2
- package/README.zh.md +331 -0
- package/client.js +2786 -0
- package/cordis.patch.yml +13 -0
- package/docs/screenshot-accounts.png +0 -0
- package/docs/screenshot-activity.png +0 -0
- package/docs/screenshot-panel.png +0 -0
- package/docs/screenshot-resource-detail.png +0 -0
- package/host/accounts.js +688 -0
- package/host/api.js +440 -0
- package/host/atomic.js +160 -0
- package/host/discover.js +440 -0
- package/host/gate.js +264 -0
- package/host/history.js +276 -0
- package/host/prompt.js +103 -0
- package/host/resources.js +604 -0
- package/host/shell.js +194 -0
- package/host/ssh.js +460 -0
- package/host/tools.js +1086 -0
- package/host/update.js +992 -0
- package/host/upload.js +399 -0
- package/icon.svg +19 -0
- package/index.js +141 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
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
|
+
}
|