@hyzyn/dsh-tty 0.21.0 → 0.22.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +113 -10
- package/README.md +117 -10
- package/client.js +150 -36
- package/lib/index.d.ts +195 -25
- package/lib/index.js +930 -149
- 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 +37 -9
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +25 -1
- package/lib/shell-integration.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.js +47 -5
- package/lib/tunnels.js.map +1 -1
- package/package.json +4 -2
- package/scripts/integration.mjs +183 -20
- 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 +123 -1
|
@@ -24,6 +24,29 @@
|
|
|
24
24
|
const q = (sel) => document.querySelector(sel)
|
|
25
25
|
const qa = (sel) => [...document.querySelectorAll(sel)]
|
|
26
26
|
|
|
27
|
+
/*
|
|
28
|
+
* 预览语言:`?locale=en`(或 CDP 注入 `window.__previewLocale = 'en'`)预览英文界面,
|
|
29
|
+
* 默认 zh。与 search 的 mock-host / codegraph 的 preview-card 同一个开关形状
|
|
30
|
+
* (见 docs/i18n.md § 预览与 smoke 夹具)。夹具**提供**了 locale 服务,就必须能选语言,
|
|
31
|
+
* 否则它验的是一条不存在的路(「老宿主没有 locale 服务」那条路由中文兜底覆盖)。
|
|
32
|
+
*/
|
|
33
|
+
try {
|
|
34
|
+
const wanted = new URLSearchParams(location.search).get('locale')
|
|
35
|
+
if (wanted === 'en' || wanted === 'zh') window.__previewLocale = wanted
|
|
36
|
+
} catch {
|
|
37
|
+
/* file:// 下 location.search 可能不可用 */
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* 按**界面文案**找元素:文案已经进目录(i18n),所以定位串必须**双语共存**——只认中文
|
|
42
|
+
* 会把英文界面下的走查挡在门外(同 search 的 `titles: ['通用设置', 'General']`)。
|
|
43
|
+
* 三个谓词都收「任一语言」,断言的失败信息仍是中文(开发者看的)。
|
|
44
|
+
*/
|
|
45
|
+
const says = (el, ...names) => names.some((name) => (el.textContent || '').includes(name))
|
|
46
|
+
const saysExact = (el, ...names) => names.some((name) => (el.textContent || '').trim() === name)
|
|
47
|
+
const attributeSays = (el, attr, ...names) => names.some((name) => (el.getAttribute(attr) || '') === name)
|
|
48
|
+
const findByAria = (root, ...names) => [...root.querySelectorAll('[aria-label]')].find((el) => attributeSays(el, 'aria-label', ...names))
|
|
49
|
+
|
|
27
50
|
/* ---------- 挂载插件 ---------- */
|
|
28
51
|
// 场景之间共用同一个页面 target,必须先清掉上一个场景的标签持久化
|
|
29
52
|
try {
|
|
@@ -99,6 +122,30 @@
|
|
|
99
122
|
// 宿主会话服务也进服务表:docker 那一半是经 ctx.inject(['sessions']) 取的(夹具里
|
|
100
123
|
// ctx.sessions 是直接属性、不走服务表,这里补一份,免得那条 inject 永远等不到依赖)
|
|
101
124
|
services.set('sessions', ctx.sessions)
|
|
125
|
+
|
|
126
|
+
/*
|
|
127
|
+
* 假 locale 服务(`@deepseek-ai/dsh-client-locale`):插件经 `ctx.inject(['locale'], cb)`
|
|
128
|
+
* **动态**取它,所以服务表里没有它时 callback 不触发、`t` 保持中文兜底(= 老宿主那条路)。
|
|
129
|
+
* 桩的形状与 search 的 mock-host 一致:`register(ns, locale, dict)` + `bind(ns)`,
|
|
130
|
+
* 词条按 `window.__previewLocale` 选语言。
|
|
131
|
+
*/
|
|
132
|
+
const localeDicts = new Map()
|
|
133
|
+
const localeStub = {
|
|
134
|
+
register: (ns, locale, dict) => {
|
|
135
|
+
const entry = localeDicts.get(ns) || {}
|
|
136
|
+
entry[locale] = dict
|
|
137
|
+
localeDicts.set(ns, entry)
|
|
138
|
+
return () => {}
|
|
139
|
+
},
|
|
140
|
+
bind: (ns) => (key, params) => {
|
|
141
|
+
const active = window.__previewLocale === 'en' ? 'en' : 'zh'
|
|
142
|
+
const dict = localeDicts.get(ns) || {}
|
|
143
|
+
const text = (dict[active] || {})[key] || key
|
|
144
|
+
if (params === undefined) return text
|
|
145
|
+
return String(text).replace(/\{(\w+)\}/g, (_match, name) => (params[name] === undefined ? '' : String(params[name])))
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
services.set('locale', localeStub)
|
|
102
149
|
exports.apply(ctx)
|
|
103
150
|
|
|
104
151
|
/**
|
|
@@ -136,8 +183,9 @@
|
|
|
136
183
|
await waitFor(() => q('.tt_addMenu'))
|
|
137
184
|
await sleep(80)
|
|
138
185
|
}
|
|
139
|
-
|
|
140
|
-
|
|
186
|
+
// 菜单项按**双语**匹配(文案已进目录,见上面的 says)
|
|
187
|
+
const clickMenuItem = async (...labels) => {
|
|
188
|
+
const item = await waitFor(() => qa('.tt_addMenuItem').find((el) => says(el, ...labels)))
|
|
141
189
|
item.click()
|
|
142
190
|
await sleep(200)
|
|
143
191
|
return item
|
|
@@ -210,7 +258,7 @@
|
|
|
210
258
|
if (bar === null) return '状态条 DOM 缺失'
|
|
211
259
|
if (bar.hidden === true) return '状态条未显示(stats 订阅链路可能断开)'
|
|
212
260
|
const text = bar.textContent
|
|
213
|
-
if (!/CPU/.test(text) ||
|
|
261
|
+
if (!/CPU/.test(text) || !/(内存|Memory)/.test(text) || !/(网络|Network)/.test(text)) return '状态条内容不完整:' + text
|
|
214
262
|
/*
|
|
215
263
|
* 订阅时序:客户端**故意**在 spawn 之前就发一次 statsOn(switchTab 早于 spawnTab),
|
|
216
264
|
* 宿主此时还没登记这个 sid,会按「未知 sid」忽略——这是设计里的正常现象,所以
|
|
@@ -309,7 +357,7 @@
|
|
|
309
357
|
await clickMenuItem('staging-db')
|
|
310
358
|
await waitFor(() => tabs().length === 3)
|
|
311
359
|
await clickAdd()
|
|
312
|
-
await clickMenuItem('本地终端')
|
|
360
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
313
361
|
await waitFor(() => tabs().length === 4)
|
|
314
362
|
tabs()[1].click()
|
|
315
363
|
await sleep(250)
|
|
@@ -338,7 +386,7 @@
|
|
|
338
386
|
async ssh() {
|
|
339
387
|
await openPanel()
|
|
340
388
|
await clickAdd()
|
|
341
|
-
await clickMenuItem('SSH 连接…')
|
|
389
|
+
await clickMenuItem('SSH 连接…', 'SSH connection…')
|
|
342
390
|
await waitFor(() => q('.tt_sshCard'))
|
|
343
391
|
await sleep(150)
|
|
344
392
|
},
|
|
@@ -346,16 +394,16 @@
|
|
|
346
394
|
async 'ssh-probe'() {
|
|
347
395
|
await openPanel()
|
|
348
396
|
await clickAdd()
|
|
349
|
-
await clickMenuItem('SSH 连接…')
|
|
397
|
+
await clickMenuItem('SSH 连接…', 'SSH connection…')
|
|
350
398
|
await waitFor(() => q('.tt_sshCard'))
|
|
351
399
|
const inputs = qa('.tt_sshCard input')
|
|
352
400
|
inputs[0].value = '192.0.2.10'
|
|
353
401
|
inputs[1].value = '22'
|
|
354
402
|
inputs[2].value = 'root'
|
|
355
|
-
const probeBtn = qa('.tt_sshActions .tt_toolBtn').find((b) => b
|
|
403
|
+
const probeBtn = qa('.tt_sshActions .tt_toolBtn').find((b) => says(b, '试连', 'Test'))
|
|
356
404
|
probeBtn.click()
|
|
357
405
|
const resultEl = () => q('.tt_sshProbeResult')
|
|
358
|
-
await waitFor(() => resultEl() && resultEl().textContent !== '' && !resultEl()
|
|
406
|
+
await waitFor(() => resultEl() && resultEl().textContent !== '' && !says(resultEl(), '测试中', 'Testing'))
|
|
359
407
|
await sleep(250)
|
|
360
408
|
},
|
|
361
409
|
/* SSH 连接对话框(编辑连接簿条目,含 env 选择器) */
|
|
@@ -366,7 +414,7 @@
|
|
|
366
414
|
const r = qa('.tt_addMenuRow')
|
|
367
415
|
return r.length > 0 ? r : null
|
|
368
416
|
})
|
|
369
|
-
const editBtn = rows[1].
|
|
417
|
+
const editBtn = [...rows[1].querySelectorAll('.tt_addMenuEdit')].find((b) => attributeSays(b, 'title', '编辑连接', 'Edit connection'))
|
|
370
418
|
editBtn.click()
|
|
371
419
|
await waitFor(() => q('.tt_sshCard'))
|
|
372
420
|
await sleep(200)
|
|
@@ -407,7 +455,7 @@
|
|
|
407
455
|
// 「编辑」按钮是本次新增的能力:先在第一条隧道行上找到它
|
|
408
456
|
const firstRow = rowOf('staging-pg')
|
|
409
457
|
if (firstRow === undefined) throw new Error('找不到 staging-pg 行(fixture 应有该隧道)')
|
|
410
|
-
const editBtn = buttonsIn(firstRow).find((b) => b
|
|
458
|
+
const editBtn = buttonsIn(firstRow).find((b) => saysExact(b, '编辑', 'Edit'))
|
|
411
459
|
if (editBtn === undefined) throw new Error('隧道行没有「编辑」按钮')
|
|
412
460
|
editBtn.click()
|
|
413
461
|
await sleep(150)
|
|
@@ -421,7 +469,7 @@
|
|
|
421
469
|
await sleep(120)
|
|
422
470
|
|
|
423
471
|
// 回填:本地转发那边「127.0.0.1:<端口>」成对呈现,端口输入带 aria-label 标识
|
|
424
|
-
const localPortInput = document.querySelector('#preview-settings
|
|
472
|
+
const localPortInput = findByAria(document.querySelector('#preview-settings') || document, '本机监听端口', 'Local listen port')
|
|
425
473
|
if (localPortInput === null) throw new Error('本地转发表单没渲染(找不到本机监听端口输入)')
|
|
426
474
|
if (localPortInput.value !== '15432') throw new Error('编辑未回填本地端口:' + String(localPortInput.value))
|
|
427
475
|
|
|
@@ -444,7 +492,7 @@
|
|
|
444
492
|
|
|
445
493
|
// 切到「远程 -R」:固定端应换到**右侧**(本机拨号),左侧变成可填的服务器侧监听地址
|
|
446
494
|
// ——两个方向的固定端不同,这正是不能照搬单一形状的原因
|
|
447
|
-
segBtns.find((b) => (b
|
|
495
|
+
segBtns.find((b) => says(b, '远程 -R', 'Remote -R')).click()
|
|
448
496
|
await sleep(200)
|
|
449
497
|
scrollTunnelIntoView()
|
|
450
498
|
const remoteEndpoints = [...document.querySelectorAll('#preview-settings .tt_tunnelEndpoint')]
|
|
@@ -460,7 +508,7 @@
|
|
|
460
508
|
throw new Error('远程方向的箭头没有翻转标记')
|
|
461
509
|
}
|
|
462
510
|
// 切回本地 -L,后续断言按本地方向的期望继续
|
|
463
|
-
segBtns.find((b) => (b
|
|
511
|
+
segBtns.find((b) => says(b, '本地 -L', 'Local -L')).click()
|
|
464
512
|
await sleep(200)
|
|
465
513
|
scrollTunnelIntoView()
|
|
466
514
|
await sleep(80)
|
|
@@ -499,13 +547,13 @@
|
|
|
499
547
|
}
|
|
500
548
|
|
|
501
549
|
// 方向切换会重渲表单:DOM 节点可能被替换,重新取一次(别用切换前的引用)
|
|
502
|
-
const localPortInput2 = document.querySelector('#preview-settings
|
|
550
|
+
const localPortInput2 = findByAria(document.querySelector('#preview-settings') || document, '本机监听端口', 'Local listen port')
|
|
503
551
|
if (localPortInput2 === null) throw new Error('切回本地后表单没渲染')
|
|
504
552
|
|
|
505
553
|
const body = q('#preview-settings .tt_cardBody')
|
|
506
|
-
if (buttonsIn(body).find((b) => b
|
|
507
|
-
if (buttonsIn(body).find((b) => b
|
|
508
|
-
if (buttonsIn(body).find((b) => b
|
|
554
|
+
if (buttonsIn(body).find((b) => saysExact(b, '保存修改', 'Save changes')) === undefined) throw new Error('编辑态没有「保存修改」')
|
|
555
|
+
if (buttonsIn(body).find((b) => saysExact(b, '取消', 'Cancel')) === undefined) throw new Error('编辑态没有「取消」')
|
|
556
|
+
if (buttonsIn(body).find((b) => saysExact(b, '添加隧道', 'Add tunnel')) !== undefined) {
|
|
509
557
|
throw new Error('编辑态仍显示「添加隧道」(会误加一条而不是改这条)')
|
|
510
558
|
}
|
|
511
559
|
if (document.querySelectorAll('#preview-settings .tt_sshHostRow[data-editing]').length !== 1) {
|
|
@@ -530,7 +578,7 @@
|
|
|
530
578
|
}
|
|
531
579
|
setReactInput(localPortInput2, '15433')
|
|
532
580
|
await sleep(150)
|
|
533
|
-
buttonsIn(body).find((b) => b
|
|
581
|
+
buttonsIn(body).find((b) => saysExact(b, '保存修改', 'Save changes')).click()
|
|
534
582
|
await sleep(400)
|
|
535
583
|
|
|
536
584
|
window.__previewAssert = async () => {
|
|
@@ -546,7 +594,7 @@
|
|
|
546
594
|
if (!names.some((n) => n.startsWith('prod-redis'))) {
|
|
547
595
|
problems.push('编辑波及了别的隧道')
|
|
548
596
|
}
|
|
549
|
-
if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => b
|
|
597
|
+
if (buttonsIn(document.querySelector('#preview-settings .tt_cardBody')).find((b) => saysExact(b, '添加隧道', 'Add tunnel')) === undefined) {
|
|
550
598
|
problems.push('保存后没有退出编辑态')
|
|
551
599
|
}
|
|
552
600
|
return problems.length > 0 ? problems.join(';') : null
|
|
@@ -839,7 +887,7 @@
|
|
|
839
887
|
await clickMenuItem('staging-db')
|
|
840
888
|
await waitFor(() => tabs().length === 2)
|
|
841
889
|
await sleep(200)
|
|
842
|
-
const btn = qa('.tt_connAct').find((b) => b
|
|
890
|
+
const btn = qa('.tt_connAct').find((b) => says(b, '隧道', 'Tunnel'))
|
|
843
891
|
if (btn) btn.click()
|
|
844
892
|
await waitFor(() => q('.tt_tunnelPop'))
|
|
845
893
|
await sleep(300)
|
|
@@ -861,17 +909,17 @@
|
|
|
861
909
|
|
|
862
910
|
const acts = qa('.tt_connAct')
|
|
863
911
|
// 没有隧道 ⇒ 不该出现常驻「隧道 N」按钮
|
|
864
|
-
if (acts.some((b) => b
|
|
912
|
+
if (acts.some((b) => says(b, '隧道', 'Tunnel'))) {
|
|
865
913
|
throw new Error('没有隧道的连接却出现了常驻「隧道」按钮')
|
|
866
914
|
}
|
|
867
|
-
const more = acts.find((b) => b
|
|
915
|
+
const more = acts.find((b) => says(b, '更多', 'More'))
|
|
868
916
|
if (more === undefined) throw new Error('没有隧道的连接缺少「⋯ 更多」入口')
|
|
869
917
|
more.click()
|
|
870
918
|
await waitFor(() => q('.tt_connMore'))
|
|
871
919
|
await sleep(250)
|
|
872
920
|
|
|
873
921
|
// 闭环:菜单项必须真的可点、并给出可执行的信息(该连接没有启用隧道 + 去哪配)
|
|
874
|
-
const item = qa('.tt_connMore .tt_addMenuItem').find((el) => el
|
|
922
|
+
const item = qa('.tt_connMore .tt_addMenuItem').find((el) => says(el, '端口转发', 'Port forwarding'))
|
|
875
923
|
if (item === undefined) throw new Error('「⋯」菜单里没有端口转发项')
|
|
876
924
|
item.click()
|
|
877
925
|
await waitFor(() => q('.tt_tunnelPop'))
|
|
@@ -880,8 +928,8 @@
|
|
|
880
928
|
const pop = q('.tt_tunnelPop')
|
|
881
929
|
if (pop === null) return '点击菜单项后没有打开隧道弹层'
|
|
882
930
|
const text = pop.textContent ?? ''
|
|
883
|
-
if (!text.includes('暂无启用')) return '弹层没有说明「该连接暂无启用的隧道」:' + text
|
|
884
|
-
if (
|
|
931
|
+
if (!text.includes('暂无启用') && !text.includes('No enabled tunnels')) return '弹层没有说明「该连接暂无启用的隧道」:' + text
|
|
932
|
+
if (!/插件配置|设置|Settings/.test(text)) return '弹层没有告诉用户去哪配置'
|
|
885
933
|
return null
|
|
886
934
|
}
|
|
887
935
|
},
|
|
@@ -1081,7 +1129,7 @@
|
|
|
1081
1129
|
await openPanel()
|
|
1082
1130
|
window.__PREVIEW_AT_LIMIT = true
|
|
1083
1131
|
await clickAdd()
|
|
1084
|
-
await clickMenuItem('本地终端')
|
|
1132
|
+
await clickMenuItem('本地终端', 'Local terminal')
|
|
1085
1133
|
await waitFor(() => q('.tt_toast'))
|
|
1086
1134
|
await sleep(200)
|
|
1087
1135
|
},
|
|
@@ -13,6 +13,15 @@ import net from 'node:net'
|
|
|
13
13
|
import { name, inject, apply } from '../lib/index.js'
|
|
14
14
|
import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
|
|
15
15
|
|
|
16
|
+
/*
|
|
17
|
+
* 变更端点要求「同源证明」(docker D32 口径,见 docs/architecture.md § 7 与
|
|
18
|
+
* packages/tty/src/index.ts 的 gateRoute):浏览器同源 fetch 必带
|
|
19
|
+
* `sec-fetch-site: same-origin`——本脚本模拟的正是**面板**这条调用路径,所以照带。
|
|
20
|
+
* 少了它,/probe 与 /sftp 的写动作会被 403「缺少同源证明」(本脚本此前就踩在这里)。
|
|
21
|
+
*/
|
|
22
|
+
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' }
|
|
23
|
+
|
|
24
|
+
|
|
16
25
|
const RESULTS = []
|
|
17
26
|
function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
|
|
18
27
|
function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
|
|
@@ -44,7 +53,7 @@ async function run() {
|
|
|
44
53
|
const probe = async (body) => {
|
|
45
54
|
const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/probe`, {
|
|
46
55
|
method: 'POST',
|
|
47
|
-
headers: { 'content-type': 'application/json' },
|
|
56
|
+
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
|
|
48
57
|
body: JSON.stringify(body),
|
|
49
58
|
})
|
|
50
59
|
return res.json()
|
|
@@ -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)
|