@hyzyn/dsh-docker 0.9.6 → 0.11.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 +106 -15
- package/README.md +97 -10
- package/client-src/docker.css +26 -0
- package/client-src/elevation.js +163 -0
- package/client-src/index.js +401 -125
- package/client.js +50 -23
- package/lib/audit.d.ts +106 -0
- package/lib/audit.js +140 -0
- package/lib/audit.js.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +94 -16
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.js +27 -10
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +260 -24
- package/scripts/route-smoke.mjs +143 -1
package/scripts/client-smoke.mjs
CHANGED
|
@@ -1451,43 +1451,51 @@ await test('聚合选择:入口 / 勾选框 / 操作条装配进 bundle', () =
|
|
|
1451
1451
|
assert.ok(decoded.includes('已选 '), '缺少「已选 N 个容器」计数')
|
|
1452
1452
|
assert.ok(decoded.includes('聚合日志 · '), '缺少聚合视图标题')
|
|
1453
1453
|
assert.ok(decoded.includes('至少选择 2 个容器'), '缺少 N<2 的提示')
|
|
1454
|
-
assert.ok(decoded.includes('
|
|
1455
|
-
// 硬上限文案由「数字 +
|
|
1456
|
-
//
|
|
1454
|
+
assert.ok(decoded.includes('已接近上限,再选下去整个页面会变卡'), '缺少软上限提示')
|
|
1455
|
+
// 硬上限文案由「数字 + 后缀」拼出来(数字不写死),所以这里只锁静态部分;
|
|
1456
|
+
// 具体数字由下面 pickDecide 的用例断言(它同时钉住「数字从哪来」)。
|
|
1457
1457
|
assert.ok(decoded.includes('最多 '), '缺少硬上限的「最多」前缀')
|
|
1458
1458
|
assert.ok(decoded.includes('个容器'), '缺少硬上限的「个容器」文案')
|
|
1459
|
-
assert.ok(decoded.includes('
|
|
1460
|
-
|
|
1459
|
+
assert.ok(decoded.includes('会占满浏览器的同源连接,整个页面都会卡'), '缺少硬上限的成因后缀')
|
|
1460
|
+
// 上限的来由要写在选择条上(D165):不写清楚,3 看起来就只是个随手定的数字
|
|
1461
|
+
assert.ok(decoded.includes('浏览器每个站点最多同时保持 4 条实时流'), '缺少「预算从哪来」的说明')
|
|
1462
|
+
assert.ok(code.includes('dk_pickBudget'), '缺少预算说明的样式钩子')
|
|
1463
|
+
// 接线判据走源码级:局部标识符会被压缩改名,产物里 `budget: t(` 认不出来
|
|
1464
|
+
const pickSource = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
1465
|
+
assert.ok(/budget: t\('hint\.pickBudget'\)/.test(pickSource), '预算说明必须真的接到操作条上')
|
|
1466
|
+
assert.ok(/props\.budget === undefined \? null/.test(pickSource), '操作条要渲染 budget(拿到不渲染等于没接)')
|
|
1461
1467
|
assert.ok(decoded.includes('Escape'), '缺少 Esc 退出选择态')
|
|
1462
1468
|
assert.ok(code.includes('dk_pick'), '缺少勾选框样式钩子')
|
|
1463
1469
|
assert.ok(code.includes('dk_pickBar'), '缺少聚合操作条样式钩子')
|
|
1464
1470
|
assert.ok(code.includes('dk_pillPick'), '缺少「聚合选择」激活态样式钩子')
|
|
1465
1471
|
})
|
|
1466
1472
|
|
|
1467
|
-
await test('
|
|
1473
|
+
await test('聚合选择:上限绑在浏览器同源连接预算上(4 条流 − 事件流 = 3)', () => {
|
|
1468
1474
|
const pick = pickApi()
|
|
1469
|
-
|
|
1470
|
-
|
|
1475
|
+
/*
|
|
1476
|
+
* D165(2026-10-08 真机实测):宿主是 HTTP/1.1,每条 EventSource 独占一条同源
|
|
1477
|
+
* 连接且不释放。实测常驻流 4 条 → 页内请求 6/6 成功(中位 29ms);5 条 → 0/6 全部超时
|
|
1478
|
+
* (同时刻进程外请求 41ms 正常,堵在浏览器不在宿主)。容器页常开着事件流,所以聚合
|
|
1479
|
+
* 只能用掉剩下的 3 条。这条用例把「数字从哪来」钉住,免得后人凭手感调大。
|
|
1480
|
+
*/
|
|
1481
|
+
assert.equal(pick.MAX, 3)
|
|
1482
|
+
assert.equal(pick.SSH_MAX, 3, 'SSH 与本地共用同一个浏览器预算(卡页面的是浏览器,不是 MaxSessions)')
|
|
1483
|
+
assert.equal(pick.SOFT_MAX, 2)
|
|
1484
|
+
assert.equal(pick.BUDGET, 4, '同源预算:4 条常驻流')
|
|
1485
|
+
assert.equal(pick.RESERVED, 1, '其中 1 条留给活动条的事件流')
|
|
1471
1486
|
assert.deepEqual(pick.decide(0), { canRun: false, hint: '' })
|
|
1472
1487
|
assert.deepEqual(pick.decide(1), { canRun: false, hint: '至少选择 2 个容器' })
|
|
1473
1488
|
assert.deepEqual(pick.decide(2), { canRun: true, hint: '' })
|
|
1474
|
-
|
|
1475
|
-
assert.
|
|
1476
|
-
assert.
|
|
1477
|
-
const over = pick.decide(
|
|
1489
|
+
// 第 3 个已经在吃满预算前的最后一格:可点,但给一句软提示
|
|
1490
|
+
assert.equal(pick.decide(3).canRun, true)
|
|
1491
|
+
assert.match(pick.decide(3).hint, /整个页面会变卡/)
|
|
1492
|
+
const over = pick.decide(4)
|
|
1478
1493
|
assert.equal(over.canRun, false, '超过硬上限必须置灰')
|
|
1479
|
-
assert.match(over.hint, /最多
|
|
1494
|
+
assert.match(over.hint, /最多 3 个容器/)
|
|
1480
1495
|
|
|
1481
|
-
// SSH
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
assert.equal(pick.SSH_MAX, 6)
|
|
1485
|
-
assert.deepEqual(pick.decide(6, true), { canRun: true, hint: '' })
|
|
1486
|
-
const sshOver = pick.decide(7, true)
|
|
1487
|
-
assert.equal(sshOver.canRun, false, 'SSH 目标上 7 个容器必须置灰')
|
|
1488
|
-
assert.match(sshOver.hint, /最多 6 个容器/)
|
|
1489
|
-
assert.match(sshOver.hint, /SSH 目标/, '提示必须说明 SSH 上为什么更紧')
|
|
1490
|
-
assert.equal(pick.decide(7, false).canRun, true, '本地目标不受 SSH 上限影响')
|
|
1496
|
+
// 两个目标类型走同一套上限:真正卡住页面的是浏览器同源预算,与目标是 SSH 还是本机无关
|
|
1497
|
+
assert.deepEqual(pick.decide(4, true).canRun, false, 'SSH 目标同样在 4 个时置灰')
|
|
1498
|
+
assert.match(pick.decide(4, true).hint, /最多 3 个容器/)
|
|
1491
1499
|
})
|
|
1492
1500
|
|
|
1493
1501
|
await test('聚合选择:勾选增删保序 + 列表刷新按 id 对账', () => {
|
|
@@ -1606,7 +1614,7 @@ await test('事件流断开:状态判定只认 closed,unsupported 不给重
|
|
|
1606
1614
|
await test('事件流断开:活动条接线(兄弟按钮、不是嵌套按钮)与 ⟳ 接手动刷新', () => {
|
|
1607
1615
|
const decoded = decodeBundle(code)
|
|
1608
1616
|
assert.ok(decoded.includes('重连'), '缺少重连按钮文案')
|
|
1609
|
-
assert.ok(decoded.includes('
|
|
1617
|
+
assert.ok(decoded.includes('不会自动恢复'), '缺少「不会自愈」的说明')
|
|
1610
1618
|
assert.ok(code.includes('dk_activityHeadRow'), '缺少头行容器(兄弟布局的前提)')
|
|
1611
1619
|
assert.ok(code.includes('dk_activityRetry'), '缺少重连按钮样式钩子')
|
|
1612
1620
|
|
|
@@ -1653,6 +1661,45 @@ await test('事件流断开:活动条接线(兄弟按钮、不是嵌套按
|
|
|
1653
1661
|
'重连补偿的判据必须含 manualReconnect(否则从未 open 过的流重连后列表不刷新)')
|
|
1654
1662
|
})
|
|
1655
1663
|
|
|
1664
|
+
/*
|
|
1665
|
+
* D164:服务端**拒绝了**这条流时,「重连」看起来毫无反应——真机现场(2026-10-08):
|
|
1666
|
+
* 248 上长流配额恒满 8/8,每次点重连都被拒;而客户端把 `event: error` 的 data 整个丢掉,
|
|
1667
|
+
* 只置 `closed`,于是界面与点击前逐像素相同。统计流一直是读出 message 的,这里补齐。
|
|
1668
|
+
*/
|
|
1669
|
+
await test('D164:事件流的 error 帧要解出服务端原因(而不是丢掉)', () => {
|
|
1670
|
+
const api = eventsApi()
|
|
1671
|
+
assert.ok(api.errorMessage !== undefined, '缺少 __events.errorMessage 测试缝')
|
|
1672
|
+
// 配额拒绝:必须原样带出那句可执行的文案
|
|
1673
|
+
const refused = api.errorMessage({ data: JSON.stringify({ message: '目标 248 上已有 8 条实时流(上限 8)' }) })
|
|
1674
|
+
assert.equal(refused, '目标 248 上已有 8 条实时流(上限 8)')
|
|
1675
|
+
// 非 JSON 的 data:整段当原因(宁可显示原文,也别显示「不知道」)
|
|
1676
|
+
assert.equal(api.errorMessage({ data: 'boom' }), 'boom')
|
|
1677
|
+
// 连接层错误(无 data):不是「服务端说了什么」,必须返回空串——
|
|
1678
|
+
// 那种情况 EventSource 会自己重连,不能冒充成一次被拒绝的重连
|
|
1679
|
+
assert.equal(api.errorMessage({}), '')
|
|
1680
|
+
assert.equal(api.errorMessage({ data: '' }), '')
|
|
1681
|
+
assert.equal(api.errorMessage(new Event('error')), '')
|
|
1682
|
+
assert.equal(api.errorMessage(null), '')
|
|
1683
|
+
assert.equal(api.errorMessage(undefined), '')
|
|
1684
|
+
// 畸形 JSON 不许把半截载荷当文案漏出去
|
|
1685
|
+
assert.equal(api.errorMessage({ data: '{"message":' }), '{"message":')
|
|
1686
|
+
// message 不是字符串时退回原文,而不是渲染出 [object Object]
|
|
1687
|
+
assert.equal(api.errorMessage({ data: JSON.stringify({ message: 42 }) }), '{"message":42}')
|
|
1688
|
+
})
|
|
1689
|
+
|
|
1690
|
+
await test('D164:被拒原因要接到界面上(接线判据)', () => {
|
|
1691
|
+
const decoded = decodeBundle(code)
|
|
1692
|
+
assert.ok(decoded.includes('重连被拒'), '缺少「被拒绝」的提示文案')
|
|
1693
|
+
assert.ok(decoded.includes('不会自动恢复'), '原有的「不会自愈」说明必须保留')
|
|
1694
|
+
const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
1695
|
+
// 拒绝原因必须进 state,并作为 props 传给活动条(只解出来不传等于没修)
|
|
1696
|
+
assert.ok(/setEventsRefused\(message\)/.test(source), 'error 帧的原因必须写进 state')
|
|
1697
|
+
assert.ok(/refused: eventsRefused/.test(source), '活动条必须拿到被拒原因')
|
|
1698
|
+
assert.ok(/hint\.eventsReconnectRefused/.test(source), '活动条必须渲染被拒文案')
|
|
1699
|
+
// 重连成功要清掉(否则「上次被拒」会一直挂着,看着像现在还坏着)
|
|
1700
|
+
assert.ok(/setEventsRefused\(''\)/.test(source), '重连成功 / 重新建流时必须清掉上一条原因')
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1656
1703
|
await test('变更操作执行中态:按钮转圈 + 卡片锁组 + 确认框不关', () => {
|
|
1657
1704
|
// 三处过渡态钩子:按钮 data-busy(图标换转圈)、卡片 data-pending(整组变更按钮压暗锁住)、
|
|
1658
1705
|
// 确认框 k_confirmBusy(保持打开 + 「执行中…」)。真实点击路径需要浏览器,这里锁契约。
|
|
@@ -2419,6 +2466,195 @@ await test('连接本机:下拉项接的是 connectLocal(哨兵被拦在换
|
|
|
2419
2466
|
assert.ok(/return/.test(branch), '哨兵分支必须 return,不能继续走换目标')
|
|
2420
2467
|
})
|
|
2421
2468
|
|
|
2469
|
+
/* ------------------------------------------------------------------ *
|
|
2470
|
+
* 就地提权:多能力 pending → 一条命令解锁全部(一次粘贴)
|
|
2471
|
+
* ------------------------------------------------------------------ */
|
|
2472
|
+
|
|
2473
|
+
/**
|
|
2474
|
+
* 为什么这一组用例必须存在:改前 `elevation` 是**单槽** state(`setElevation({capability})`),
|
|
2475
|
+
* 面板开着时再点另一个开关会把前一个能力的面板**替换**掉、它的命令从界面消失(宿主侧 challenge
|
|
2476
|
+
* 仍在 pending)。2026-09-26 的设置卡片截图实测:两次授权时刻差 14 秒——用户必须在宿主终端
|
|
2477
|
+
* **粘贴两次**。逐能力记录 + 合并复制修掉的就是这件事:两个开关各点一次,界面上同时有两块面板
|
|
2478
|
+
* 与一个「复制全部」,终端**粘贴一次**两行。
|
|
2479
|
+
*
|
|
2480
|
+
* 断言打在**渲染树**与**按钮交下去的字节**上(不只是纯函数):纯函数对而接线错(按钮接错回调、
|
|
2481
|
+
* 只复制了第一条、顺序反了)在真机上才暴露,正是离线冒烟要拦的那一类。
|
|
2482
|
+
*/
|
|
2483
|
+
function elevationApi() {
|
|
2484
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
2485
|
+
assert.ok(exports_.__elevation !== undefined, '缺少 __elevation 测试缝')
|
|
2486
|
+
return exports_.__elevation
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/** 面板组的渲染:回调用桩记录,配置值默认都关着(与「开关还没打开」的真实初态一致)。 */
|
|
2490
|
+
function renderElevationPanels(records, overrides = {}) {
|
|
2491
|
+
const api = elevationApi()
|
|
2492
|
+
const copied = []
|
|
2493
|
+
const closed = []
|
|
2494
|
+
const regenerated = []
|
|
2495
|
+
const tree = api.view(records, {
|
|
2496
|
+
now: overrides.now ?? 1_700_000_000_000,
|
|
2497
|
+
copiedKey: overrides.copiedKey ?? '',
|
|
2498
|
+
configured: overrides.configured ?? (() => false),
|
|
2499
|
+
onCopy: (capability, command) => copied.push({ capability, command }),
|
|
2500
|
+
onCopyAll: (text) => copied.push({ capability: api.COPY_ALL_KEY, command: text }),
|
|
2501
|
+
onRegenerate: (capability) => regenerated.push(capability),
|
|
2502
|
+
onClose: (capability) => closed.push(capability),
|
|
2503
|
+
onDisableFirst: () => {},
|
|
2504
|
+
})
|
|
2505
|
+
return { api, tree, copied, closed, regenerated }
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
/** 合并按钮:从渲染树里找(而不是读入参),保证「界面上真的有」这件事被测到。 */
|
|
2509
|
+
function mergeButtonOf(tree) {
|
|
2510
|
+
return treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copyAll')[0]
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
await test('就地提权:两个 pending → 合并按钮复制的内容 = 两条命令按发起顺序的换行连接(逐字节)', () => {
|
|
2514
|
+
const api = elevationApi()
|
|
2515
|
+
const expiresAt = 1_700_000_000_000 + 300_000
|
|
2516
|
+
// 发起顺序:先点 allowMutations,再点 allowExec(数组顺序就是点击顺序)
|
|
2517
|
+
const records = [
|
|
2518
|
+
{ capability: 'allowMutations', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/aaa'", expiresAt },
|
|
2519
|
+
{ capability: 'allowExec', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/bbb'", expiresAt },
|
|
2520
|
+
]
|
|
2521
|
+
const { tree, copied } = renderElevationPanels(records)
|
|
2522
|
+
const button = mergeButtonOf(tree)
|
|
2523
|
+
assert.ok(button !== undefined, '两个 pending 时必须出「复制全部」按钮')
|
|
2524
|
+
|
|
2525
|
+
/*
|
|
2526
|
+
* 驱动按钮的 onClick(真跑一次「点了会怎样」):断言的是**交给复制动作的那个字符串**
|
|
2527
|
+
* (桩里没有 navigator,剪贴板本身是浏览器的事)——内容错(少一条 / 顺序反 / 用 && 连接)
|
|
2528
|
+
* 才是本用例要拦的。
|
|
2529
|
+
*/
|
|
2530
|
+
button.props.onClick()
|
|
2531
|
+
assert.equal(copied.length, 1, '点一次合并按钮应恰好触发一次复制')
|
|
2532
|
+
const text = copied[0].command
|
|
2533
|
+
const expected = records[0].command + '\n' + records[1].command
|
|
2534
|
+
assert.equal(text, expected, '合并内容必须逐字节等于两条命令的换行连接')
|
|
2535
|
+
assert.equal(
|
|
2536
|
+
Buffer.from(text, 'utf8').toString('base64'),
|
|
2537
|
+
Buffer.from(expected, 'utf8').toString('base64'),
|
|
2538
|
+
'按字节比对(换行符形态也是契约)',
|
|
2539
|
+
)
|
|
2540
|
+
assert.equal(text.split('\n').length, 2, '两条命令 = 两行')
|
|
2541
|
+
assert.ok(!text.includes('&&'), '不得用 && 连接(PS 5.1 不认)')
|
|
2542
|
+
assert.ok(!text.includes('; '), '不得用 ; 连接(cmd 不认)')
|
|
2543
|
+
assert.ok(text.indexOf(records[0].command) < text.indexOf(records[1].command), '顺序必须是发起顺序(先点的在前)')
|
|
2544
|
+
assert.equal(copied[0].capability, api.COPY_ALL_KEY, '合并复制的反馈键必须与单条按钮隔离')
|
|
2545
|
+
|
|
2546
|
+
// 每块面板自己的单条复制按钮仍在(用户仍可只复制一条),且只复制自己那条
|
|
2547
|
+
const singleButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy')
|
|
2548
|
+
assert.equal(singleButtons.length, 2, '两块面板各自保留单条复制按钮')
|
|
2549
|
+
singleButtons[0].props.onClick()
|
|
2550
|
+
assert.equal(copied[1].command, records[0].command, '单条复制只复制自己那条命令')
|
|
2551
|
+
assert.equal(copied[1].capability, 'allowMutations', '单条复制的反馈键是能力名(与合并按钮互不串)')
|
|
2552
|
+
})
|
|
2553
|
+
|
|
2554
|
+
await test('就地提权:单个 pending → 无合并按钮(那条命令就在自己的面板里)', () => {
|
|
2555
|
+
const expiresAt = 1_700_000_000_000 + 300_000
|
|
2556
|
+
const single = [{ capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt }]
|
|
2557
|
+
const { tree, api } = renderElevationPanels(single)
|
|
2558
|
+
assert.equal(mergeButtonOf(tree), undefined, '只点过一个开关时不该出现「复制全部(1 条)」')
|
|
2559
|
+
// 反面判据:纯判定同样为假(防止某天有人把「≥2」放宽成「≥1」而用例只看渲染树)
|
|
2560
|
+
assert.equal(api.shouldOfferMerge(single, 1_700_000_000_000), false)
|
|
2561
|
+
// 界面结构不变形:一块面板 + 一个单条复制按钮
|
|
2562
|
+
assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1)
|
|
2563
|
+
assert.equal(treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy').length, 1)
|
|
2564
|
+
})
|
|
2565
|
+
|
|
2566
|
+
await test('就地提权:一个 granted 后 → 合并按钮只剩剩下那一条(组里还有别的能力没解锁)', () => {
|
|
2567
|
+
const api = elevationApi()
|
|
2568
|
+
const expiresAt = 1_700_000_000_000 + 300_000
|
|
2569
|
+
const two = [
|
|
2570
|
+
{ capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
|
|
2571
|
+
{ capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
|
|
2572
|
+
]
|
|
2573
|
+
// allowMutations 先解锁:记录留痕(面板消失),命令清掉
|
|
2574
|
+
const afterGrant = api.markGranted(two, 'allowMutations')
|
|
2575
|
+
assert.equal(api.shouldOfferMerge(afterGrant, 1_700_000_000_000), true, '还有一条没解锁时按钮要留着')
|
|
2576
|
+
assert.deepEqual(api.merge(afterGrant, 1_700_000_000_000), ["touch '/tmp/b'"], '只剩仍 pending 的那一条')
|
|
2577
|
+
assert.equal(api.text(afterGrant, 1_700_000_000_000), "touch '/tmp/b'", '合并内容退化成那一行(没有多余换行)')
|
|
2578
|
+
|
|
2579
|
+
const { tree, copied } = renderElevationPanels(afterGrant)
|
|
2580
|
+
const button = mergeButtonOf(tree)
|
|
2581
|
+
assert.ok(button !== undefined, '一个 granted 后合并按钮仍在')
|
|
2582
|
+
button.props.onClick()
|
|
2583
|
+
assert.equal(copied[0].command, "touch '/tmp/b'", '按钮此刻只复制仍 pending 的那条')
|
|
2584
|
+
// 已授权的那块面板消失,仍 pending 的那块还在
|
|
2585
|
+
assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1, '已授权的面板必须消失')
|
|
2586
|
+
|
|
2587
|
+
// 两条都解锁 → 组里没有可复制命令:留痕被收掉,按钮自然消失
|
|
2588
|
+
const allGranted = api.markGranted(afterGrant, 'allowExec')
|
|
2589
|
+
assert.deepEqual(allGranted, [], '全部解锁后整组清空(含留痕)')
|
|
2590
|
+
assert.equal(
|
|
2591
|
+
api.view(allGranted, { copiedKey: '', configured: () => false, onCopy() {}, onCopyAll() {}, onRegenerate() {}, onClose() {}, onDisableFirst() {} }),
|
|
2592
|
+
null,
|
|
2593
|
+
'全部结束后面板组不渲染',
|
|
2594
|
+
)
|
|
2595
|
+
})
|
|
2596
|
+
|
|
2597
|
+
await test('就地提权:过期的那条不进合并(复制过去只会让宿主落一个过期后才出现的文件)', () => {
|
|
2598
|
+
const api = elevationApi()
|
|
2599
|
+
const now = 1_700_000_000_000
|
|
2600
|
+
const records = [
|
|
2601
|
+
{ capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt: now - 1 },
|
|
2602
|
+
{ capability: 'allowExec', command: "touch '/tmp/b'", expiresAt: now + 300_000 },
|
|
2603
|
+
]
|
|
2604
|
+
assert.deepEqual(api.merge(records, now), ["touch '/tmp/b'"], '过期的从合并里剔掉')
|
|
2605
|
+
const { tree, copied } = renderElevationPanels(records, { now })
|
|
2606
|
+
const button = mergeButtonOf(tree)
|
|
2607
|
+
assert.ok(button !== undefined, '组里仍有两条记录时按钮在(只是内容少了一条)')
|
|
2608
|
+
button.props.onClick()
|
|
2609
|
+
assert.equal(copied[0].command, "touch '/tmp/b'", '合并按钮只复制仍 pending 的那些')
|
|
2610
|
+
// 两条都过期 → 没有可复制的:按钮收起(不再承诺一个不可能发生的解锁)
|
|
2611
|
+
const bothExpired = records.map((record) => ({ ...record, expiresAt: now - 1 }))
|
|
2612
|
+
assert.equal(api.shouldOfferMerge(bothExpired, now), false)
|
|
2613
|
+
})
|
|
2614
|
+
|
|
2615
|
+
await test('就地提权:✕ 只关一个能力的面板,另一个照常解锁(不撤销宿主 challenge)', () => {
|
|
2616
|
+
const api = elevationApi()
|
|
2617
|
+
const expiresAt = 1_700_000_000_000 + 300_000
|
|
2618
|
+
const records = [
|
|
2619
|
+
{ capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
|
|
2620
|
+
{ capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
|
|
2621
|
+
]
|
|
2622
|
+
const { tree, closed } = renderElevationPanels(records)
|
|
2623
|
+
const closeButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'close')
|
|
2624
|
+
assert.equal(closeButtons.length, 2, '每块面板各有自己的收起按钮')
|
|
2625
|
+
closeButtons[0].props.onClick()
|
|
2626
|
+
assert.deepEqual(closed, ['allowMutations'], '✕ 只收起被点的那一个能力')
|
|
2627
|
+
// 关掉 A 之后 B 照常解锁:记录集里仍留着 B,合并内容只剩 B 那条
|
|
2628
|
+
const afterClose = api.remove(records, 'allowMutations')
|
|
2629
|
+
assert.deepEqual(api.merge(afterClose, 1_700_000_000_000), ["touch '/tmp/b'"])
|
|
2630
|
+
assert.equal(api.shouldOfferMerge(afterClose, 1_700_000_000_000), false, '只剩一个 pending 时合并按钮收起')
|
|
2631
|
+
})
|
|
2632
|
+
|
|
2633
|
+
await test('就地提权:两条命令各自独立——一条出错 / 在途不影响另一条的合并', () => {
|
|
2634
|
+
const api = elevationApi()
|
|
2635
|
+
const expiresAt = 1_700_000_000_000 + 300_000
|
|
2636
|
+
const records = [
|
|
2637
|
+
{ capability: 'allowMutations', error: 'HTTP 429' },
|
|
2638
|
+
{ capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
|
|
2639
|
+
]
|
|
2640
|
+
assert.deepEqual(api.merge(records, 1_700_000_000_000), ["touch '/tmp/b'"], '出错那条没有命令,不进合并')
|
|
2641
|
+
assert.equal(api.shouldOfferMerge(records, 1_700_000_000_000), false, '只有一条可复制时不出按钮')
|
|
2642
|
+
// 在途(begin 还没回)同样不参与合并
|
|
2643
|
+
assert.deepEqual(api.merge([{ capability: 'allowExec' }], 1_700_000_000_000), [])
|
|
2644
|
+
})
|
|
2645
|
+
|
|
2646
|
+
await test('就地提权:i18n 双份齐(新增键 zh/en 都在,且都进了产物)', () => {
|
|
2647
|
+
const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
2648
|
+
for (const key of ['elev.copyAll', 'elev.copyAllHint', 'elev.copyAllCopied']) {
|
|
2649
|
+
const hits = source.split(`'${key}':`).length - 1
|
|
2650
|
+
assert.equal(hits, 2, `${key} 必须在 zh / en 两份目录里各出现一次(现在是 ${String(hits)} 次)`)
|
|
2651
|
+
}
|
|
2652
|
+
const decoded = decodeBundle(code)
|
|
2653
|
+
assert.ok(decoded.includes('复制全部'), 'bundle 里应有合并按钮的中文兜底文案')
|
|
2654
|
+
assert.ok(code.includes('dk_elevPanels'), 'bundle 里应有面板组的样式钩子')
|
|
2655
|
+
assert.ok(code.includes('dk_elevMerge'), 'bundle 里应有合并复制行的样式钩子')
|
|
2656
|
+
})
|
|
2657
|
+
|
|
2422
2658
|
/* ------------------------------------------------------------------ *
|
|
2423
2659
|
* 结果
|
|
2424
2660
|
* ------------------------------------------------------------------ */
|
package/scripts/route-smoke.mjs
CHANGED
|
@@ -181,6 +181,8 @@ function makeCtx(config, options = {}) {
|
|
|
181
181
|
settingsStored: {},
|
|
182
182
|
ttyConfig: options.ttyConfig,
|
|
183
183
|
disposers: [],
|
|
184
|
+
// 宿主日志捕获:info / warn 都进这里(其余宿主侧断言用)
|
|
185
|
+
logs: [],
|
|
184
186
|
/**
|
|
185
187
|
* 本插件 entry 的安装级配置(= apply 拿到的 config)。DSH ≥0.1.7 的
|
|
186
188
|
* `settings.describe()` 返回的是「默认值 ← 组合 base ← 用户层」的 resolved 值,
|
|
@@ -202,7 +204,10 @@ function makeCtx(config, options = {}) {
|
|
|
202
204
|
return () => {}
|
|
203
205
|
}
|
|
204
206
|
const child = {
|
|
205
|
-
logger: {
|
|
207
|
+
logger: {
|
|
208
|
+
info: (msg) => { state.logs.push(msg) },
|
|
209
|
+
warn: (msg) => { state.logs.push(msg) },
|
|
210
|
+
},
|
|
206
211
|
effect: (callback, _name) => {
|
|
207
212
|
const dispose = callback()
|
|
208
213
|
state.disposers.push(typeof dispose === 'function' ? dispose : () => {})
|
|
@@ -1185,6 +1190,143 @@ await test('重启路径:settings 命名空间已禁用时挂载 → 挂载即
|
|
|
1185
1190
|
assert.equal(config.body.config.enabled, false)
|
|
1186
1191
|
})
|
|
1187
1192
|
|
|
1193
|
+
/* ------------------------------------------------------------------ *
|
|
1194
|
+
* 7.5 能力使用审计(capability-use):过闸的变更 / exec 每次一行
|
|
1195
|
+
* ------------------------------------------------------------------ */
|
|
1196
|
+
|
|
1197
|
+
const USE_PREFIX = '[dsh-docker] capability-use: '
|
|
1198
|
+
/*
|
|
1199
|
+
* 审计行的出口是宿主 stdout(console.log,docker D162)——断言面因此是 console 捕获,
|
|
1200
|
+
* 不是假 ctx 的 logger(那是其余宿主侧断言用的)。捕获行原样放行,不影响人工排障。
|
|
1201
|
+
*/
|
|
1202
|
+
const consoleLogs = []
|
|
1203
|
+
const origConsoleLog = console.log.bind(console)
|
|
1204
|
+
console.log = (...args) => {
|
|
1205
|
+
consoleLogs.push(args.map((arg) => String(arg)).join(' '))
|
|
1206
|
+
origConsoleLog(...args)
|
|
1207
|
+
}
|
|
1208
|
+
const useLines = () => consoleLogs.filter((line) => line.startsWith(USE_PREFIX))
|
|
1209
|
+
/** 前段字段无空格;detail 恒在行尾且值可含空格、可含像 key=value 的文本(exec 命令)——从最后一个 ` detail=` 处整段吃下。 */
|
|
1210
|
+
function parseUseLine(line) {
|
|
1211
|
+
const fields = {}
|
|
1212
|
+
const body = line.slice(USE_PREFIX.length)
|
|
1213
|
+
const detailAt = body.lastIndexOf(' detail=')
|
|
1214
|
+
const head = detailAt === -1 ? body : body.slice(0, detailAt)
|
|
1215
|
+
for (const pair of head.split(' ')) {
|
|
1216
|
+
const eq = pair.indexOf('=')
|
|
1217
|
+
if (eq > 0) fields[pair.slice(0, eq)] = pair.slice(eq + 1)
|
|
1218
|
+
}
|
|
1219
|
+
if (detailAt !== -1) fields.detail = body.slice(detailAt + ' detail='.length)
|
|
1220
|
+
return fields
|
|
1221
|
+
}
|
|
1222
|
+
/** 跑一条变更路由,断言它**恰好**产生一行 use 行且指定字段逐一对上。 */
|
|
1223
|
+
async function auditCase(sub, body, expected) {
|
|
1224
|
+
const before = useLines().length
|
|
1225
|
+
const res = await call('POST', sub, body)
|
|
1226
|
+
assert.equal(res.status, 200, `${sub} 前置:路由本身要成功`)
|
|
1227
|
+
const lines = useLines().slice(before)
|
|
1228
|
+
assert.equal(lines.length, 1, `${sub} 应恰好产生一行 use 行,实际 ${String(lines.length)} 行`)
|
|
1229
|
+
const fields = parseUseLine(lines[0])
|
|
1230
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
1231
|
+
assert.equal(fields[key], value, `${sub}: 字段 ${key}`)
|
|
1232
|
+
}
|
|
1233
|
+
assert.equal(fields.target, '本机', `${sub}: target`)
|
|
1234
|
+
assert.equal(fields.ok, 'true', `${sub}: ok`)
|
|
1235
|
+
assert.notEqual(Number(fields.durationMs), NaN, `${sub}: durationMs 应是数字`)
|
|
1236
|
+
return fields
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
await test('审计:八条变更 / exec 路由各恰好一行,字段与操作一一对应', async () => {
|
|
1240
|
+
await auditCase('/images/remove', { target: '本机', ref: 'nginx:1.27' }, { capability: 'allowMutations', source: 'http', action: 'image.remove', ref: 'nginx:1.27' })
|
|
1241
|
+
const prune = await auditCase('/images/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'image.prune' })
|
|
1242
|
+
assert.equal('ref' in prune, false, 'prune 无目标:ref 字段应省略')
|
|
1243
|
+
await auditCase('/networks/remove', { target: '本机', name: 'shop_default' }, { capability: 'allowMutations', source: 'http', action: 'network.remove', ref: 'shop_default' })
|
|
1244
|
+
await auditCase('/networks/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'network.prune' })
|
|
1245
|
+
await auditCase('/volumes/remove', { target: '本机', name: 'pgdata' }, { capability: 'allowMutations', source: 'http', action: 'volume.remove', ref: 'pgdata' })
|
|
1246
|
+
await auditCase('/volumes/prune', { target: '本机' }, { capability: 'allowMutations', source: 'http', action: 'volume.prune' })
|
|
1247
|
+
await auditCase('/action', { target: '本机', action: 'start', id: 'shop-web-1' }, { capability: 'allowMutations', source: 'http', action: 'container.start', ref: 'shop-web-1' })
|
|
1248
|
+
await auditCase('/exec', { target: '本机', id: 'shop-web-1', command: 'echo hi' }, { capability: 'allowExec', source: 'http', action: 'exec', ref: 'shop-web-1', detail: 'code=0 cmd=echo hi' })
|
|
1249
|
+
})
|
|
1250
|
+
|
|
1251
|
+
await test('审计:pull 流开记 start、收尾记 end(reason / code / durationMs)', async () => {
|
|
1252
|
+
const before = useLines().length
|
|
1253
|
+
const res = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
|
|
1254
|
+
assert.equal(res.status, 200)
|
|
1255
|
+
const lines = useLines().slice(before)
|
|
1256
|
+
assert.equal(lines.length, 2, 'pull 流应恰好产生 start + end 两行')
|
|
1257
|
+
const start = parseUseLine(lines[0])
|
|
1258
|
+
assert.equal(start.event, 'start')
|
|
1259
|
+
assert.equal(start.action, 'image.pull')
|
|
1260
|
+
assert.equal(start.ref, 'nginx:1.27')
|
|
1261
|
+
assert.equal('ok' in start, false, 'start 行还没有结果:不带 ok')
|
|
1262
|
+
const end = parseUseLine(lines[1])
|
|
1263
|
+
assert.equal(end.event, 'end')
|
|
1264
|
+
assert.equal(end.ok, 'true')
|
|
1265
|
+
assert.equal(end.reason, 'pull-exit')
|
|
1266
|
+
assert.equal(end.code, '0')
|
|
1267
|
+
assert.notEqual(Number(end.durationMs), NaN)
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
await test('审计:只读路由与 /connect-local 产生零 use 行', async () => {
|
|
1271
|
+
const before = useLines().length
|
|
1272
|
+
for (const [method, sub, body] of [
|
|
1273
|
+
['POST', '/probe', { target: '本机' }],
|
|
1274
|
+
['POST', '/containers', { target: '本机' }],
|
|
1275
|
+
['POST', '/inspect', { target: '本机', id: 'shop-web-1' }],
|
|
1276
|
+
['POST', '/logs', { target: '本机', id: 'shop-web-1' }],
|
|
1277
|
+
['POST', '/stats', { target: '本机', ids: ['shop-web-1'] }],
|
|
1278
|
+
['POST', '/images', { target: '本机' }],
|
|
1279
|
+
['POST', '/networks', { target: '本机' }],
|
|
1280
|
+
['POST', '/volumes', { target: '本机' }],
|
|
1281
|
+
['POST', '/connect-local', {}],
|
|
1282
|
+
]) {
|
|
1283
|
+
const res = await call(method, sub, body)
|
|
1284
|
+
assert.equal(res.status, 200, `${sub} 前置`)
|
|
1285
|
+
}
|
|
1286
|
+
assert.equal(useLines().length - before, 0, '只读操作与 /connect-local 不该产生 use 行')
|
|
1287
|
+
})
|
|
1288
|
+
|
|
1289
|
+
await test('审计:agent 工具 source=tool(不经 HTTP 路由,不与路由重复计数)', async () => {
|
|
1290
|
+
const before = useLines().length
|
|
1291
|
+
const action = state.tools.find((item) => item.name === 'docker_action')
|
|
1292
|
+
assert.ok(action !== undefined)
|
|
1293
|
+
await action.execute({ target: '本机', action: 'stop', id: 'shop-web-1' })
|
|
1294
|
+
const exec = state.tools.find((item) => item.name === 'docker_exec')
|
|
1295
|
+
assert.ok(exec !== undefined)
|
|
1296
|
+
await exec.execute({ target: '本机', id: 'shop-web-1', command: 'ls -la' })
|
|
1297
|
+
const lines = useLines().slice(before)
|
|
1298
|
+
assert.equal(lines.length, 2, '两个工具各恰好一行')
|
|
1299
|
+
const stop = parseUseLine(lines[0])
|
|
1300
|
+
assert.equal(stop.source, 'tool')
|
|
1301
|
+
assert.equal(stop.capability, 'allowMutations')
|
|
1302
|
+
assert.equal(stop.action, 'container.stop')
|
|
1303
|
+
assert.equal(stop.ref, 'shop-web-1')
|
|
1304
|
+
const execLine = parseUseLine(lines[1])
|
|
1305
|
+
assert.equal(execLine.source, 'tool')
|
|
1306
|
+
assert.equal(execLine.capability, 'allowExec')
|
|
1307
|
+
assert.equal(execLine.action, 'exec')
|
|
1308
|
+
assert.equal(execLine.detail, 'code=0 cmd=ls -la')
|
|
1309
|
+
})
|
|
1310
|
+
|
|
1311
|
+
await test('审计:403 不记(使用 = 过了闸);开关恢复后一切照旧', async () => {
|
|
1312
|
+
const before = useLines().length
|
|
1313
|
+
const off = await call('POST', '/config', { allowMutations: false, allowExec: false })
|
|
1314
|
+
assert.equal(off.status, 200)
|
|
1315
|
+
const action = await call('POST', '/action', { target: '本机', action: 'stop', id: 'shop-web-1' })
|
|
1316
|
+
assert.equal(action.status, 403)
|
|
1317
|
+
const exec = await call('POST', '/exec', { target: '本机', id: 'shop-web-1', command: 'ls' })
|
|
1318
|
+
assert.equal(exec.status, 403)
|
|
1319
|
+
const pull = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
|
|
1320
|
+
assert.equal(pull.status, 403)
|
|
1321
|
+
assert.equal(useLines().length - before, 0, '被闸挡回的请求不算使用')
|
|
1322
|
+
|
|
1323
|
+
// 恢复开关:后续用例(若有)不受影响,工具重新注册
|
|
1324
|
+
const on = await call('POST', '/config', { allowMutations: true, allowExec: true })
|
|
1325
|
+
assert.equal(on.status, 200)
|
|
1326
|
+
assert.equal(toolNames().includes('docker_action'), true)
|
|
1327
|
+
await auditCase('/action', { target: '本机', action: 'start', id: 'shop-web-1' }, { capability: 'allowMutations', source: 'http', action: 'container.start', ref: 'shop-web-1' })
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1188
1330
|
/* ------------------------------------------------------------------ *
|
|
1189
1331
|
* 8. 结果
|
|
1190
1332
|
* ------------------------------------------------------------------ */
|