dsh-ensp 0.1.6 → 0.1.7
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.md +56 -4
- package/docs/SCENARIOS.md +1410 -2
- package/docs/UI-ANCHORS.md +52 -0
- package/docs/plugin-detail.en.md +3 -2
- package/docs/plugin-detail.zh.md +3 -2
- package/package.json +5 -3
- package/scripts/gen-scenarios-doc.mjs +173 -0
- package/src/cli/ensp.mjs +462 -410
- package/src/core/build.mjs +218 -299
- package/src/core/compat.mjs +226 -0
- package/src/core/index.mjs +35 -3
- package/src/core/labs.mjs +1019 -0
- package/src/core/launcher.mjs +884 -725
- package/src/core/models.mjs +212 -0
- package/src/core/presets.mjs +583 -0
- package/src/core/topology.mjs +259 -333
- package/src/core/version.mjs +22 -0
- package/src/dsh/index.mjs +625 -595
- package/src/mcp/server.mjs +438 -406
package/docs/UI-ANCHORS.md
CHANGED
|
@@ -63,6 +63,58 @@
|
|
|
63
63
|
| 窗口在后台也能点 | **不可**;遮挡/最小化状态下驱动会让进程退出。必须先置前、操作完再最小化 |
|
|
64
64
|
| `FindWindowW('eNSP_Client')` 可定位 | **不可靠**;标题随拓扑变化,改拓扑后取不到 |
|
|
65
65
|
|
|
66
|
+
## 遮挡是硬失败,不是可重试的小问题
|
|
67
|
+
|
|
68
|
+
合成点击落在**拥有那个像素的窗口**上,不是落在我们以为的窗口上。当别的程序盖住 eNSP 时,
|
|
69
|
+
鼠标事件全被那个程序接走,eNSP 一条都没收到;而每一步都会报成功,调用方于是去等一批从未启动的设备。
|
|
70
|
+
这不是理论问题:一次实测里,全屏 VMware 窗口正压在 eNSP 上,`(992,84)` 这个点的属主是
|
|
71
|
+
`vmware-vmx`(PID 9948),点击返回 `ok:true` 而 `VBoxManage list runningvms` 全程为空。
|
|
72
|
+
|
|
73
|
+
所以插件在**点击前**先用 `WindowFromPoint` + `GetAncestor(GA_ROOT)` 查落点属主:
|
|
74
|
+
|
|
75
|
+
- 属主 PID ≠ eNSP PID → 不点击,抛错并点名挡路进程(`eNSP is covered by vmware (unknown) at 1307,426 ...`)
|
|
76
|
+
- `startAllDevices` 在标定之前还会用一张 5×4 的采样网格扫整个窗口;只要有任何一点被外人占住就拒绝开始,
|
|
77
|
+
因为标定是**拍照**,被遮挡时量出来的是别人的像素,锚点全错
|
|
78
|
+
- 点击之后会再查一次属主(`STOLEN` 情形),防止点击瞬间被抢走
|
|
79
|
+
|
|
80
|
+
### 为什么不能自动置顶
|
|
81
|
+
|
|
82
|
+
这里有两层独立的拦截,缺一层都解释不通实测结果。
|
|
83
|
+
|
|
84
|
+
**第一层:UIPI。** eNSP 的 exe 内嵌 manifest 写死 `<requestedExecutionLevel level="requireAdministrator">`,
|
|
85
|
+
所以它总以 **High 完整性**运行;普通 shell 是 **Medium**。Windows 的 UIPI 禁止低完整性进程向高完整性
|
|
86
|
+
窗口投递输入类消息,因此从 Medium 上下文:
|
|
87
|
+
|
|
88
|
+
| 调用 | 结果 |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `SendMessage(hwnd, WM_MOUSEMOVE=0x200)` | `ret=0 err=5`(ERROR_ACCESS_DENIED) |
|
|
91
|
+
| `SendMessage(hwnd, WM_LBUTTONDOWN=0x201)` | `ret=0 err=5` |
|
|
92
|
+
| `PostMessage(hwnd, WM_MOUSEMOVE)` | `ok=False err=5` |
|
|
93
|
+
| `SendMessage(hwnd, WM_GETTEXT=0xD)` / `WM_NULL` | `err=0`(只读消息能过,输入消息被拦) |
|
|
94
|
+
| `ShowWindow` / `BringWindowToTop` / `SetWindowPos` | `err=5` |
|
|
95
|
+
| `GetCursorPos` / `SetCursorPos` | `err=203` |
|
|
96
|
+
|
|
97
|
+
**第二层:前台锁。** 用 `Start-Process -Verb RunAs` 提到 High 之后(本机 UAC 是
|
|
98
|
+
`ConsentPromptBehaviorAdmin=0`,提升不弹窗),UIPI 那层就放行了——`SendMessage` 的 errno 从 5 变成 183,
|
|
99
|
+
`BringWindowToTop` 甚至返回 `True`。但前台窗口仍是别的进程,`SetForegroundWindow` 依然 `False`、
|
|
100
|
+
`SetCursorPos` 依然 `False`。原因是 Windows 的前台锁:只有“当前拥有前台权限”的进程才能改前台,
|
|
101
|
+
一个刚从后台起来的进程没有这个权限,`AttachThreadInput` 也拿不到(`attach=False err=5`)。
|
|
102
|
+
|
|
103
|
+
已排除“不同窗口站/桌面”:`GetProcessWindowStation()` = `WinSta0`、`GetThreadDesktop()` = `Default`、
|
|
104
|
+
`OpenInputDesktop(0,false,0x0001)` 返回的桌面名也是 `Default`;所有进程 `SessionId` 均为 1,
|
|
105
|
+
`WTSGetActiveConsoleSessionId()` 也是 1。也排除了 Job 限制:
|
|
106
|
+
`QueryInformationJobObject(JobObjectBasicUIRestrictions)` 返回 `flags=0x0`。
|
|
107
|
+
|
|
108
|
+
另一条路也堵着:`PrintWindow` 从 Medium 上下文返回全白(`ok=False`),从 High 上下文**能**拍到真画布
|
|
109
|
+
(这是唯一一处提权有实质收益的地方),但拍到画面不等于能把点击送进去。
|
|
110
|
+
|
|
111
|
+
结论:**eNSP 窗口必须由用户自己保持在最上层,并且用户得在那个桌面上**。插件能做的是尽早、明确、
|
|
112
|
+
可操作地报错,而不是点空了继续等。这也是 `ensp up` 要求“启动时短暂占用前台”的真实原因。
|
|
113
|
+
|
|
114
|
+
顺带一条实测结论,用来排除“是不是该提权”:**提权解决不了这件事**。提到 High 只换来
|
|
115
|
+
`PrintWindow` 能拍到画布,点击仍进不去(前台锁),而代价是每次运行都要过一次 UAC
|
|
116
|
+
(本机静默,别人机器不一定)。所以插件不提权,也不建议用户“用管理员身份运行”。
|
|
117
|
+
|
|
66
118
|
## 设备控制台
|
|
67
119
|
|
|
68
120
|
| 项 | 值 |
|
package/docs/plugin-detail.en.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
Automation for Huawei eNSP: generate topologies, boot devices, push configuration line by line and verify every command's result.
|
|
5
5
|
|
|
6
|
-
- Generates `.topo` files eNSP opens directly, from
|
|
6
|
+
- Generates `.topo` files eNSP opens directly, from a scenario blueprint (devices, links and addressing included) or from custom devices and links
|
|
7
7
|
- Locates the toolbar buttons to start and stop devices, confirms each one individually, then minimises the window
|
|
8
8
|
- Pushes configuration one command at a time; the first error aborts the batch and reports the line, the command and the device's raw output
|
|
9
|
-
- Ships
|
|
9
|
+
- Ships 50 scenario presets covering VLAN, STP, stacking, link aggregation, routing, BGP, MPLS, multicast, NAT, ACL, QoS, IPsec, IPv6, WLAN, firewalls and high availability
|
|
10
|
+
- Checks that every device image is installed before launching, and names the missing component instead of letting eNSP open the topology and quit silently
|
|
10
11
|
- Usable as a CLI, an MCP server, or a DeepSeek Harness plugin
|
|
11
12
|
|
|
12
13
|
Scroll down and open "dsh-ensp>" under the included components to reach its settings.
|
package/docs/plugin-detail.zh.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
面向华为 eNSP 的自动化工具:生成拓扑、启动设备、逐条下发配置并核对每条命令的结果。
|
|
5
5
|
|
|
6
|
-
-
|
|
6
|
+
- 直接生成 eNSP 可打开的 `.topo` 文件,可用场景蓝图(含设备、链路、地址规划)或自定义设备与连线
|
|
7
7
|
- 自动定位工具栏按钮完成设备启停,启动后按设备逐一确认就绪,窗口随即最小化
|
|
8
8
|
- 逐条下发配置,任一命令出错即中断并回报行号、命令与设备原始输出
|
|
9
|
-
- 内置
|
|
9
|
+
- 内置 50 个场景预设,覆盖 VLAN、STP、堆叠、链路聚合、路由、BGP、MPLS、组播、NAT、ACL、QoS、IPsec、IPv6、WLAN、防火墙与高可用等主题
|
|
10
|
+
- 启动前检查机型镜像是否齐全,缺镜像时直接说明缺哪个组件,而不是让 eNSP 打开后静默退出
|
|
10
11
|
- 提供 CLI、MCP Server 与 DeepSeek Harness 插件三种调用方式
|
|
11
12
|
|
|
12
13
|
向下翻页后点击“包含的组件”内的“dsh-ensp>”进入设置界面。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ensp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Automate Huawei eNSP labs: generate .topo files, start and stop devices, and push configuration one command at a time with per-command error reporting.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -43,8 +43,10 @@
|
|
|
43
43
|
"LICENSE"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"test": "node --test",
|
|
47
|
-
"probe": "node src/cli/ensp.mjs probe"
|
|
46
|
+
"test": "node --test \"tests/**/*.test.mjs\"",
|
|
47
|
+
"probe": "node src/cli/ensp.mjs probe",
|
|
48
|
+
"docs": "node scripts/gen-scenarios-doc.mjs",
|
|
49
|
+
"docs:check": "node scripts/gen-scenarios-doc.mjs --check"
|
|
48
50
|
},
|
|
49
51
|
"engines": {
|
|
50
52
|
"node": ">=18"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Regenerate `docs/SCENARIOS.md` from `src/core/presets.mjs`.
|
|
4
|
+
*
|
|
5
|
+
* The doc used to be maintained by hand, which meant the count in the heading
|
|
6
|
+
* drifted away from the catalogue every time a scenario was added. It is now a
|
|
7
|
+
* build product: run this after editing presets, and the same script validates
|
|
8
|
+
* the catalogue before writing anything.
|
|
9
|
+
*
|
|
10
|
+
* node scripts/gen-scenarios-doc.mjs # rewrite docs/SCENARIOS.md
|
|
11
|
+
* node scripts/gen-scenarios-doc.mjs --check # fail if the file is stale
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
import { SCENARIOS, SCENARIO_IDS, byLevel } from '../src/core/presets.mjs'
|
|
19
|
+
import { LABS, LAB_IDS, hasLab, labAddressing, labDevices, labModels } from '../src/core/labs.mjs'
|
|
20
|
+
import { imageNotesFor } from '../src/core/compat.mjs'
|
|
21
|
+
|
|
22
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const root = path.resolve(here, '..')
|
|
24
|
+
const target = path.join(root, 'docs', 'SCENARIOS.md')
|
|
25
|
+
|
|
26
|
+
const LEVEL_TITLES = { basic: '基础', intermediate: '进阶', advanced: '高级' }
|
|
27
|
+
const REQUIRED = ['title', 'titleEn', 'level', 'source', 'devices', 'goal']
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate every entry. A broken catalogue should stop the build rather than
|
|
31
|
+
* silently produce a doc missing a scenario.
|
|
32
|
+
*
|
|
33
|
+
* @returns {string[]} problems, empty when the catalogue is sound
|
|
34
|
+
*/
|
|
35
|
+
export function validateScenarios(catalogue = SCENARIOS) {
|
|
36
|
+
const problems = []
|
|
37
|
+
const ids = Object.keys(catalogue)
|
|
38
|
+
const seenTitles = new Map()
|
|
39
|
+
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
const s = catalogue[id]
|
|
42
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(id)) {
|
|
43
|
+
problems.push(`${id}: id must be kebab-case`)
|
|
44
|
+
}
|
|
45
|
+
for (const field of REQUIRED) {
|
|
46
|
+
if (!s?.[field] || typeof s[field] !== 'string') {
|
|
47
|
+
problems.push(`${id}: missing ${field}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!LEVEL_TITLES[s?.level]) {
|
|
51
|
+
problems.push(`${id}: level must be basic, intermediate or advanced (got ${s?.level})`)
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(s?.steps) || s.steps.length === 0) {
|
|
54
|
+
problems.push(`${id}: needs at least one step`)
|
|
55
|
+
} else {
|
|
56
|
+
s.steps.forEach((step, i) => {
|
|
57
|
+
if (!step?.do || typeof step.do !== 'string') problems.push(`${id}: step ${i + 1} has no "do"`)
|
|
58
|
+
if (step?.detail !== undefined && typeof step.detail !== 'string') {
|
|
59
|
+
problems.push(`${id}: step ${i + 1} detail must be a string`)
|
|
60
|
+
}
|
|
61
|
+
if (step?.verify !== undefined && typeof step.verify !== 'string') {
|
|
62
|
+
problems.push(`${id}: step ${i + 1} verify must be a string`)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(s?.pitfalls) || s.pitfalls.length === 0) {
|
|
67
|
+
problems.push(`${id}: needs at least one pitfall`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const titleKey = `${s?.title}\u0000${s?.titleEn}`
|
|
71
|
+
if (seenTitles.has(titleKey)) {
|
|
72
|
+
problems.push(`${id}: duplicate title of ${seenTitles.get(titleKey)}`)
|
|
73
|
+
} else {
|
|
74
|
+
seenTitles.set(titleKey, id)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const id of LAB_IDS) {
|
|
78
|
+
if (!catalogue[id]) problems.push(`${id}: topology blueprint with no matching scenario`)
|
|
79
|
+
}
|
|
80
|
+
return problems
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function render(catalogue = SCENARIOS) {
|
|
84
|
+
const groups = byLevel()
|
|
85
|
+
const out = [
|
|
86
|
+
`# 场景预设(${SCENARIO_IDS.length} 个)`,
|
|
87
|
+
'',
|
|
88
|
+
'由 `scripts/gen-scenarios-doc.mjs` 从 `src/core/presets.mjs` 与 `src/core/labs.mjs` 生成,勿手改。',
|
|
89
|
+
'',
|
|
90
|
+
'每个场景只给**方向、顺序、验证命令**,不给成品命令块。',
|
|
91
|
+
'',
|
|
92
|
+
`其中 ${LAB_IDS.length} 个带拓扑蓝图,可直接生成 \`.topo\`:`,
|
|
93
|
+
'',
|
|
94
|
+
'```',
|
|
95
|
+
'ensp preset ospf-single --topo ospf-single.topo',
|
|
96
|
+
'ensp up ospf-single.topo',
|
|
97
|
+
'```',
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
for (const level of ['basic', 'intermediate', 'advanced']) {
|
|
101
|
+
out.push('', `## ${LEVEL_TITLES[level]}`, '')
|
|
102
|
+
for (const id of groups[level]) {
|
|
103
|
+
const s = catalogue[id]
|
|
104
|
+
out.push(`### ${id} — ${s.title}`, '')
|
|
105
|
+
out.push(`- **英文名**:${s.titleEn}`)
|
|
106
|
+
out.push(`- **来源**:${s.source}`)
|
|
107
|
+
out.push(`- **设备**:${s.devices}`)
|
|
108
|
+
out.push(`- **目标**:${s.goal}`)
|
|
109
|
+
out.push('', '**配置顺序**', '')
|
|
110
|
+
s.steps.forEach((step, i) => {
|
|
111
|
+
const detail = step.detail ? ` — ${step.detail}` : ''
|
|
112
|
+
out.push(`${i + 1}. **${step.do}**${detail}`)
|
|
113
|
+
if (step.verify) out.push(` - 验证:\`${step.verify}\``)
|
|
114
|
+
})
|
|
115
|
+
if (s.pitfalls?.length) {
|
|
116
|
+
out.push('', '**易错点**', '')
|
|
117
|
+
for (const p of s.pitfalls) out.push(`- ${p}`)
|
|
118
|
+
}
|
|
119
|
+
if (hasLab(id)) {
|
|
120
|
+
out.push('', '**拓扑蓝图**', '')
|
|
121
|
+
out.push(`- 设备:${labDevices(id).join('、')}`)
|
|
122
|
+
const links = LABS[id].links.length
|
|
123
|
+
out.push(`- 链路:${links} 条`)
|
|
124
|
+
const addressing = labAddressing(id)
|
|
125
|
+
if (addressing.length) {
|
|
126
|
+
out.push('', '| 设备 | 接口 | 地址 | 说明 |', '| --- | --- | --- | --- |')
|
|
127
|
+
for (const row of addressing) {
|
|
128
|
+
out.push(`| ${row.device} | ${row.port} | ${row.ip} | ${row.note || ''} |`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
out.push('', `生成:\`ensp preset ${id} --topo ${id}.topo\``)
|
|
132
|
+
const notes = imageNotesFor(labModels(id))
|
|
133
|
+
for (const [model, note] of notes) out.push(`- 设备镜像:${model} ${note}`)
|
|
134
|
+
}
|
|
135
|
+
out.push('')
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return `${out.join('\n').replace(/\n+$/, '')}\n`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const problems = validateScenarios()
|
|
142
|
+
if (problems.length) {
|
|
143
|
+
console.error(`catalogue invalid (${problems.length}):`)
|
|
144
|
+
for (const p of problems) console.error(` - ${p}`)
|
|
145
|
+
process.exit(1)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Only act when run directly. Tests import `validateScenarios` from this file, and
|
|
149
|
+
// an unguarded body would rewrite docs/SCENARIOS.md as a side effect of importing.
|
|
150
|
+
const invokedDirectly =
|
|
151
|
+
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
152
|
+
|
|
153
|
+
if (invokedDirectly) {
|
|
154
|
+
const next = render()
|
|
155
|
+
const check = process.argv.includes('--check')
|
|
156
|
+
|
|
157
|
+
if (check) {
|
|
158
|
+
let current = ''
|
|
159
|
+
try {
|
|
160
|
+
current = await readFile(target, 'utf8')
|
|
161
|
+
} catch {
|
|
162
|
+
current = ''
|
|
163
|
+
}
|
|
164
|
+
if (current !== next) {
|
|
165
|
+
console.error('docs/SCENARIOS.md is stale; run `node scripts/gen-scenarios-doc.mjs`')
|
|
166
|
+
process.exit(1)
|
|
167
|
+
}
|
|
168
|
+
console.log(`docs/SCENARIOS.md is current (${SCENARIO_IDS.length} scenarios)`)
|
|
169
|
+
} else {
|
|
170
|
+
await writeFile(target, next, 'utf8')
|
|
171
|
+
console.log(`wrote docs/SCENARIOS.md (${SCENARIO_IDS.length} scenarios)`)
|
|
172
|
+
}
|
|
173
|
+
}
|