@hyzyn/dsh-tty 0.20.2 → 0.22.0-rc.1
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 +76 -4
- package/README.md +76 -6
- package/client.js +135 -36
- package/lib/index.d.ts +51 -0
- package/lib/index.js +507 -99
- package/lib/index.js.map +1 -1
- package/lib/probe.d.ts +29 -1
- package/lib/probe.js +98 -19
- package/lib/probe.js.map +1 -1
- package/lib/sftp.js +66 -22
- package/lib/sftp.js.map +1 -1
- package/lib/ssh-config.d.ts +34 -2
- package/lib/ssh-config.js +150 -32
- package/lib/ssh-config.js.map +1 -1
- package/lib/ssh.d.ts +256 -2
- package/lib/ssh.js +637 -6
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.d.ts +2 -0
- package/lib/tunnels.js +79 -7
- package/lib/tunnels.js.map +1 -1
- package/package.json +7 -5
- package/scripts/integration.mjs +51 -19
- package/scripts/jump-smoke.mjs +175 -0
- package/scripts/lib/proxy-bridge.mjs +30 -0
- package/scripts/preview/harness.js +74 -26
- package/scripts/probe-route-smoke.mjs +10 -1
- package/scripts/proxycommand-smoke.mjs +267 -0
- package/scripts/windows-smoke.mjs +23 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — 代理命令(ProxyCommand)的真机冒烟。
|
|
4
|
+
*
|
|
5
|
+
* 为什么必须真机跑一遍:`child_process.spawn → Duplex.from({readable, writable}) → ConnectConfig.sock`
|
|
6
|
+
* 这条链路的正确性单测证明不了——测试里只能断言「sock 是个 Duplex」;「stdin/stdout 真的把字节
|
|
7
|
+
* 送到了对端 sshd、目标真的在这条流上完成了握手、会话与 SFTP 都通、收尾真的没留下子进程」
|
|
8
|
+
* 只有真实现能给。这里刻意**不用系统 ssh / nc**(CI 上未必有、参数还各不相同),而是自己写一个
|
|
9
|
+
* 十几行的桥(`scripts/lib/proxy-bridge.mjs`):把 stdio 接到 `host port`——这正是
|
|
10
|
+
* `ssh -W %h:%p bastion` 的原语,于是 `%h` / `%p` 的展开也被一并验到。
|
|
11
|
+
*
|
|
12
|
+
* 用例:
|
|
13
|
+
* P1 终端会话经代理命令连上(spawnSsh:握手 + shell channel + 命令往返)
|
|
14
|
+
* P2 SFTP 经代理命令列目录(同一份传输被 sftp.ts 那条路径复用)
|
|
15
|
+
* P3 未获宿主授权 → 明确失败(文案点名环境变量),**且没有起进程**(即便「开关想开着」)
|
|
16
|
+
* P4 命令本身失败(桥不存在)→ 文案带上代理命令的失败事实(stderr 摘要),而不是笼统的目标超时
|
|
17
|
+
* P5 含 shell 特殊字符的主机名 → 拒绝代入(不执行任何命令)
|
|
18
|
+
* P6 收尾后没有残留的桥进程(子进程不许变常驻孤儿)
|
|
19
|
+
*
|
|
20
|
+
* 退出码:全部 PASS → 0;任一 FAIL → 1(已进 CI 的 dsh-tty 端到端那一档)。
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
23
|
+
import fsp from 'node:fs/promises'
|
|
24
|
+
import os from 'node:os'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { generateKeyPairSync } from 'node:crypto'
|
|
28
|
+
import ssh2 from 'ssh2'
|
|
29
|
+
import { SftpManager } from '../lib/sftp.js'
|
|
30
|
+
import { bindCapabilitySources, capabilityGranted } from '@hyzyn/dsh-kit'
|
|
31
|
+
import { setProxyCommandPolicy, spawnSsh } from '../lib/ssh.js'
|
|
32
|
+
import { startSftpSshd, TEST_PASSWORD, TEST_USER } from './lib/test-sshd.mjs'
|
|
33
|
+
|
|
34
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
35
|
+
const BRIDGE = path.join(here, 'lib', 'proxy-bridge.mjs')
|
|
36
|
+
|
|
37
|
+
let failed = 0
|
|
38
|
+
function pass(name) {
|
|
39
|
+
console.log(` ✔ PASS ${name}`)
|
|
40
|
+
}
|
|
41
|
+
function fail(name, detail) {
|
|
42
|
+
failed += 1
|
|
43
|
+
console.log(` ✘ FAIL ${name}${detail === undefined ? '' : ` — ${detail}`}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* 全局看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
console.error('[watchdog] 60s 看门狗触发:proxycommand-smoke 卡死')
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}, 60_000).unref?.()
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 数一数现在还活着几个桥进程(按命令行里的脚本路径匹配)。
|
|
54
|
+
*
|
|
55
|
+
* 返回 **-1 = 这个平台数不了**(Windows 没有 `ps`)。调用方必须把 -1 当「无法判定」而不是
|
|
56
|
+
* 0——否则「没有残留进程」在 Windows 上会变成一个**假通过**(第一次写这个脚本时就是这么比的)。
|
|
57
|
+
*/
|
|
58
|
+
function bridgeProcessCount() {
|
|
59
|
+
try {
|
|
60
|
+
const out = execFileSync('ps', ['-A', '-o', 'command'], { encoding: 'utf8' })
|
|
61
|
+
return out.split('\n').filter((line) => line.includes('proxy-bridge.mjs')).length
|
|
62
|
+
} catch {
|
|
63
|
+
return -1
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-proxycmd-'))
|
|
68
|
+
await fsp.writeFile(path.join(root, 'hello.txt'), 'via proxy command\n')
|
|
69
|
+
|
|
70
|
+
const target = await startSftpSshd({ rootDir: root })
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 最小「能开 shell」的内存 sshd(只做密码认证 + pty + 回一句标记)。
|
|
74
|
+
*
|
|
75
|
+
* 为什么要另一个服务端:`test-sshd.mjs` 是 SFTP 专用(不接 pty 请求),而终端那条路
|
|
76
|
+
* `spawnSsh` 一定要 pty(`shell channel 打开失败: Unable to request a pseudo-terminal`)。
|
|
77
|
+
* 这里只需要能证明「终端真的跑在代理命令的 stdio 上」与「收尾后子进程没了」。
|
|
78
|
+
*/
|
|
79
|
+
async function startShellSshd() {
|
|
80
|
+
const { privateKey } = generateKeyPairSync('rsa', {
|
|
81
|
+
modulusLength: 2048,
|
|
82
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
83
|
+
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
|
|
84
|
+
})
|
|
85
|
+
const server = new ssh2.Server({ hostKeys: [privateKey] }, (client) => {
|
|
86
|
+
client.on('authentication', (ctx) => {
|
|
87
|
+
if (ctx.method === 'password' && ctx.username === TEST_USER && ctx.password === TEST_PASSWORD) {
|
|
88
|
+
ctx.accept()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
ctx.reject()
|
|
92
|
+
})
|
|
93
|
+
client.on('ready', () => {
|
|
94
|
+
client.on('session', (accept) => {
|
|
95
|
+
const session = accept()
|
|
96
|
+
session.on('pty', (acceptPty) => acceptPty())
|
|
97
|
+
session.on('shell', (acceptShell) => {
|
|
98
|
+
const stream = acceptShell()
|
|
99
|
+
stream.on('data', (chunk) => {
|
|
100
|
+
// 极简伪 shell:收到 printf 就回一句可匹配标记(传输通了就够,不解释命令)
|
|
101
|
+
if (chunk.toString('utf8').includes('printf')) stream.write('PROXYCMD:OK\r\n')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
client.on('error', () => {
|
|
107
|
+
/* 断开:用例自己断言 */
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
await new Promise((resolve, reject) => {
|
|
111
|
+
server.once('error', reject)
|
|
112
|
+
server.listen(0, '127.0.0.1', () => resolve())
|
|
113
|
+
})
|
|
114
|
+
return {
|
|
115
|
+
port: server.address().port,
|
|
116
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const shellTarget = await startShellSshd()
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 代理命令串:`"<node>" "<bridge>" %h %p`。
|
|
124
|
+
*
|
|
125
|
+
* 用 JSON.stringify 包引号是为了两平台都对:POSIX 的 sh 与 Windows 的 cmd 都认双引号,
|
|
126
|
+
* 而 execPath 在 Windows 上常含空格(`C:\Program Files\nodejs\node.exe`)。
|
|
127
|
+
*/
|
|
128
|
+
const proxyCommand = `${JSON.stringify(process.execPath)} ${JSON.stringify(BRIDGE)} %h %p`
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 闸门的两个维度:`granted` 来自宿主侧环境变量(进程启动时采样一次,见 kit 的 capability.js),
|
|
132
|
+
* `enabled` 是界面上那个开关。这里**照插件宿主半体的做法**拼出同样的组合,而不是直接传 true
|
|
133
|
+
* ——否则冒烟就绕过了这一轮新加的那一层授权。
|
|
134
|
+
*/
|
|
135
|
+
const GRANT_ENV = 'DSH_TTY_ALLOW_PROXY_COMMAND'
|
|
136
|
+
const applyPolicy = (enabled) => setProxyCommandPolicy({ granted: capabilityGranted(GRANT_ENV), enabled })
|
|
137
|
+
const specOf = (extra = {}) => ({
|
|
138
|
+
host: '127.0.0.1',
|
|
139
|
+
port: target.port,
|
|
140
|
+
username: TEST_USER,
|
|
141
|
+
auth: 'password',
|
|
142
|
+
password: TEST_PASSWORD,
|
|
143
|
+
proxyCommand,
|
|
144
|
+
...extra,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/** 读一条 SSH 会话的输出直到匹配(或超时)。 */
|
|
148
|
+
async function readUntil(handle, pattern, timeoutMs = 8000) {
|
|
149
|
+
let text = ''
|
|
150
|
+
return await new Promise((resolve) => {
|
|
151
|
+
const timer = setTimeout(() => resolve({ text, matched: pattern.test(text) }), timeoutMs)
|
|
152
|
+
handle.output.on('data', (chunk) => {
|
|
153
|
+
text += chunk.toString('utf8')
|
|
154
|
+
if (pattern.test(text)) {
|
|
155
|
+
clearTimeout(timer)
|
|
156
|
+
resolve({ text, matched: true })
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// ---- P3 放在最前:**未获宿主授权**时不该起任何进程(先测这条,后面的用例才有干净的计数) ----
|
|
164
|
+
const before = bridgeProcessCount()
|
|
165
|
+
delete process.env[GRANT_ENV]
|
|
166
|
+
// 绑定 = 「这次宿主启动时带了什么环境」(见 kit 的 bindCapabilitySources):先改变量再绑定,顺序不能反
|
|
167
|
+
bindCapabilitySources(undefined)
|
|
168
|
+
applyPolicy(true) // 即便「开关想开着」,没有宿主授权也必须拒绝
|
|
169
|
+
try {
|
|
170
|
+
await new SftpManager({ info: () => {}, warn: () => {} }).list(specOf(), root)
|
|
171
|
+
fail('P3 闸门关着 → 明确失败', '竟然连上了')
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
175
|
+
const after = bridgeProcessCount()
|
|
176
|
+
if (message.includes('未获宿主授权') && message.includes(GRANT_ENV) && (after === -1 || after <= before)) {
|
|
177
|
+
const count = after === -1 ? '本平台数不了进程(无 ps)' : `桥进程 ${String(before)} → ${String(after)}`
|
|
178
|
+
pass(`P3 未授权 → 明确失败${after === -1 ? '(未起进程这一条跳过:' + count + ')' : `且没起进程(${count})`}`)
|
|
179
|
+
} else {
|
|
180
|
+
fail('P3 未授权 → 明确失败且没起进程', `msg=${message} before=${String(before)} after=${String(after)}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- P5:代入值含 shell 特殊字符 → 拒绝执行(连进程都不该起) ----
|
|
185
|
+
// 从这里开始模拟「宿主启动时就带了授权环境变量」:先设变量再绑定(顺序不能反)
|
|
186
|
+
process.env[GRANT_ENV] = '1'
|
|
187
|
+
bindCapabilitySources(undefined)
|
|
188
|
+
applyPolicy(true)
|
|
189
|
+
if (!capabilityGranted(GRANT_ENV)) fail('P3b 宿主侧授权生效', '设了环境变量却没被采到')
|
|
190
|
+
else pass('P3b 宿主侧授权生效(DSH_TTY_ALLOW_PROXY_COMMAND=1)')
|
|
191
|
+
try {
|
|
192
|
+
await new SftpManager({ info: () => {}, warn: () => {} }).list(specOf({ host: '127.0.0.1; touch /tmp/dsh-pwned' }), root)
|
|
193
|
+
fail('P5 主机名含 shell 特殊字符 → 拒绝代入', '竟然没报错')
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
196
|
+
if (message.includes('无法代入')) pass('P5 主机名含 shell 特殊字符 → 拒绝代入')
|
|
197
|
+
else fail('P5 主机名含 shell 特殊字符 → 拒绝代入', message)
|
|
198
|
+
}
|
|
199
|
+
const pwned = await fsp.access('/tmp/dsh-pwned').then(() => true).catch(() => false)
|
|
200
|
+
if (pwned) {
|
|
201
|
+
await fsp.rm('/tmp/dsh-pwned', { force: true })
|
|
202
|
+
fail('P5b 注入没有落地', '竟然真的创建了 /tmp/dsh-pwned')
|
|
203
|
+
} else {
|
|
204
|
+
pass('P5b 注入没有落地(/tmp/dsh-pwned 不存在)')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- P1:终端会话经代理命令连上,并在上面跑一条命令 ----
|
|
208
|
+
let session = null
|
|
209
|
+
try {
|
|
210
|
+
session = await spawnSsh(specOf({ port: shellTarget.port }), { term: 'xterm-256color', cols: 80, rows: 24, logger: { info: () => {}, warn: () => {} } })
|
|
211
|
+
session.write('printf "PROXYCMD:%s\\n" OK\n')
|
|
212
|
+
const { text } = await readUntil(session, /PROXYCMD:OK/)
|
|
213
|
+
if (text.includes('PROXYCMD:OK')) pass('P1 终端会话经代理命令连上并完成命令往返')
|
|
214
|
+
else fail('P1 终端会话经代理命令连上并完成命令往返', JSON.stringify(text.slice(-200)))
|
|
215
|
+
} catch (error) {
|
|
216
|
+
fail('P1 终端会话经代理命令连上并完成命令往返', error instanceof Error ? error.message : String(error))
|
|
217
|
+
} finally {
|
|
218
|
+
if (session !== null) await session.terminate().catch(() => {})
|
|
219
|
+
session?.forceKill?.()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- P2:SFTP 走同一条准备路径(prepareSshConnect) ----
|
|
223
|
+
const sftp = new SftpManager({ info: () => {}, warn: () => {} })
|
|
224
|
+
try {
|
|
225
|
+
const listed = await sftp.list(specOf(), root)
|
|
226
|
+
const names = listed.entries.map((entry) => entry.name)
|
|
227
|
+
if (names.includes('hello.txt')) pass(`P2 SFTP 经代理命令列目录(${String(names.length)} 项)`)
|
|
228
|
+
else fail('P2 SFTP 经代理命令列目录', JSON.stringify(names))
|
|
229
|
+
} catch (error) {
|
|
230
|
+
fail('P2 SFTP 经代理命令列目录', error instanceof Error ? error.message : String(error))
|
|
231
|
+
} finally {
|
|
232
|
+
sftp.disposeAll()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- P4:命令自己失败 → 文案点名「代理命令已退出」+ 退出码 ----
|
|
236
|
+
try {
|
|
237
|
+
const dial = new SftpManager({ info: () => {}, warn: () => {} })
|
|
238
|
+
await dial.list(specOf({ proxyCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(os.tmpdir(), 'definitely-missing-bridge.mjs'))} %h %p` }), root)
|
|
239
|
+
fail('P4 命令失败 → 文案点名代理命令', '竟然连上了')
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
242
|
+
/*
|
|
243
|
+
* 断言刻意宽松:子进程「结束输出」与「exit」相差 1~2ms,而 ssh2 一看流断了就报错——
|
|
244
|
+
* 谁先到不确定。两种时序都必须**至少**把 stderr 摘要交出来(那才是可排查的信息):
|
|
245
|
+
* - exit 先到 → 「代理命令已退出(退出码 1)…」;
|
|
246
|
+
* - 传输先关 → 「代理命令传输已关闭;stderr: …」。
|
|
247
|
+
*/
|
|
248
|
+
const named = message.includes('代理命令')
|
|
249
|
+
const explained = message.includes('Cannot find module') || message.includes('退出码')
|
|
250
|
+
if (named && explained) pass('P4 命令失败 → 文案带上代理命令的失败事实(stderr 摘要可见)')
|
|
251
|
+
else fail('P4 命令失败 → 文案带上代理命令的失败事实(stderr 摘要可见)', message)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---- P6:收尾后没有残留的桥进程 ----
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 400))
|
|
256
|
+
const leaked = bridgeProcessCount()
|
|
257
|
+
if (leaked === 0) pass('P6 收尾后没有残留的代理命令进程')
|
|
258
|
+
else if (leaked === -1) console.log(' ⊘ SKIP P6 收尾后没有残留的代理命令进程 — 本平台没有 ps,数不了')
|
|
259
|
+
else fail('P6 收尾后没有残留的代理命令进程', `ps 里还有 ${String(leaked)} 个 proxy-bridge.mjs`)
|
|
260
|
+
} finally {
|
|
261
|
+
await target.close().catch(() => {})
|
|
262
|
+
await shellTarget.close().catch(() => {})
|
|
263
|
+
await fsp.rm(root, { recursive: true, force: true }).catch(() => {})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.log(failed === 0 ? '\nproxycommand smoke: 全部 PASS' : `\nproxycommand smoke: ${String(failed)} 个 FAIL`)
|
|
267
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -178,4 +178,27 @@ const failed = RESULTS.filter((row) => row[0] === 'FAIL')
|
|
|
178
178
|
console.log('\n==== Windows 冒烟:' + String(RESULTS.length - failed.length) + '/' + String(RESULTS.length) + ' PASS ====')
|
|
179
179
|
for (const row of failed) console.error(' ✘ ' + row[1] + (row[2] ? ' — ' + row[2] : ''))
|
|
180
180
|
clearTimeout(watchdog)
|
|
181
|
+
/**
|
|
182
|
+
* 先把两条流排空再退(D62)。
|
|
183
|
+
*
|
|
184
|
+
* `process.exit()` 会**丢掉管道里还没 flush 的写**(Node 文档原话:尽快退出,不等
|
|
185
|
+
* 未完成的异步操作)。CI 上 stdout 正是管道,缓冲 64 KB:实测「写 300 KB 后直接
|
|
186
|
+
* exit」只活下来 65 536 字节。所以 2026-09-25 那次 CI 只留下
|
|
187
|
+
* `##[error]Process completed with exit code 1.` —— 五条断言全是 PASS、没有 ✘ 行、
|
|
188
|
+
* 也没有末尾的汇总行,失败原因整段被吞掉,只能靠拆发布记录反推"大概是哪一步"。
|
|
189
|
+
*
|
|
190
|
+
* 空串写入的回调在**前面那些写都落盘之后**才触发,等于一个 flush 屏障;拿到它再退,
|
|
191
|
+
* 输出就一定完整。仍然用 exit 而不是 `process.exitCode`(D121:主体结束后可能有
|
|
192
|
+
* 周期句柄漏着,不显式退就会挂死,而看门狗已经 clear 了)。
|
|
193
|
+
*/
|
|
194
|
+
await new Promise((resolve) => {
|
|
195
|
+
process.stdout.write('', () => {
|
|
196
|
+
resolve()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
await new Promise((resolve) => {
|
|
200
|
+
process.stderr.write('', () => {
|
|
201
|
+
resolve()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
181
204
|
process.exit(failed.length === 0 ? 0 : 1)
|