@hyzyn/dsh-tty 0.18.2 → 0.19.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/README.en.md +85 -34
- package/README.md +65 -29
- package/client.js +51 -37
- package/lib/index.d.ts +367 -1
- package/lib/index.js +420 -138
- package/lib/index.js.map +1 -1
- package/lib/known-hosts.d.ts +7 -1
- package/lib/known-hosts.js +26 -12
- package/lib/known-hosts.js.map +1 -1
- package/lib/probe.d.ts +2 -2
- package/lib/probe.js +8 -36
- package/lib/probe.js.map +1 -1
- package/lib/sftp.d.ts +63 -10
- package/lib/sftp.js +244 -44
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +33 -13
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +21 -7
- package/lib/ssh.js +114 -19
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +67 -13
- package/lib/tunnels.js.map +1 -1
- package/package.json +7 -2
- package/scripts/build-client.mjs +30 -0
- package/scripts/integration.mjs +1691 -0
- package/scripts/lib/test-sshd.mjs +314 -0
- package/scripts/live.mjs +68 -0
- package/scripts/preview/harness.html +75 -0
- package/scripts/preview/harness.js +875 -0
- package/scripts/preview/mock-host.js +365 -0
- package/scripts/preview.mjs +502 -0
- package/scripts/probe-route-smoke.mjs +111 -0
- package/scripts/probe-smoke.mjs +143 -0
- package/scripts/probe.mjs +240 -0
- package/scripts/sftplimits-smoke.mjs +92 -0
- package/scripts/ssh-smoke.mjs +593 -0
- package/scripts/tui-smoke.mjs +112 -0
- package/scripts/windows-smoke.mjs +179 -0
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — SSH 冒烟:内存 SSH server(ssh2.Server)× 真实 spawnSsh 端到端。
|
|
4
|
+
*
|
|
5
|
+
* 本机不依赖真实 sshd —— 用 ssh2 的 Server 在 127.0.0.1 随机端口起一个内存 SSH
|
|
6
|
+
* 服务端(临时 RSA host key,crypto.generateKeyPairSync 导出 pkcs1 PEM),只放行
|
|
7
|
+
* 密码用户 test/secret。shell 回调实现一个微型伪 shell:
|
|
8
|
+
* - 连上先发送 prompt;
|
|
9
|
+
* - 收到 `printf "X%s\n" OK` / `echo X` 时返回可匹配文本(不回显输入行,匹配零歧义);
|
|
10
|
+
* - 处理 `stty size`(返回 "rows cols"),验证 pty-req 初始尺寸与 resize 后的
|
|
11
|
+
* window-change 真正到达服务端(同 channel 顺序投递,resize 后紧跟的 stty
|
|
12
|
+
* 必然读到新尺寸);
|
|
13
|
+
* - `exit` 回送 exit-status 后关 channel。
|
|
14
|
+
* 注意:客户端 conn.shell({term,cols,rows}) 会先发 pty-req,ssh2 服务端在没有
|
|
15
|
+
* 'pty' 监听时会「自动拒绝」该请求导致 shell 开不起来 —— 必须接住并 accept。
|
|
16
|
+
*
|
|
17
|
+
* 然后用构建产物 ../lib/ssh.js 的 spawnSsh 以 auth=password 连上来,验证:
|
|
18
|
+
* S1 password 认证 + shell channel 建立,prompt 经输出流可见
|
|
19
|
+
* S2 printf / echo 命令输出可匹配(channel 数据往返)
|
|
20
|
+
* S3 stty size 返回 pty-req 初始尺寸(24x80 → "24 80")
|
|
21
|
+
* S4 resize(110,33) 不抛错且生效(window-change → stty "33 110")
|
|
22
|
+
* S5 terminate 后 done 恰好 resolve(含退出事实)
|
|
23
|
+
* S6 第二轮会话 exit 命令 → 服务端 exit-status → done.exitCode=0
|
|
24
|
+
* S7 TOFU:首次连接经 HostKeyStore 记录 sha256 指纹
|
|
25
|
+
* S8 TOFU:同 host:port 换服务器密钥后,指纹变更拒绝连接(带重置指引)
|
|
26
|
+
* S9 SFTP(0.7.0):scripts/lib/test-sshd.mjs 的 sftp subsystem(映射临时目录
|
|
27
|
+
* 真实 fs)× 真实 SftpManager——list/realpath home/上传覆盖+追加/下载
|
|
28
|
+
* (含 content-length)/mkdir+rename+递归与非递归删除/TOFU 指纹变更拒绝;
|
|
29
|
+
* S9g(0.8.0):mkdir parents 自底向上逐级补齐(幂等)+ tree 限深截断/全量
|
|
30
|
+
*
|
|
31
|
+
* 用法:pnpm --filter @hyzyn/dsh-tty ssh-smoke(需先 build 产出 lib/ssh.js、lib/sftp.js)
|
|
32
|
+
* 退出码:0 = 全部 PASS,1 = 任一 FAIL。
|
|
33
|
+
*/
|
|
34
|
+
import ssh2 from 'ssh2'
|
|
35
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
36
|
+
import fsp from 'node:fs/promises'
|
|
37
|
+
import os from 'node:os'
|
|
38
|
+
import path from 'node:path'
|
|
39
|
+
import { spawnSsh } from '../lib/ssh.js'
|
|
40
|
+
import { buildRemoteStatsCommand } from '../lib/stats.js'
|
|
41
|
+
import { SftpManager } from '../lib/sftp.js'
|
|
42
|
+
import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
|
|
43
|
+
|
|
44
|
+
const USER = 'test'
|
|
45
|
+
const PASSWORD = 'secret'
|
|
46
|
+
const PROMPT = 'dsh-ssh-smoke:~$ '
|
|
47
|
+
|
|
48
|
+
/* 全局看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
console.error('[watchdog] 90s 看门狗触发:ssh-smoke 卡死')
|
|
51
|
+
process.exit(2)
|
|
52
|
+
}, 90000).unref()
|
|
53
|
+
process.on('unhandledRejection', (reason) => {
|
|
54
|
+
console.error('[unhandledRejection]', reason)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** 内存 sshd 收到的 exec 记录(S10 验证:确为非 PTY、命令经 sh -c 单引号包裹)。 */
|
|
58
|
+
const EXEC_LOG = []
|
|
59
|
+
const RESULTS = []
|
|
60
|
+
function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
|
|
61
|
+
function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
|
|
62
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
63
|
+
|
|
64
|
+
/** 累积终端输出直到匹配正则,超时 reject(先建订阅再写输入,无竞态窗口)。 */
|
|
65
|
+
function expectOutput(handle, re, timeoutMs = 8000, label = '输出') {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
let buffer = ''
|
|
68
|
+
const onData = (chunk) => {
|
|
69
|
+
buffer += chunk.toString('utf8')
|
|
70
|
+
const match = buffer.match(re)
|
|
71
|
+
if (match) {
|
|
72
|
+
clearTimeout(timer)
|
|
73
|
+
handle.output.off('data', onData)
|
|
74
|
+
resolve(match)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
handle.output.off('data', onData)
|
|
79
|
+
reject(new Error(`等待「${label}」超时;尾部输出: ${JSON.stringify(buffer.slice(-200))}`))
|
|
80
|
+
}, timeoutMs)
|
|
81
|
+
handle.output.on('data', onData)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** done 竞速:超时给出可读 FAIL 而不是等看门狗。 */
|
|
86
|
+
function expectDone(handle, timeoutMs = 10000) {
|
|
87
|
+
return Promise.race([
|
|
88
|
+
handle.done,
|
|
89
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('done 未在 ' + timeoutMs + 'ms 内 resolve')), timeoutMs)),
|
|
90
|
+
])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ------------------------------------------------------------------ *
|
|
94
|
+
* 内存 SSH server(伪 shell)
|
|
95
|
+
* ------------------------------------------------------------------ */
|
|
96
|
+
|
|
97
|
+
/** 把一行命令翻译成响应;返回 true 表示 shell 应当结束(exit)。 */
|
|
98
|
+
function respondTo(stream, cmd, dims) {
|
|
99
|
+
// stty size → "rows cols"(与真实 stty 输出格式一致)
|
|
100
|
+
if (/^stty\s+size$/.test(cmd)) {
|
|
101
|
+
stream.write(`${dims.rows} ${dims.cols}\r\n`)
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
// printf "FMT" [args…]:只支持 %s 占位符逐个吃参数(冒烟所需最小子集)
|
|
105
|
+
const printf = cmd.match(/^printf\s+(["'])(.*?)\1(?:\s+(.*))?$/)
|
|
106
|
+
if (printf) {
|
|
107
|
+
const args = printf[3] === undefined ? [] : printf[3].split(/\s+/)
|
|
108
|
+
let i = 0
|
|
109
|
+
const text = printf[2].replace(/%s/g, () => args[i++] ?? '')
|
|
110
|
+
stream.write(text.replace(/\\n/g, '\r\n') + '\r\n')
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
// echo [args…]
|
|
114
|
+
const echo = cmd.match(/^echo\s+(.+?)\s*$/)
|
|
115
|
+
if (echo) {
|
|
116
|
+
const quoted = echo[1].match(/^(["'])(.*)\1$/)
|
|
117
|
+
stream.write((quoted ? quoted[2] : echo[1].split(/\s+/).join(' ')) + '\r\n')
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
stream.write(`ssh-smoke: command not found: ${cmd}\r\n`)
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function onClientConnection(client) {
|
|
125
|
+
client.on('authentication', (ctx) => {
|
|
126
|
+
if (ctx.method === 'password' && ctx.username === USER && ctx.password === PASSWORD) {
|
|
127
|
+
ctx.accept()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
ctx.reject()
|
|
131
|
+
})
|
|
132
|
+
client.on('ready', () => {
|
|
133
|
+
client.on('session', (accept) => {
|
|
134
|
+
const session = accept()
|
|
135
|
+
// 终端尺寸:pty-req 给初值,window-change 跟进(resize 验证的数据源)
|
|
136
|
+
const dims = { rows: 24, cols: 80 }
|
|
137
|
+
let ptySeen = false
|
|
138
|
+
session.on('pty', (acceptPty, _rejectPty, info) => {
|
|
139
|
+
ptySeen = true
|
|
140
|
+
if (Number.isFinite(info?.rows)) dims.rows = info.rows
|
|
141
|
+
if (Number.isFinite(info?.cols)) dims.cols = info.cols
|
|
142
|
+
acceptPty()
|
|
143
|
+
})
|
|
144
|
+
session.on('window-change', (_accept, _reject, info) => {
|
|
145
|
+
if (Number.isFinite(info?.rows)) dims.rows = info.rows
|
|
146
|
+
if (Number.isFinite(info?.cols)) dims.cols = info.cols
|
|
147
|
+
})
|
|
148
|
+
// 服务器状态条(0.17.0):非 PTY 的 exec channel。真实远端脚本见
|
|
149
|
+
// src/stats.ts 的 remoteStatsScript;这里按同一输出形状回吐 JSON 行,
|
|
150
|
+
// 供冒烟验证「同连接另开 channel + 逐行解析 + stop 关 channel」。
|
|
151
|
+
session.on('exec', (acceptExec, _rejectExec, info) => {
|
|
152
|
+
const command = typeof info?.command === 'string' ? info.command : ''
|
|
153
|
+
EXEC_LOG.push({ command, pty: ptySeen })
|
|
154
|
+
if (!command.includes('/proc/stat')) {
|
|
155
|
+
const stream = acceptExec()
|
|
156
|
+
stream.exit(127)
|
|
157
|
+
stream.end()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
const stream = acceptExec()
|
|
161
|
+
let tick = 0
|
|
162
|
+
const timer = setInterval(() => {
|
|
163
|
+
tick += 1
|
|
164
|
+
stream.write(JSON.stringify({
|
|
165
|
+
cpuPct: 10 + tick, cores: 8, memUsed: 1e9 * tick, memTotal: 8e9, memPct: 12.5 * tick,
|
|
166
|
+
diskTotal: 1e11, diskUsed: 5e10, diskPct: 50, uptimeSec: 3600 * tick,
|
|
167
|
+
tcpConns: 100 + tick, rxRate: 1024 * tick, txRate: 2048 * tick, tempC: 45.5,
|
|
168
|
+
}) + '\n')
|
|
169
|
+
}, 40)
|
|
170
|
+
const stop = () => clearInterval(timer)
|
|
171
|
+
stream.on('close', stop)
|
|
172
|
+
stream.on('end', stop)
|
|
173
|
+
})
|
|
174
|
+
session.on('shell', (acceptShell) => {
|
|
175
|
+
const stream = acceptShell()
|
|
176
|
+
stream.write(PROMPT)
|
|
177
|
+
let line = ''
|
|
178
|
+
stream.on('data', (chunk) => {
|
|
179
|
+
for (const ch of chunk.toString('utf8')) {
|
|
180
|
+
if (ch === '\r' || ch === '\n') {
|
|
181
|
+
const cmd = line.trim()
|
|
182
|
+
line = ''
|
|
183
|
+
if (cmd === 'exit' || cmd === 'logout') {
|
|
184
|
+
// 先 exit-status 再 eof+close:客户端 channel 依次收到
|
|
185
|
+
// exit(exitCode=0) 与 close → done resolve
|
|
186
|
+
stream.exit(0)
|
|
187
|
+
stream.end()
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (cmd !== '') respondTo(stream, cmd, dims)
|
|
191
|
+
stream.write(PROMPT)
|
|
192
|
+
} else if (ch === '\x7f') {
|
|
193
|
+
line = line.slice(0, -1) // 退格
|
|
194
|
+
} else if (ch >= ' ') {
|
|
195
|
+
line += ch
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
client.on('error', (error) => {
|
|
203
|
+
console.error('[ssh-smoke] 服务端 client 连接错误: ' + error.message)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function startServer(preferredPort) {
|
|
208
|
+
// 临时 RSA host key:pkcs1 PEM(-----BEGIN RSA PRIVATE KEY-----),不落盘。
|
|
209
|
+
// 传 preferredPort 时复用同一端口(TOFU 用例:同 host:port 换密钥模拟指纹变更)
|
|
210
|
+
const { privateKey } = generateKeyPairSync('rsa', {
|
|
211
|
+
modulusLength: 2048,
|
|
212
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
213
|
+
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
214
|
+
})
|
|
215
|
+
let lastError = null
|
|
216
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
217
|
+
const server = new ssh2.Server({ hostKeys: [privateKey] }, onClientConnection)
|
|
218
|
+
try {
|
|
219
|
+
await new Promise((resolve, reject) => {
|
|
220
|
+
server.once('error', reject)
|
|
221
|
+
server.listen(preferredPort ?? 0, '127.0.0.1', () => {
|
|
222
|
+
server.removeListener('error', reject)
|
|
223
|
+
resolve()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
server.on('error', (error) => {
|
|
227
|
+
console.error('[ssh-smoke] server 错误: ' + error.message)
|
|
228
|
+
})
|
|
229
|
+
return server
|
|
230
|
+
} catch (error) {
|
|
231
|
+
lastError = error
|
|
232
|
+
await sleep(300) // 端口尚未完全释放(TIME_WAIT 等):稍后重试
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
throw lastError ?? new Error('startServer 重试耗尽')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function stopServer(server) {
|
|
239
|
+
await new Promise((resolve) => {
|
|
240
|
+
const timer = setTimeout(resolve, 3000).unref() // close 回调兜底,避免清理环节卡死
|
|
241
|
+
server.close(() => {
|
|
242
|
+
clearTimeout(timer)
|
|
243
|
+
resolve()
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
server.closeAllConnections?.()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ------------------------------------------------------------------ *
|
|
250
|
+
* 主流程
|
|
251
|
+
* ------------------------------------------------------------------ */
|
|
252
|
+
|
|
253
|
+
async function runSession(server, port, options, label) {
|
|
254
|
+
console.log(`\n[${label}] spawnSsh → 127.0.0.1:${port}(auth=password, ${USER}/${PASSWORD})`)
|
|
255
|
+
const handle = await spawnSsh(
|
|
256
|
+
{ host: '127.0.0.1', port, username: USER, auth: 'password', password: PASSWORD },
|
|
257
|
+
options,
|
|
258
|
+
)
|
|
259
|
+
return handle
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function main() {
|
|
263
|
+
console.log('启动内存 SSH server(ssh2.Server @ 127.0.0.1 随机端口)…')
|
|
264
|
+
const server = await startServer()
|
|
265
|
+
const port = server.address().port
|
|
266
|
+
console.log(`server 就绪 on 127.0.0.1:${port},放行密码用户 ${USER}`)
|
|
267
|
+
|
|
268
|
+
// TOFU 指纹存储(内存实现):S1/S6 走 record + match,S8 用换密钥的服务端走 mismatch
|
|
269
|
+
const fingerprintMap = new Map()
|
|
270
|
+
const hostKeyStore = {
|
|
271
|
+
// 0.19.0 起 get 返回指纹集合(一机多把钥匙)
|
|
272
|
+
get: (host, p) => {
|
|
273
|
+
const fp = fingerprintMap.get(host + ':' + p)
|
|
274
|
+
return fp === undefined ? undefined : [fp]
|
|
275
|
+
},
|
|
276
|
+
record: (host, p, fp) => fingerprintMap.set(host + ':' + p, fp),
|
|
277
|
+
}
|
|
278
|
+
const options = { term: 'xterm-256color', cols: 80, rows: 24, hostKeyStore, logger: { info: (m) => console.log(' ' + m), warn: (m) => console.warn(' ' + m) } }
|
|
279
|
+
|
|
280
|
+
// ---- 会话 1:认证/输出/resize/terminate ----
|
|
281
|
+
console.log('\n[1] 全链路(spawn→prompt→printf/echo→stty→resize→terminate)')
|
|
282
|
+
{
|
|
283
|
+
let handle
|
|
284
|
+
try {
|
|
285
|
+
handle = await runSession(server, port, options, '1')
|
|
286
|
+
} catch (error) {
|
|
287
|
+
fail('S1 password 认证 + shell channel 建立(prompt 可见)', error.message)
|
|
288
|
+
throw error
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
await expectOutput(handle, /dsh-ssh-smoke:~\$ /, 8000, 'prompt')
|
|
292
|
+
pass('S1 password 认证 + shell channel 建立(prompt 经输出流可见)')
|
|
293
|
+
} catch (error) {
|
|
294
|
+
fail('S1 password 认证 + shell channel 建立(prompt 可见)', error.message)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// printf(先建订阅再写输入,无竞态窗口)
|
|
298
|
+
try {
|
|
299
|
+
const pending = expectOutput(handle, /SSH_SMOKE_OK/, 8000, 'printf 输出')
|
|
300
|
+
await handle.write('printf "SSH_SMOKE_%s\\n" OK\n')
|
|
301
|
+
await pending
|
|
302
|
+
pass('S2a printf 命令输出可匹配(channel 数据往返)')
|
|
303
|
+
} catch (error) {
|
|
304
|
+
fail('S2a printf 命令输出可匹配(channel 数据往返)', error.message)
|
|
305
|
+
}
|
|
306
|
+
// echo
|
|
307
|
+
try {
|
|
308
|
+
const pending = expectOutput(handle, /SMOKE_ECHO_OK/, 8000, 'echo 输出')
|
|
309
|
+
await handle.write('echo SMOKE_ECHO_OK\n')
|
|
310
|
+
await pending
|
|
311
|
+
pass('S2b echo 命令输出可匹配')
|
|
312
|
+
} catch (error) {
|
|
313
|
+
fail('S2b echo 命令输出可匹配', error.message)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// stty size:pty-req 初始尺寸(rows cols = "24 80")
|
|
317
|
+
try {
|
|
318
|
+
const pending = expectOutput(handle, /(\d+)\s+(\d+)/, 8000, 'stty 初始尺寸')
|
|
319
|
+
await handle.write('stty size\n')
|
|
320
|
+
const m = await pending
|
|
321
|
+
const ok = Number(m[1]) === 24 && Number(m[2]) === 80
|
|
322
|
+
console.log(` stty size → ${m[1]} ${m[2]}(期望 24 80)`)
|
|
323
|
+
if (ok) pass('S3 stty size 命中 pty-req 初始尺寸(rows=24 cols=80)')
|
|
324
|
+
else fail('S3 stty size 命中 pty-req 初始尺寸(rows=24 cols=80)', `得到 ${m[1]} ${m[2]}`)
|
|
325
|
+
} catch (error) {
|
|
326
|
+
fail('S3 stty size 命中 pty-req 初始尺寸(rows=24 cols=80)', error.message)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// resize:不抛错 + window-change 生效(同 channel 顺序投递,紧跟的 stty 必读到新尺寸)
|
|
330
|
+
try {
|
|
331
|
+
let threw = null
|
|
332
|
+
try { handle.resize(110, 33) } catch (error) { threw = error }
|
|
333
|
+
if (threw !== null) fail('S4 resize(110,33) 不抛错', threw.message)
|
|
334
|
+
else {
|
|
335
|
+
await sleep(100)
|
|
336
|
+
const pending = expectOutput(handle, /(\d+)\s+(\d+)/, 8000, 'stty resize 后尺寸')
|
|
337
|
+
await handle.write('stty size\n')
|
|
338
|
+
const m = await pending
|
|
339
|
+
const ok = Number(m[1]) === 33 && Number(m[2]) === 110
|
|
340
|
+
console.log(` resize(110,33) 后 stty size → ${m[1]} ${m[2]}(期望 33 110)`)
|
|
341
|
+
if (ok) pass('S4 resize(110,33) 不抛错且 window-change 生效')
|
|
342
|
+
else fail('S4 resize(110,33) 不抛错且 window-change 生效', `stty 报告 ${m[1]} ${m[2]}`)
|
|
343
|
+
}
|
|
344
|
+
} catch (error) {
|
|
345
|
+
fail('S4 resize(110,33) 不抛错且 window-change 生效', error.message)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// terminate → done resolve(此时 channel 未自然退出,done 由 terminate 触发)
|
|
349
|
+
try {
|
|
350
|
+
let threw = null
|
|
351
|
+
try { await handle.terminate() } catch (error) { threw = error }
|
|
352
|
+
if (threw !== null) throw threw
|
|
353
|
+
const outcome = await expectDone(handle)
|
|
354
|
+
console.log(` terminate → done resolve(exitCode=${outcome.exitCode} signal=${outcome.signal})`)
|
|
355
|
+
pass('S5 terminate 后 done resolve(含退出事实)')
|
|
356
|
+
} catch (error) {
|
|
357
|
+
fail('S5 terminate 后 done resolve(含退出事实)', error.message)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---- 会话 2:exit 命令 → 服务端 exit-status → done.exitCode=0 ----
|
|
362
|
+
console.log('\n[2] exit 命令自然退出(exit-status 路径)')
|
|
363
|
+
{
|
|
364
|
+
let handle
|
|
365
|
+
try {
|
|
366
|
+
handle = await runSession(server, port, options, '2')
|
|
367
|
+
await expectOutput(handle, /dsh-ssh-smoke:~\$ /, 8000, 'prompt')
|
|
368
|
+
const pending = expectDone(handle)
|
|
369
|
+
await handle.write('exit\n')
|
|
370
|
+
const outcome = await pending
|
|
371
|
+
console.log(` exit → done resolve(exitCode=${outcome.exitCode} signal=${outcome.signal})`)
|
|
372
|
+
if (outcome.exitCode === 0) pass('S6 exit 命令 → 服务端 exit-status → done.exitCode=0')
|
|
373
|
+
else fail('S6 exit 命令 → 服务端 exit-status → done.exitCode=0', `exitCode=${outcome.exitCode}`)
|
|
374
|
+
} catch (error) {
|
|
375
|
+
fail('S6 exit 命令 → 服务端 exit-status → done.exitCode=0', error.message)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---- TOFU 主机指纹钉扎(协议层)----
|
|
380
|
+
console.log('\n[3] TOFU 主机指纹钉扎')
|
|
381
|
+
{
|
|
382
|
+
// S7:前两轮连接应已在 HostKeyStore 记录指纹
|
|
383
|
+
const fp = hostKeyStore.get('127.0.0.1', port)
|
|
384
|
+
if (Array.isArray(fp) && typeof fp[0] === 'string' && fp[0].length > 0) pass('S7 首次连接经 HostKeyStore 记录 sha256 指纹(' + fp[0].slice(0, 16) + '…)')
|
|
385
|
+
else fail('S7 首次连接经 HostKeyStore 记录 sha256 指纹', String(fp))
|
|
386
|
+
|
|
387
|
+
// S8:同 host:port 换一把服务器密钥 → 指纹变更必须拒绝连接
|
|
388
|
+
await stopServer(server)
|
|
389
|
+
const server2 = await startServer(port)
|
|
390
|
+
console.log(` 已在 127.0.0.1:${port} 起新 server(不同 host key),验证指纹变更拒绝…`)
|
|
391
|
+
let mismatchError = null
|
|
392
|
+
try {
|
|
393
|
+
await spawnSsh(
|
|
394
|
+
{ host: '127.0.0.1', port, username: USER, auth: 'password', password: PASSWORD },
|
|
395
|
+
options,
|
|
396
|
+
)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
mismatchError = error instanceof Error ? error.message : String(error)
|
|
399
|
+
}
|
|
400
|
+
if (mismatchError !== null && /主机密钥指纹变更/.test(mismatchError)) pass('S8 指纹变更拒绝连接(错误含重置指引)')
|
|
401
|
+
else fail('S8 指纹变更拒绝连接(错误含重置指引)', mismatchError === null ? '连接意外成功' : String(mismatchError))
|
|
402
|
+
|
|
403
|
+
await stopServer(server2)
|
|
404
|
+
console.log('两个内存 SSH server 均已关闭(端口已释放)')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---- SFTP(0.7.0):test-sshd 的 sftp subsystem × 真实 SftpManager ----
|
|
408
|
+
console.log('\n[4] SFTP 文件传输(test-sshd sftp subsystem × lib/sftp.js)')
|
|
409
|
+
{
|
|
410
|
+
const rootDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-sftp-'))
|
|
411
|
+
const sftpd = await startSftpSshd({ rootDir })
|
|
412
|
+
console.log(` sftp sshd on 127.0.0.1:${sftpd.port},rootDir=${rootDir}`)
|
|
413
|
+
await fsp.writeFile(path.join(rootDir, 'a.txt'), Buffer.alloc(100, 0x61))
|
|
414
|
+
await fsp.mkdir(path.join(rootDir, 'sub'))
|
|
415
|
+
await fsp.writeFile(path.join(rootDir, 'sub', 'nested.txt'), 'nested')
|
|
416
|
+
|
|
417
|
+
const sftpLogger = { info: () => {}, warn: (m) => console.warn(' ' + m) }
|
|
418
|
+
const acceptStore = { get: () => undefined, record: () => {} }
|
|
419
|
+
const manager = new SftpManager(sftpLogger, acceptStore)
|
|
420
|
+
const spec = { host: '127.0.0.1', port: sftpd.port, username: TEST_USER, auth: 'password', password: TEST_PASSWORD }
|
|
421
|
+
|
|
422
|
+
// S9a 目录列表:真实路径、文件/目录判定与 size
|
|
423
|
+
try {
|
|
424
|
+
const listed = await manager.list(spec, rootDir)
|
|
425
|
+
const aTxt = listed.entries.find((e) => e.name === 'a.txt')
|
|
426
|
+
const sub = listed.entries.find((e) => e.name === 'sub')
|
|
427
|
+
const ok = listed.path === rootDir
|
|
428
|
+
&& Array.isArray(listed.entries) && listed.entries.length === 2
|
|
429
|
+
&& aTxt?.isFile === true && aTxt?.size === 100
|
|
430
|
+
&& sub?.isDir === true && sub?.isFile === false
|
|
431
|
+
if (ok) pass('S9a list 目录列表(真实路径、文件/目录判定与 size)')
|
|
432
|
+
else fail('S9a list 目录列表(真实路径、文件/目录判定与 size)', JSON.stringify(listed).slice(0, 200))
|
|
433
|
+
} catch (error) {
|
|
434
|
+
fail('S9a list 目录列表(真实路径、文件/目录判定与 size)', error.message)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// S9b path 缺省 → realpath('.') 解析到登录 home(test-sshd 映射为 rootDir)
|
|
438
|
+
try {
|
|
439
|
+
const home = await manager.list(spec, '')
|
|
440
|
+
if (home.path === rootDir) pass('S9b path 缺省 realpath 解析到登录 home')
|
|
441
|
+
else fail('S9b path 缺省 realpath 解析到登录 home', 'path=' + String(home.path))
|
|
442
|
+
} catch (error) {
|
|
443
|
+
fail('S9b path 缺省 realpath 解析到登录 home', error.message)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// S9c 上传:覆盖写 + 追加写,字节逐一比对
|
|
447
|
+
try {
|
|
448
|
+
const data = Buffer.alloc(5000, 0x5a)
|
|
449
|
+
const first = await manager.openUpload(spec, rootDir + '/up.bin')
|
|
450
|
+
first.stream.write(data)
|
|
451
|
+
first.stream.end()
|
|
452
|
+
await first.done
|
|
453
|
+
const second = await manager.openUpload(spec, rootDir + '/up.bin', true)
|
|
454
|
+
second.stream.write('tail')
|
|
455
|
+
second.stream.end()
|
|
456
|
+
await second.done
|
|
457
|
+
const expected = Buffer.concat([data, Buffer.from('tail')])
|
|
458
|
+
const stored = await fsp.readFile(path.join(rootDir, 'up.bin'))
|
|
459
|
+
if (stored.equals(expected)) pass('S9c 上传覆盖 + 追加(5000+4 字节逐一一致)')
|
|
460
|
+
else fail('S9c 上传覆盖 + 追加(5000+4 字节逐一一致)', `落盘 ${String(stored.length)} 字节,期望 ${String(expected.length)}`)
|
|
461
|
+
} catch (error) {
|
|
462
|
+
fail('S9c 上传覆盖 + 追加(5000+4 字节逐一一致)', error.message)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// S9d 下载:字节一致 + size(content-length 数据源)
|
|
466
|
+
try {
|
|
467
|
+
const { stream, size } = await manager.openDownload(spec, rootDir + '/up.bin')
|
|
468
|
+
const chunks = []
|
|
469
|
+
for await (const chunk of stream) chunks.push(chunk)
|
|
470
|
+
const downloaded = Buffer.concat(chunks)
|
|
471
|
+
const expected = Buffer.concat([Buffer.alloc(5000, 0x5a), Buffer.from('tail')])
|
|
472
|
+
if (downloaded.equals(expected) && size === expected.length) {
|
|
473
|
+
pass('S9d 下载字节一致(stat size=' + String(size) + ' = 实际字节数)')
|
|
474
|
+
} else {
|
|
475
|
+
fail('S9d 下载字节一致(stat size 与实际一致)', `size=${String(size)} 实际=${String(downloaded.length)}`)
|
|
476
|
+
}
|
|
477
|
+
} catch (error) {
|
|
478
|
+
fail('S9d 下载字节一致(stat size 与实际一致)', error.message)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// S9e mkdir / rename / 非递归删除拒绝 / 递归删除
|
|
482
|
+
try {
|
|
483
|
+
await manager.mkdir(spec, rootDir + '/nested')
|
|
484
|
+
await manager.rename(spec, rootDir + '/a.txt', rootDir + '/nested/b.txt')
|
|
485
|
+
const moved = await fsp.access(path.join(rootDir, 'nested', 'b.txt')).then(() => true, () => false)
|
|
486
|
+
let refused = null
|
|
487
|
+
try { await manager.remove(spec, rootDir + '/nested', false) } catch (error) { refused = error.message }
|
|
488
|
+
const refusedOk = refused !== null && /recursive|非空/.test(refused)
|
|
489
|
+
await manager.remove(spec, rootDir + '/nested', true)
|
|
490
|
+
const gone = await fsp.access(path.join(rootDir, 'nested')).then(() => false, () => true)
|
|
491
|
+
if (moved && refusedOk && gone) pass('S9e mkdir/rename/非递归删除拒绝/递归删除')
|
|
492
|
+
else fail('S9e mkdir/rename/非递归删除拒绝/递归删除', `moved=${String(moved)} refused=${JSON.stringify(refused)} gone=${String(gone)}`)
|
|
493
|
+
} catch (error) {
|
|
494
|
+
fail('S9e mkdir/rename/非递归删除拒绝/递归删除', error.message)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// S9f TOFU:预置指纹与服务器不符 → SFTP 连接同样拒绝(与终端同源钉扎)
|
|
498
|
+
try {
|
|
499
|
+
const mismatchMap = new Map([[`127.0.0.1:${String(sftpd.port)}`, 'deadbeef'.repeat(8)]])
|
|
500
|
+
const strictStore = { get: (host, p) => { const fp = mismatchMap.get(host + ':' + String(p)); return fp === undefined ? undefined : [fp] }, record: () => {} }
|
|
501
|
+
const strictManager = new SftpManager(sftpLogger, strictStore)
|
|
502
|
+
let mismatch = null
|
|
503
|
+
try { await strictManager.list(spec, '') } catch (error) { mismatch = error.message }
|
|
504
|
+
strictManager.disposeAll()
|
|
505
|
+
if (mismatch !== null && /主机密钥指纹变更/.test(mismatch)) pass('S9f TOFU 指纹变更拒绝 SFTP 连接(错误含重置指引)')
|
|
506
|
+
else fail('S9f TOFU 指纹变更拒绝 SFTP 连接(错误含重置指引)', mismatch === null ? '连接意外成功' : String(mismatch))
|
|
507
|
+
} catch (error) {
|
|
508
|
+
fail('S9f TOFU 指纹变更拒绝 SFTP 连接(错误含重置指引)', error.message)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// S9g mkdir parents 自底向上逐级补齐(mkdir -p 语义)+ tree 递归列举与限深
|
|
512
|
+
try {
|
|
513
|
+
const deep = rootDir + '/p/a/b/c'
|
|
514
|
+
await manager.mkdir(spec, deep, true)
|
|
515
|
+
await manager.mkdir(spec, rootDir + '/p', true) // 幂等:已存在目录不报错
|
|
516
|
+
await fsp.writeFile(path.join(rootDir, 'p', 'a', 'b', 'c', 'leaf.txt'), 'leaf\n')
|
|
517
|
+
const shallow = await manager.tree(spec, rootDir + '/p', { maxDepth: 1 })
|
|
518
|
+
const full = await manager.tree(spec, rootDir + '/p', { maxDepth: 4 })
|
|
519
|
+
const shallowOk = shallow.entries.length === 1 && shallow.entries[0].path === rootDir + '/p/a'
|
|
520
|
+
&& shallow.entries[0].isDir === true && shallow.truncated === true
|
|
521
|
+
const fullOk = full.entries.map((e) => e.path).join('|') === [rootDir + '/p/a', rootDir + '/p/a/b', rootDir + '/p/a/b/c', rootDir + '/p/a/b/c/leaf.txt'].join('|')
|
|
522
|
+
&& full.truncated === false
|
|
523
|
+
&& full.entries[3].size === 'leaf\n'.length
|
|
524
|
+
let noParentsRejected = false
|
|
525
|
+
try { await manager.mkdir(spec, rootDir + '/q/r/s') } catch { noParentsRejected = true }
|
|
526
|
+
await manager.remove(spec, rootDir + '/p', true)
|
|
527
|
+
const cleaned = await fsp.access(path.join(rootDir, 'p')).then(() => false, () => true)
|
|
528
|
+
if (shallowOk && fullOk && noParentsRejected && cleaned) pass('S9g mkdir parents 逐级补齐/幂等 + tree 限深截断/全量')
|
|
529
|
+
else fail('S9g mkdir parents 逐级补齐/幂等 + tree 限深截断/全量', `shallow=${JSON.stringify(shallow.entries).slice(0, 100)} truncated=${String(shallow.truncated)} full=${full.entries.map((e) => e.path).join('|')} noParents=${String(noParentsRejected)} cleaned=${String(cleaned)}`)
|
|
530
|
+
} catch (error) {
|
|
531
|
+
fail('S9g mkdir parents 逐级补齐/幂等 + tree 限深截断/全量', error.message)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
manager.disposeAll()
|
|
535
|
+
await sftpd.close()
|
|
536
|
+
await fsp.rm(rootDir, { recursive: true, force: true })
|
|
537
|
+
console.log(' sftp sshd 已关闭,临时目录已清理')
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ---- S10:服务器状态条(非 PTY exec channel + 逐行 JSON + stop 收尾)----
|
|
541
|
+
console.log('\n[10] 服务器状态条:同连接另开 exec channel')
|
|
542
|
+
{
|
|
543
|
+
// S8 已把前面那台 server 关掉(换密钥用例),这里另起一台(TOFU 接受式 store)
|
|
544
|
+
const statsServer = await startServer()
|
|
545
|
+
const statsPort = statsServer.address().port
|
|
546
|
+
const statsOptions = { ...options, hostKeyStore: { get: () => undefined, record: () => {} } }
|
|
547
|
+
let handle
|
|
548
|
+
try {
|
|
549
|
+
handle = await runSession(statsServer, statsPort, statsOptions, '10')
|
|
550
|
+
await expectOutput(handle, /dsh-ssh-smoke:~\$ /, 8000, 'prompt')
|
|
551
|
+
if (typeof handle.statsExec !== 'function') {
|
|
552
|
+
fail('S10 handle 暴露 statsExec(非 PTY exec channel)', 'statsExec 缺失')
|
|
553
|
+
} else {
|
|
554
|
+
const lines = []
|
|
555
|
+
let errors = 0
|
|
556
|
+
const collector = handle.statsExec(buildRemoteStatsCommand(), (line) => lines.push(line), () => { errors += 1 })
|
|
557
|
+
const deadline = Date.now() + 5000
|
|
558
|
+
while (lines.length < 2 && Date.now() < deadline) await sleep(50)
|
|
559
|
+
const entry = EXEC_LOG[EXEC_LOG.length - 1]
|
|
560
|
+
const nonPty = entry !== undefined && entry.pty === false
|
|
561
|
+
const wrapped = entry !== undefined && entry.command.startsWith('sh -c ') && entry.command.includes('/proc/stat')
|
|
562
|
+
if (lines.length >= 2 && nonPty && wrapped) pass('S10 独立 exec channel(无 PTY)+ 逐行 JSON:' + String(lines.length) + ' 行')
|
|
563
|
+
else fail('S10 独立 exec channel(无 PTY)+ 逐行 JSON', 'lines=' + String(lines.length) + ' nonPty=' + String(nonPty) + ' wrapped=' + String(wrapped) + ' cmd=' + String(entry && entry.command).slice(0, 60))
|
|
564
|
+
// stop() 关 channel → 远端循环结束(不再有新行),且不误报 onError
|
|
565
|
+
collector.stop()
|
|
566
|
+
await sleep(120)
|
|
567
|
+
const mark = lines.length
|
|
568
|
+
await sleep(300)
|
|
569
|
+
if (lines.length === mark) pass('S10b stop() 关闭 channel,远端不再产出行')
|
|
570
|
+
else fail('S10b stop() 关闭 channel,远端不再产出行', 'stop 后又收到 ' + String(lines.length - mark) + ' 行')
|
|
571
|
+
if (errors === 0) pass('S10c 主动 stop 不触发 onError(不算采集失败)')
|
|
572
|
+
else fail('S10c 主动 stop 不触发 onError', 'errors=' + String(errors))
|
|
573
|
+
}
|
|
574
|
+
} catch (error) {
|
|
575
|
+
fail('S10 服务器状态条 exec channel', error.message)
|
|
576
|
+
} finally {
|
|
577
|
+
try { await handle?.terminate() } catch { /* 忽略 */ }
|
|
578
|
+
await stopServer(statsServer)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
{
|
|
583
|
+
const failed = RESULTS.filter(([kind]) => kind === 'FAIL')
|
|
584
|
+
console.log(`\n==== SSH 冒烟:${RESULTS.length - failed.length}/${RESULTS.length} PASS ====`)
|
|
585
|
+
for (const [kind, name, detail] of RESULTS) console.log(` ${kind === 'PASS' ? '✔' : '✘'} ${name}${detail ? ' — ' + detail : ''}`)
|
|
586
|
+
process.exit(failed.length > 0 ? 1 : 0)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
main().catch((error) => {
|
|
591
|
+
console.error('ssh-smoke 崩溃:', error)
|
|
592
|
+
process.exit(1)
|
|
593
|
+
})
|