dsh-ensp 0.2.1 → 0.3.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.md CHANGED
@@ -25,6 +25,8 @@
25
25
 
26
26
  生成 eNSP 可直接打开并正常显示的 `.topo` 文件。支持内置场景,也可自定义设备与链路。
27
27
 
28
+ 机型与接口布局定义在 `src/core/models.mjs`,覆盖 16 个机型。解析拓扑时,端口名优先取自设备自身声明的 `<interface>` 块;同一型号在加装扩展卡后端口排布可能不同,声明块是唯一可靠依据。
29
+
28
30
  ```js
29
31
  import { twoPcLab, writeTopology } from 'dsh-ensp'
30
32
 
@@ -43,11 +45,11 @@ const topo = autoLayout(
43
45
  )
44
46
  ```
45
47
 
46
- 拓扑格式有几处约束来自 eNSP 本身:接口分组必须与机型一致,链路锚点必须落在图标包围盒内。写错任意一项,eNSP 会打开一个没有任何设备的空白画布,并且不报错。规则见 [`docs/ENSP-FORMAT.md`](docs/ENSP-FORMAT.md)。
48
+ 拓扑格式有几处约束来自 eNSP 本身:接口分组必须与机型一致,链路锚点必须落在图标包围盒内。任一项写错,eNSP 会打开空白画布且不报错。规则见 [`docs/ENSP-FORMAT.md`](docs/ENSP-FORMAT.md)。
47
49
 
48
50
  ### 启动与停止设备
49
51
 
50
- 自动定位工具栏按钮,无需手动点击。启动后按设备逐一确认就绪,窗口随即最小化,桌面可继续使用。
52
+ 自动定位工具栏按钮,无需手动点击。启动后按设备逐一确认就绪,随后最小化窗口,桌面可继续使用。
51
53
 
52
54
  ```bash
53
55
  ensp probe # 重新标定按钮位置
@@ -55,7 +57,7 @@ ensp up lab.topo # 打开、启动、确认就绪、最小化
55
57
  ensp stop # 关闭全部设备
56
58
  ```
57
59
 
58
- eNSP 只允许运行一个实例,异常退出时可能留下不占线程的僵尸进程,它会一直持有这个单实例锁,使下一次启动打开空白窗口。工具在启动前会识别并清理这种进程。
60
+ eNSP 只允许运行一个实例。异常退出可能遗留不占线程的僵尸进程,该进程持续持有单实例锁,导致下一次启动只打开空白窗口。工具在启动前会识别并清理此类进程。
59
61
 
60
62
  ### 下发配置
61
63
 
@@ -85,7 +87,7 @@ if (!result.ok) {
85
87
 
86
88
  ### 等待协议收敛
87
89
 
88
- 路由协议是异步收敛的,配置下发完不代表邻居已经建立。这类断言一律走轮询,直到状态达成或超时。
90
+ 路由协议异步收敛,配置下发完成不代表邻居已建立。此类判定一律轮询,直到状态达成或超时。
89
91
 
90
92
  ```js
91
93
  import { waitForOspfFull, waitForOspfRoute, waitForOutput } from 'dsh-ensp'
@@ -95,7 +97,7 @@ await waitForOspfRoute({ port: 2000, prefix: '2.2.2.2/32' })
95
97
  await waitForOutput({ port: 2000, command: 'display ip routing-table', until: /2\.2\.2\.2/ })
96
98
  ```
97
99
 
98
- 三个函数都返回 `{ ok, converged, output, attempts, waitedMs }`。固定等待一段时间再查询,在慢机器上会误判为失败,在快机器上纯属浪费。
100
+ 三个函数均返回 `{ ok, converged, output, attempts, waitedMs }`。固定等待在慢机器上会误判,在快机器上会浪费。
99
101
 
100
102
  ### 场景预设
101
103
 
@@ -108,16 +110,16 @@ ensp preset --search bgp # 按关键词搜索
108
110
  ensp preset campus-network --topo campus.topo # 生成该场景的拓扑
109
111
  ```
110
112
 
111
- 每个场景给出三项信息:需要创建的对象、配置顺序、用于确认结果的检查命令。具体命令由使用者按设备补全。
113
+ 每个场景提供三项信息:需要创建的对象、配置顺序、用于确认结果的检查命令。具体命令由使用者按设备补全。
112
114
 
113
- 场景自带拓扑蓝图:设备、链路、地址规划都是现成的,加上 `--topo` 就直接得到一个 eNSP 能打开的 `.topo`,不用先自己画图再算 IP。蓝图不含配置命令,场景仍然只是骨架。
115
+ 场景自带拓扑蓝图:设备、链路、地址规划均为现成内容,加 `--topo` 即生成 eNSP 可打开的 `.topo`,无需先绘图再规划地址。蓝图不含配置命令,场景仍为骨架。
114
116
 
115
117
  ```bash
116
118
  ensp preset ospf-single --topo ospf-single.topo
117
119
  ensp up ospf-single.topo
118
120
  ```
119
121
 
120
- 场景清单与蓝图都是可生成、可校验的。`docs/SCENARIOS.md` 由 `src/core/presets.mjs` 与 `src/core/labs.mjs` 渲染而来,`npm run docs:check` 在两者不一致时返回非零,CI 也会执行这一步。
122
+ 场景清单与蓝图均可生成、可校验。`docs/SCENARIOS.md` 由 `src/core/presets.mjs` 与 `src/core/labs.mjs` 渲染,`npm run docs:check` 在两者不一致时返回非零,CI 亦执行该检查。
121
123
 
122
124
  ```bash
123
125
  npm run docs # 重新生成 docs/SCENARIOS.md
@@ -126,9 +128,9 @@ npm run docs:check # 只检查是否过期
126
128
 
127
129
  ### 设备镜像检查
128
130
 
129
- 部分机型只有设备目录条目、没有随装镜像,最典型的是 `USG6000V`(eNSP 安装时需勾选该组件)。这类机型放上画布、存成 `.topo` 都没问题,但 eNSP 打开后会先渲染再自行退出,事后只能看到一堆“控制台无响应”,很容易误判成拓扑或插件的问题。
131
+ 部分机型只有设备目录条目、没有随装镜像,最典型的是 `USG6000V`(eNSP 安装时需勾选该组件)。这类机型可以正常放入画布并保存为 `.topo`,但 eNSP 打开后会先渲染再自行退出,表现为控制台全部无响应。
130
132
 
131
- `ensp up` 启动客户端之前会读 `res/items.xml` 与各插件的镜像文件,把启动不了的设备连同缺什么一起报出来:
133
+ `ensp up` 会在启动客户端之前读取 `res/items.xml` 与各插件的镜像文件,把无法启动的设备连同缺失项一并报出:
132
134
 
133
135
  ```console
134
136
  $ ensp up firewall-hrp.topo
@@ -138,13 +140,13 @@ error: topology uses devices this eNSP installation cannot start:
138
140
  This is not a bug in the topology: eNSP opens it, renders it, then quits. ...
139
141
  ```
140
142
 
141
- `ensp preset ... --topo` 生成文件时也会给出同样的提示,`ensp_build_topology` 的返回值里带 `unsupportedDevices`。检查只读本机文件,不启动 eNSP。设备清单见 [docs/SCENARIOS.md](docs/SCENARIOS.md) 每个场景的“设备镜像”一行。
143
+ `ensp preset ... --topo` 生成文件时给出同样的提示,`ensp_build_topology` 的返回值包含 `unsupportedDevices`。该检查只读取本机文件,不启动 eNSP。各场景的设备清单见 [docs/SCENARIOS.md](docs/SCENARIOS.md) 中的“设备镜像”一行。
142
144
 
143
145
  ### 窗口遮挡检查
144
146
 
145
- 启动设备只能点 eNSP 的工具栏按钮,而合成点击落在**拥有那个像素的窗口**上。别的程序盖住 eNSP 时,鼠标事件全被它接走,eNSP 一条都收不到,结果就是报了一连串成功、然后等一批从未启动的设备。
147
+ 启动设备需要点击 eNSP 工具栏按钮,而合成点击由持有该像素的窗口接收。其他程序覆盖 eNSP 时,鼠标事件被该程序接收,eNSP 收不到任何输入。
146
148
 
147
- 插件在点击前用 `WindowFromPoint` 查落点属主,不是 eNSP 就不点,直接报:
149
+ 插件在点击前通过 `WindowFromPoint` 检查落点归属,不属于 eNSP 则不执行点击:
148
150
 
149
151
  ```console
150
152
  $ ensp up lab.topo
@@ -153,17 +155,23 @@ The mouse events would go to that window, so nothing was clicked.
153
155
  Bring eNSP to the front (restore it from the taskbar) and try again.
154
156
  ```
155
157
 
156
- 标定之前还会扫一张 5×4 的采样网格,任一采样点被外人占住就拒绝开始,因为标定量的是屏幕像素,被遮挡时量到的全是别人的画面。点击后再查一次,防止点击瞬间被抢。
158
+ 标定前会以 5×4 采样网格检查窗口可见性,任一采样点被其他窗口占用即停止。标定读取屏幕像素,被遮挡时得到的是其他窗口的画面。点击完成后再次校验落点归属。
159
+
160
+ 插件无法将 eNSP 窗口提到前台。Windows 仅允许持有前台权限的进程切换前台窗口,跨进程调用 `SetForegroundWindow` 会被拒绝。运行前请将 eNSP 置于最上层。
161
+
162
+ ### 运行权限
157
163
 
158
- 插件不能自己把 eNSP 提到前台,这一点值得说清楚,因为它决定了你该怎么用。eNSP 的 exe 要求管理员权限,所以它总以 High 完整性级别运行;普通进程属于 Medium,Windows 的 UIPI 会拦下所有发往高完整性窗口的输入消息(`WM_LBUTTONDOWN` 返回 `err=5`)。提到 High 之后 UIPI 放行,但 Windows 的前台锁仍然不允许一个后台进程改前台(`SetForegroundWindow` 返回 `False`,`AttachThreadInput` 被拒)。两条路都堵着,所以 eNSP 必须由你保持在最上层,并且你在那个桌面上。
164
+ 本工具需要以管理员身份运行。
159
165
 
160
- 还有一点:**不要用管理员身份运行本工具**。提权只让 `PrintWindow` 能拍到画布,点击照样进不去,还多过一次 UAC。
166
+ eNSP 客户端要求管理员权限,始终以高完整性级别运行。Windows 的用户界面特权隔离(UIPI)会丢弃低完整性进程发出的输入消息,模拟点击也在其中。普通权限下 `SendInput` 返回成功但输入队列不变,eNSP 收不到事件,表现为点击无效;以管理员身份启动后可正常注入输入。
161
167
 
162
- 还有一种情况既不是遮挡也不是坐标:某些执行上下文(服务、计划任务、SSH 会话、agent 沙箱)根本没有输入注入能力。`SendInput` 会被接受并返回成功,但 `GetLastInputInfo` 的 tick 不动,窗口一个事件都收不到。`ensp doctor` 会实测这一点,见下节。
168
+ 管理员权限解决输入注入,不解决窗口置顶。eNSP 仍需保持可见并在最上层。
169
+
170
+ 服务、计划任务、SSH 会话等非交互上下文不具备输入注入能力,提升权限也无法改变。`ensp doctor` 的 `input-injection` 项用于判定当前会话是否可用。
163
171
 
164
172
  ### 排错自检
165
173
 
166
- 启动失败时最费时间的不是修,而是判断该修哪里。`ensp doctor` 把“这台机器能不能跑起来”拆成七项逐条检查,每一项都能独立判定,失败时给一句能照做的修法:
174
+ 启动失败时,定位故障项通常比修复本身更耗时。`ensp doctor` 将环境检查拆为七项,逐项判定,失败时给出可执行的修法:
167
175
 
168
176
  ```console
169
177
  $ ensp doctor --topology lab.topo
@@ -174,32 +182,33 @@ $ ensp doctor --topology lab.topo
174
182
  fix: Close some applications; devices may boot very slowly or fail to start.
175
183
  [ok ] device-images: AR2220 available
176
184
  [FAIL] input-injection: SendInput accepted 2 event(s) but the session last-input tick did not change (session 1, console session 1)
177
- fix: This session cannot deliver mouse input, so the eNSP toolbar button can never be
178
- pressed. Run the tool from an interactive desktop session ...
185
+ fix: Run the tool elevated (as administrator) from an interactive desktop session.
179
186
  [ok ] anchors: start (987,226) stop (1049,228)
180
187
  ```
181
188
 
182
- 它不启动 eNSP,只读本机文件并做一次合成输入探测,所以可以在任何失败之后、任何操作之前随手跑。`--shallow` 跳过较慢的输入探测,`--json` 输出完整结构。对应的工具是 `ensp_doctor`。
189
+ 该命令不启动 eNSP,只读取本机文件并执行一次合成输入探测,可在任意失败之后或操作之前运行。`--shallow` 跳过较慢的输入探测,`--json` 输出完整结构。对应工具为 `ensp_doctor`。
183
190
 
184
- 判断顺序是固定的:exe 在不在 → VirtualBox 在不在 → 这个拓扑要的镜像齐不齐 → 控制台端口空不空 → 输入能不能进去 → 内存够不够 → 锚点合不合理。卡在哪一项,就只修那一项。
191
+ 检查顺序固定:客户端可执行文件、VirtualBox、拓扑所需镜像、控制台端口、输入注入、内存、锚点。定位到哪一项失败,只需修复该项。
185
192
 
186
193
  ### 验收
187
194
 
188
- 配置下发成功不等于实验成功。`ensp_verify` 读 `.topo` 拿到每台设备的控制台端口,逐条执行该场景的验收命令,按输出内容判定,而不是看命令有没有报错:
195
+ 配置下发成功不等于实验通过。`ensp_verify` 从 `.topo` 读取各设备的控制台端口,逐条执行该场景的验收命令,按输出内容判定,不依据命令是否报错:
189
196
 
190
197
  ```console
191
198
  $ ensp verify ospf-single --topology lab.topo
192
- ospf-single — 单区域 OSPF
193
- (curated checks)
199
+ ospf-single · 单区域 OSPF
200
+ [curated checks]
194
201
  [pass] neighbour-full on R1: display ospf peer brief
195
202
  [pass] ospf-route on R1: display ip routing-table protocol ospf
196
203
 
197
204
  accepted: 2/2 checks passed
198
205
  ```
199
206
 
200
- 约 30 个场景有专门写的断言(OSPF 要 `Full`、BGP 要 `Established`、VRRP 要有 `Master`/`Backup`、MPLS VPN 要有 `Established` 与 VPN 路由表),其余场景回退成“把场景里提到的验证命令都跑一遍”的冒烟检查。判定逻辑在 `src/core/acceptance.mjs` 的 `EXPECTATIONS`,断言支持 `contains`、`notContains`、`matches`、`minLines`。对应的工具是 `ensp_verify`,`ensp_scenario` 会告诉某个场景有没有专门断言。
207
+ 约 30 个场景配置了针对性断言(OSPF 需 `Full`、BGP 需 `Established`、VRRP 需 `Master`/`Backup`、MPLS VPN 需 `Established` 与 VPN 路由表),其余场景回退为执行场景内列出的验证命令。判定逻辑位于 `src/core/acceptance.mjs` 的 `EXPECTATIONS`,断言支持 `contains`、`notContains`、`matches`、`minLines`。对应工具为 `ensp_verify`,`ensp_scenario` 会标明某场景是否具有针对性断言。
208
+
209
+ 断言中的设备名按角色映射到实际拓扑:蓝图使用 `R1`/`R2`,课程作业可能使用 `RA`/`RB`/`RC`,校验时取第一台具备控制台的设备(`R2` 取第二台),名称不同不影响判定。
201
210
 
202
- 断言不通过时会自己重试,因为路由协议是在命令返回之后才收敛的。`ensp apply` 一结束就验收,OSPF 邻居通常还在 `ExStart` 或 `2-Way`,只取一次快照会把它报成坏的实验。默认给 60 秒(`--wait` 改),通过时会注明等了多久:
211
+ 断言不通过时会自动重试。路由协议在命令返回之后才收敛,`ensp apply` 结束后立即验收时,OSPF 邻居通常仍处于 `ExStart` 或 `2-Way`,单次采样会误判为失败。默认等待 60 秒,可通过 `--wait` 调整,通过时注明确切等待时长:
203
212
 
204
213
  ```console
205
214
  $ ensp verify ospf-single --topology lab.topo
@@ -209,9 +218,9 @@ $ ensp verify ospf-single --topology lab.topo
209
218
  [pass] neighbour-full on R1: display ospf peer brief (converged after 27s)
210
219
  ```
211
220
 
212
- 两类失败不进重试循环,因为等下去也不会变好:控制台连不上(设备没启动)和 VRP 拒绝命令(命令写错)。前者直接说 `console unreachable: ... ECONNREFUSED`,后者说 `VRP rejected the command`,都不会伪装成“断言不符”。
221
+ 以下两类失败不进入重试循环,重试不会改变结果:控制台不可达(设备未启动)与 VRP 拒绝命令(命令有误)。前者报告 `console unreachable: ... ECONNREFUSED`,后者报告 `VRP rejected the command`,均不伪装为断言不符。
213
222
 
214
- 用法上先 `doctor` 再 `up` 再 `verify`:doctor 回答环境行不行,up 回答设备起没起,verify 回答网络通不通。
223
+ 推荐顺序为 `doctor`、`up`、`verify`:doctor 判定环境,up 判定设备,verify 判定配置效果。
215
224
 
216
225
  ## 安装
217
226
 
@@ -250,14 +259,16 @@ ensp status
250
259
  | 项 | 要求 |
251
260
  |---|---|
252
261
  | 操作系统 | Windows。eNSP 仅有 Windows 版本 |
253
- | eNSP | 已安装且可正常运行,需要管理员权限 |
262
+ | eNSP | 已安装且可正常运行 |
254
263
  | Node.js | 18 或更高版本 |
255
- | 权限 | 插件与 eNSP 需处于相同权限级别 |
264
+ | 权限 | 以管理员身份运行本工具与 eNSP,两者需为同一权限级别 |
256
265
 
257
266
  系统缩放设为 125% 或 150% 时,程序会自行完成坐标换算,不需要手动调分辨率。若 eNSP 升级后按钮位置改变,执行 `ensp probe` 重新标定。
258
267
 
259
268
  ## 快速开始
260
269
 
270
+ 在管理员身份下执行(见上文“运行权限”):
271
+
261
272
  ```bash
262
273
  ensp doctor # 先确认这台机器能跑起来
263
274
  ensp preset ospf-single --topo lab.topo # 生成拓扑
@@ -291,7 +302,7 @@ ensp stop
291
302
 
292
303
  仅自行使用而不对外分发时,上述要求不适用。
293
304
 
294
- 未选用 MIT 或 Apache-2.0,是因为这两者允许二次修改后闭源,与"二次修改也须开源"的意愿冲突。
305
+ 未选用 MIT 或 Apache-2.0,是因为两者允许二次修改后闭源,与二次修改亦须开源的要求不符。
295
306
 
296
307
  ### 致谢
297
308
 
@@ -95,6 +95,21 @@ shows an empty canvas with **no error message**.
95
95
  | `MCS` | `Ethernet/Ethernet×1` | 1 | 1 |
96
96
  | `Cloud` | `Ethernet/Ethernet×3`, `Ethernet/GE×0`, `Serial/Serial×0` | 3 | 4 |
97
97
  | `FRSW` | `Serial/Serial×16` | 16 | 1 |
98
+ | `Router` | `Ethernet/Ethernet×2`, `Ethernet/GE×4`, `Serial/Serial×4` | 10 | 43 |
99
+ | `Client` | `Ethernet/Ethernet×1` | 1 | 4 |
100
+
101
+ `Router` and `Client` were counted on the 18-topology teaching corpus (190 devices,
102
+ 185 links) rather than on the bundled samples, where they never appear. Both are
103
+ `isplugin="0"` in `res/items.xml`, so they ship with every eNSP install; the
104
+ `Router` entry does say `eNSP don't maintained this device any more, just keep it.`
105
+ The CLI name order was confirmed on a live device: `display interface brief` lists
106
+ `Ethernet0/0/0..1`, `GigabitEthernet0/0/0..3`, `Serial0/0/0..3`, `NULL0`.
107
+
108
+ > **A device may add cards, so read its own declarations.** A NAT lab in that
109
+ > corpus mounts a serial card on an AR2220: flat index 3 is `Serial0/0/0` there and
110
+ > `GigabitEthernet0/0/3` on a stock unit. `parseTopology` therefore builds the port
111
+ > list from each `<dev>`'s `<interface>` block and only falls back to the model
112
+ > table when a file declares nothing.
98
113
 
99
114
  > **`PC` declares `interfacename="GE"`, not `"Ethernet"`.** An earlier version of
100
115
  > this project wrote `Ethernet` for PCs on the theory that a PC has one Ethernet
@@ -95,10 +95,10 @@
95
95
  | `GetCursorPos` / `SetCursorPos` | `err=203` |
96
96
 
97
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`)。
98
+ `ConsentPromptBehaviorAdmin=0`,提升不弹窗),UIPI 那层放行,`SendMessage` 的 errno 从 5 变成 183,
99
+ `BringWindowToTop` 返回 `True`。但前台窗口仍是其他进程,`SetForegroundWindow` 依然 `False`、
100
+ `SetCursorPos` 依然 `False`。原因是 Windows 的前台锁:只有持有前台权限的进程才能改前台,
101
+ 从后台启动的进程没有该权限,`AttachThreadInput` 也拿不到(`attach=False err=5`)。
102
102
 
103
103
  已排除“不同窗口站/桌面”:`GetProcessWindowStation()` = `WinSta0`、`GetThreadDesktop()` = `Default`、
104
104
  `OpenInputDesktop(0,false,0x0001)` 返回的桌面名也是 `Default`;所有进程 `SessionId` 均为 1,
@@ -108,12 +108,12 @@
108
108
  另一条路也堵着:`PrintWindow` 从 Medium 上下文返回全白(`ok=False`),从 High 上下文**能**拍到真画布
109
109
  (这是唯一一处提权有实质收益的地方),但拍到画面不等于能把点击送进去。
110
110
 
111
- 结论:**eNSP 窗口必须由用户自己保持在最上层,并且用户得在那个桌面上**。插件能做的是尽早、明确、
112
- 可操作地报错,而不是点空了继续等。这也是 `ensp up` 要求“启动时短暂占用前台”的真实原因。
111
+ 结论:eNSP 窗口必须由用户自行保持在最上层,并且用户处于那个交互桌面。插件能做的是尽早、
112
+ 明确、可操作地报错,而不是点空了继续等。这也是 `ensp up` 要求启动时短暂占用前台的原因。
113
113
 
114
- 顺带一条实测结论,用来排除“是不是该提权”:**提权解决不了这件事**。提到 High 只换来
115
- `PrintWindow` 能拍到画布,点击仍进不去(前台锁),而代价是每次运行都要过一次 UAC
116
- (本机静默,别人机器不一定)。所以插件不提权,也不建议用户“用管理员身份运行”。
114
+ 提权的作用需要与置顶区分开。提到 High 不会让插件取得前台权限,`SetForegroundWindow` 仍然失败,
115
+ 所以置顶问题必须由用户解决。但提权是输入注入的前提:Medium 上下文里 `SendInput` 虽然返回成功,
116
+ 输入队列却不变,eNSP 收不到点击;以管理员身份运行后输入注入恢复正常。因此本工具需要以管理员身份运行。
117
117
 
118
118
  ## 设备控制台
119
119
 
@@ -1,14 +1,15 @@
1
1
 
2
2
  # dsh-ensp
3
3
 
4
- Automation for Huawei eNSP: generate topologies, boot devices, push configuration line by line and verify every command's result.
4
+ Automation for Huawei eNSP: generate topologies, boot devices, push configuration line by line, and verify the result of every command.
5
5
 
6
- - Generates `.topo` files eNSP opens directly, from a scenario blueprint (devices, links and addressing included) or from custom devices and links
7
- - Locates the toolbar buttons to start and stop devices, confirms each one individually, then minimises the window
6
+ - Generates `.topo` files that eNSP opens directly, from a scenario blueprint (devices, links and addressing included) or from custom devices and links
7
+ - Locates the toolbar buttons to start and stop devices, confirms each device 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
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
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
11
- - Runs seven preflight checks (images, ports, memory, input injection) that each name the fix, then verifies the scenario against what the devices actually report instead of trusting that commands were accepted
11
+ - Runs seven preflight checks (images, ports, memory, input injection), each with a stated fix, then verifies the scenario against what the devices actually report
12
+ - Must run elevated, with the eNSP window visible (see "运行权限" in the repository documentation)
12
13
  - Usable as a CLI, an MCP server, or a DeepSeek Harness plugin
13
14
 
14
15
  Scroll down and open "dsh-ensp>" under the included components to reach its settings.
@@ -1,14 +1,15 @@
1
1
 
2
2
  # dsh-ensp
3
3
 
4
- 面向华为 eNSP 的自动化工具:生成拓扑、启动设备、逐条下发配置并核对每条命令的结果。
4
+ 面向华为 eNSP 的自动化工具:生成拓扑、启动设备、逐条下发配置,并在每条命令后核对结果。
5
5
 
6
- - 直接生成 eNSP 可打开的 `.topo` 文件,可用场景蓝图(含设备、链路、地址规划)或自定义设备与连线
7
- - 自动定位工具栏按钮完成设备启停,启动后按设备逐一确认就绪,窗口随即最小化
8
- - 逐条下发配置,任一命令出错即中断并回报行号、命令与设备原始输出
6
+ - 生成 eNSP 可直接打开的 `.topo` 文件,可用场景蓝图(含设备、链路、地址规划)或自定义设备与连线
7
+ - 自动定位工具栏按钮完成设备启停,启动后按设备逐一确认就绪,随后最小化窗口
8
+ - 逐条下发配置,任一命令出错即中断,并回报行号、命令与设备原始输出
9
9
  - 内置 50 个场景预设,覆盖 VLAN、STP、堆叠、链路聚合、路由、BGP、MPLS、组播、NAT、ACL、QoS、IPsec、IPv6、WLAN、防火墙与高可用等主题
10
- - 启动前检查机型镜像是否齐全,缺镜像时直接说明缺哪个组件,而不是让 eNSP 打开后静默退出
11
- - 七项环境自检(镜像、端口、内存、输入注入能力)逐条给结论与修法,配置下发后再按场景断言验收,而不是只看命令有没有报错
10
+ - 启动前检查机型镜像是否齐全,缺镜像时说明缺失组件,避免 eNSP 打开后静默退出
11
+ - 提供七项环境自检(镜像、端口、内存、输入注入能力),逐条给出结论与修法;配置下发后按场景断言验收
12
+ - 需要以管理员身份运行,并保持 eNSP 窗口可见(详见仓库文档“运行权限”)
12
13
  - 提供 CLI、MCP Server 与 DeepSeek Harness 插件三种调用方式
13
14
 
14
15
  向下翻页后点击“包含的组件”内的“dsh-ensp>”进入设置界面。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ensp",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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",
package/src/cli/ensp.mjs CHANGED
@@ -93,7 +93,9 @@ ${C.bold('EXAMPLES')}
93
93
  ensp mcp
94
94
 
95
95
  ${C.bold('NOTES')}
96
- · eNSP must be installed and runnable; it requires administrator rights.
96
+ · eNSP must be installed and runnable. It requires administrator rights.
97
+ · Run this tool elevated too: a normal-integrity process cannot inject the mouse
98
+ input that starts devices, and SendInput will silently report success.
97
99
  · Starting devices briefly focuses the eNSP window (it has to be clicked),
98
100
  then the window is minimised again. Run doctor first if a start does nothing.
99
101
  · Readiness is verified per device by polling its console, never by sleeping.
@@ -169,6 +169,25 @@ export function evaluateAssertion(output, expect = {}) {
169
169
  }
170
170
  }
171
171
 
172
+ /**
173
+ * Resolve a curated check's device name against the devices that actually exist.
174
+ *
175
+ * Curated plans name devices the way the shipped blueprints do: R1, R2, R3. A lab
176
+ * built from somebody else's topology calls them RA/RB/RC, LSW1/LSW2, or anything
177
+ * at all. Reporting `no console port for device "R1"` on a healthy lab is worse
178
+ * than useless, so fall back to the device that plays the same role: the number in
179
+ * the name picks the position (R1 first, R2 second), and a name without a number
180
+ * picks the first console device.
181
+ */
182
+ export function resolveDevice(on, available = []) {
183
+ const names = [...available].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
184
+ if (!names.length) return on
185
+ if (names.includes(on)) return on
186
+ const numbered = /(\d+)$/.exec(on)
187
+ const index = numbered ? Math.max(0, Number(numbered[1]) - 1) : 0
188
+ return names[Math.min(index, names.length - 1)]
189
+ }
190
+
172
191
  /**
173
192
  * Build the acceptance plan for a scenario.
174
193
  *
@@ -185,7 +204,7 @@ export function planFor(id, { devices } = {}) {
185
204
  scenario: id,
186
205
  title: scenario.title,
187
206
  source: 'curated',
188
- checks: curated.map((c) => ({ ...c, kind: 'assert' })),
207
+ checks: curated.map((c) => ({ ...c, on: resolveDevice(c.on, devices), kind: 'assert' })),
189
208
  }
190
209
  }
191
210
  const first = (devices && devices.length && devices[0]) || scenario.devices?.split(/[、,,\s]+/)[0]
@@ -36,6 +36,18 @@ const ERROR_RE =
36
36
  /** A `[Y/N]`-style confirmation waiting for an answer. */
37
37
  const CONFIRM_RE = /(?:\[Y\/N\]|\(y\/n\)|\[y\/n\]|continue\?|Are you sure)/i
38
38
 
39
+ /**
40
+ * VRP's pager marker. `screen-length 0 temporary` normally turns paging off, but
41
+ * it is a user-view command: issued from a sub-view it is rejected, and on some
42
+ * builds it is rejected even in user view. When the pager is still on, a long
43
+ * `display` command stops at this marker and the rest of the output is simply
44
+ * missing — which reads as "the configuration is not there" when it is.
45
+ */
46
+ const PAGER_RE = /---- More ----\s*$/
47
+
48
+ /** Cap the number of pages we will advance for one command. */
49
+ const MAX_PAGES = 200
50
+
39
51
  const VIEW = {
40
52
  user: 'user',
41
53
  system: 'system',
@@ -91,6 +103,50 @@ export function hostnameOf(prompt) {
91
103
  return inner
92
104
  }
93
105
 
106
+ /**
107
+ * Does this transcript end on a pager stop rather than a prompt?
108
+ *
109
+ * Exported because "the output stops at `---- More ----`" is the difference
110
+ * between "the device has nothing more to say" and "we are about to lose the rest
111
+ * of the answer", and that distinction deserves a test of its own.
112
+ *
113
+ * @param {string} text
114
+ * @returns {boolean}
115
+ */
116
+ export function hasPagerMarker(text) {
117
+ return PAGER_RE.test(String(text ?? '').trimEnd())
118
+ }
119
+
120
+ /**
121
+ * Remove pager markers from a transcript collected across several pages.
122
+ *
123
+ * VRP prints `---- More ----` where the page broke and then continues on the next
124
+ * page. The continuation is kept as its own line: page breaks land on line
125
+ * boundaries in practice, and a spurious break inside a line is harmless, while
126
+ * gluing two lines together would corrupt `^keyword` style assertions.
127
+ *
128
+ * This is only a fallback: {@link ConsoleSession#prepare} normally turns paging
129
+ * off before any output is read.
130
+ *
131
+ * @param {string} text
132
+ * @returns {string}
133
+ */
134
+ function stitchPages(text) {
135
+ const out = []
136
+ for (const line of text.split('\n')) {
137
+ const m = line.match(/^(.*?)---- More ----(.*)$/)
138
+ if (!m) {
139
+ out.push(line)
140
+ continue
141
+ }
142
+ const head = m[1].trimEnd()
143
+ if (head) out.push(head)
144
+ const tail = m[2].trimStart()
145
+ if (tail) out.push(tail)
146
+ }
147
+ return out.join('\n')
148
+ }
149
+
94
150
  /** Strip ANSI escapes and trailing whitespace from a transcript chunk. */
95
151
  function clean(text) {
96
152
  return text
@@ -191,7 +247,9 @@ export class ConsoleSession {
191
247
  const text = clean(this.#buf)
192
248
  // A confirmation question wins over a stale prompt: answer it explicitly.
193
249
  if (CONFIRM_RE.test(text.split('\n').slice(-3).join('\n'))) break
194
- if (PROMPT_RE.test(text)) {
250
+ // A pager stop is a terminal state too: nothing more will arrive until we
251
+ // send a key, so waiting for a prompt here would burn the whole timeout.
252
+ if (PROMPT_RE.test(text) || PAGER_RE.test(text.trimEnd())) {
195
253
  const before = this.#buf.length
196
254
  await this.#waitUntil(() => this.#buf.length !== before || this.#closed, settleMs)
197
255
  if (this.#buf.length === before) break
@@ -199,7 +257,7 @@ export class ConsoleSession {
199
257
  // Poll the buffer rather than sleeping a fixed slice: most commands reply
200
258
  // in a few milliseconds, and waiting 200 ms per line dominates the runtime
201
259
  // of a 300-line config.
202
- await this.#waitUntil(() => PROMPT_RE.test(clean(this.#buf)) || this.#closed, 25)
260
+ await this.#waitUntil(() => PROMPT_RE.test(clean(this.#buf)) || PAGER_RE.test(clean(this.#buf).trimEnd()) || this.#closed, 25)
203
261
  }
204
262
  }
205
263
  const text = clean(this.#buf)
@@ -260,6 +318,20 @@ export class ConsoleSession {
260
318
  output += await this.#readToPrompt(timeoutMs)
261
319
  }
262
320
 
321
+ // Walk the pager to the end. A page ends at `---- More ----` with no prompt,
322
+ // so the command is not finished and the remaining output would be lost.
323
+ let pages = 0
324
+ while (PAGER_RE.test(output.trimEnd()) && pages < MAX_PAGES && !this.#closed) {
325
+ pages += 1
326
+ this.#buf = ''
327
+ this.writeRaw(' ')
328
+ output += await this.#readToPrompt(timeoutMs)
329
+ }
330
+ // The marker sits inline where the page broke: VRP prints `---- More ----`
331
+ // followed by the rest of the line it interrupted. Stitch that tail back onto
332
+ // the previous line so the caller sees what paging disabled would have shown.
333
+ if (pages > 0) output = stitchPages(output)
334
+
263
335
  const errorMatch = output.match(ERROR_RE)
264
336
  const prompt = this.#prompt
265
337
  return {
@@ -311,10 +383,15 @@ export class ConsoleSession {
311
383
 
312
384
  /**
313
385
  * Enable command paging off so long `display` output arrives in one piece.
314
- * Failures are non-fatal: some older VRP builds reject the command.
386
+ *
387
+ * Failures are non-fatal on purpose: `screen-length 0 temporary` is a user-view
388
+ * command and is rejected from a sub-view, and some builds reject it outright.
389
+ * `exec` pages through `---- More ----` stops on its own, so the only cost of a
390
+ * rejection here is a few extra round trips.
315
391
  */
316
392
  async prepare() {
317
- await this.exec('screen-length 0 temporary', { timeoutMs: 8000 })
393
+ if (!this.#prompt) await this.prime()
394
+ if (this.view === VIEW.user) await this.exec('screen-length 0 temporary', { timeoutMs: 8000 })
318
395
  return this
319
396
  }
320
397
 
@@ -248,7 +248,7 @@ export function doctor(options = {}) {
248
248
  : `SendInput accepted ${input.sent} event(s) but the session last-input tick did not change (session ${input.session}, console session ${input.consoleSession})`,
249
249
  input.ok
250
250
  ? undefined
251
- : 'This session cannot deliver mouse input, so the eNSP toolbar button can never be pressed. Run the tool from an interactive desktop session (a normal logged-in terminal), not from a service, a scheduled task, an SSH shell, or an agent sandbox.',
251
+ : 'Run the tool elevated (as administrator) from an interactive desktop session. A normal-integrity process cannot inject mouse input into eNSP, which runs at high integrity, and services or scheduled tasks cannot inject input at all.',
252
252
  ),
253
253
  )
254
254
  }
@@ -16,6 +16,8 @@ export {
16
16
  flatIndexToVrp,
17
17
  portCount,
18
18
  describePorts,
19
+ portsFromDeclarations,
20
+ declarationsFor,
19
21
  } from './models.mjs'
20
22
 
21
23
  // Kept for consumers written against the old shape; the table behind it was
@@ -76,13 +78,14 @@ export {
76
78
  EXPECTATIONS,
77
79
  evaluateAssertion,
78
80
  planFor,
81
+ resolveDevice,
79
82
  hasChecks,
80
83
  consolePorts,
81
84
  runAcceptance,
82
85
  formatAcceptance,
83
86
  } from './acceptance.mjs'
84
87
 
85
- export { ConsoleSession, openConsole, probeConsole, viewOf, hostnameOf } from './console.mjs'
88
+ export { ConsoleSession, openConsole, probeConsole, viewOf, hostnameOf, hasPagerMarker } from './console.mjs'
86
89
 
87
90
  export {
88
91
  enspPid,
@@ -814,8 +814,15 @@ export async function startAllDevices({
814
814
  }
815
815
 
816
816
  report({ phase: 'calibrate', hwnd })
817
- // Calibration photographs the screen, so an occluded window yields anchors
818
- // measured on the wrong pixels. Check first, and say who is in the way.
817
+ // Bringing the window forward and photographing it are the same problem seen
818
+ // twice: calibration reads pixels off the screen, so it has to happen while
819
+ // eNSP is actually in front. Focus first, then ask who owns the toolbar point.
820
+ // Doing it the other way round reports the window that was on top a moment ago
821
+ // (usually the terminal that launched us) as if it were a permanent blocker.
822
+ report({ phase: 'focus', hwnd })
823
+ let focused = focusEnsp(hwnd)
824
+ if (focused) await new Promise((resolve) => setTimeout(resolve, 300))
825
+
819
826
  const occlusion = checkOcclusion(hwnd)
820
827
  if (occlusion.occluded) {
821
828
  throw new Error(
@@ -843,9 +850,12 @@ export async function startAllDevices({
843
850
  }
844
851
  report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
845
852
 
846
- report({ phase: 'focus', hwnd })
847
- const focused = focusEnsp(hwnd)
848
- if (!focused) report({ phase: 'focus-failed', hwnd })
853
+ // The window was raised before calibration; re-raise it in case photographing
854
+ // the screen or the terminal regaining focus pushed it back down.
855
+ if (!focused) {
856
+ focused = focusEnsp(hwnd)
857
+ if (!focused) report({ phase: 'focus-failed', hwnd })
858
+ }
849
859
 
850
860
  report({ phase: 'click', button: 'start', anchor: anchors.start })
851
861
  const click = clickToolbar(hwnd, { button: 'start', anchors })
@@ -76,6 +76,29 @@ export const MODEL_SPECS = {
76
76
  vrp: 'GigabitEthernet0/0/',
77
77
  console: true,
78
78
  },
79
+ // Lab builds from teaching material (the 18 ICVE topologies) use the legacy
80
+ // `Router` model far more than any AR: 43 of 190 devices. Its port list is two
81
+ // fast Ethernet, four gigabit, four serial ports, and the CLI keeps the same
82
+ // order — verified by booting one and reading `display interface brief`.
83
+ // `Router` and `Client` are `isplugin="0"` in eNSP's items.xml, so they are
84
+ // built in; a machine "missing" them is not out of date.
85
+ Router: {
86
+ groups: [
87
+ ['Ethernet', 'Ethernet', 2, 0, 'Ethernet0/0/'],
88
+ ['Ethernet', 'GE', 4, 0],
89
+ ['Serial', 'Serial', 4, 0, 'Serial0/0/'],
90
+ ],
91
+ base: 0,
92
+ vrp: 'Ethernet0/0/',
93
+ console: true,
94
+ },
95
+ // A web client: one Ethernet port, no console.
96
+ Client: {
97
+ groups: [['Ethernet', 'Ethernet', 1, 0, 'Ethernet0/0/']],
98
+ base: 0,
99
+ vrp: 'Ethernet0/0/',
100
+ console: false,
101
+ },
79
102
  // PC and Server declare `interfacename="GE"` in the file but expose
80
103
  // `Ethernet0/0/1` on the CLI. eNSP's file vocabulary and VRP's differ here.
81
104
  PC: {
@@ -131,6 +154,60 @@ export const GEOMETRY = { editLeftOffset: 27, editTopOffset: 54 }
131
154
  */
132
155
  export const ICON = { w: 86, h: 86, left: -16.2, right: 70.2, top: -16.2, bottom: 70.2 }
133
156
 
157
+ /**
158
+ * CLI namespace for one interface group.
159
+ *
160
+ * The eNSP file and VRP disagree in two places: PC/Server store `GE` but expose
161
+ * `Ethernet`, and firewalls store `GE` but number from `GigabitEthernet1/0/`.
162
+ * The model spec states those overrides by position; everything else follows from
163
+ * the declared interface name.
164
+ */
165
+ function cliPrefixForGroup(sztype, enspPrefix, model, groupIndex) {
166
+ const override = MODEL_SPECS[model]?.groups?.[groupIndex]?.[4]
167
+ if (override) return override
168
+ if (enspPrefix === 'GE' || sztype === 'GE') return 'GigabitEthernet0/0/'
169
+ if (sztype === 'Serial' || enspPrefix === 'Serial') return 'Serial0/0/'
170
+ return 'Ethernet0/0/'
171
+ }
172
+
173
+ /**
174
+ * Build a port list from a device's own `<interface>` declarations.
175
+ *
176
+ * This matters for labs that add hardware: a stock AR2220 has no serial ports, but
177
+ * a NAT lab in the ICVE corpus mounts a serial card, so flat index 3 is
178
+ * `Serial0/0/0` there and `GigabitEthernet0/0/3` on a stock unit. The file states
179
+ * the real layout, so the file wins over the model table.
180
+ *
181
+ * @param {Array<{sztype: string, name: string, count: number, firstNumber: number|null}>} declarations
182
+ * @param {string} model
183
+ * @returns {string[]} CLI port name per flat index, in declaration order
184
+ */
185
+ export function portsFromDeclarations(declarations, model) {
186
+ if (!declarations?.length) return []
187
+ const base = MODEL_SPECS[model]?.base ?? 0
188
+ const ports = []
189
+ declarations.forEach((d, groupIndex) => {
190
+ const first = d.firstNumber ?? base
191
+ const cli = cliPrefixForGroup(d.sztype, d.name, model, groupIndex)
192
+ for (let i = 0; i < d.count; i += 1) ports.push(`${cli}${first + i}`)
193
+ })
194
+ return ports
195
+ }
196
+
197
+ /**
198
+ * Resident interface declarations of a model, as `{sztype, name, count, firstNumber}`.
199
+ *
200
+ * Callers use this when they need to emit the `<interface>` block for a device.
201
+ */
202
+ export function declarationsFor(model) {
203
+ return (MODEL_SPECS[model]?.groups ?? []).map(([sztype, name, count, firstNumber]) => ({
204
+ sztype,
205
+ name,
206
+ count,
207
+ firstNumber: firstNumber ?? null,
208
+ }))
209
+ }
210
+
134
211
  /** Total declared interface count for a model. */
135
212
  export function portCount(model) {
136
213
  const spec = MODEL_SPECS[model]
@@ -23,7 +23,14 @@
23
23
 
24
24
  import { readFile, writeFile } from 'node:fs/promises'
25
25
 
26
- import { MODEL_SPECS, GEOMETRY, portCount, flatIndexToVrp, vrpToFlatIndex } from './models.mjs'
26
+ import {
27
+ MODEL_SPECS,
28
+ GEOMETRY,
29
+ portCount,
30
+ portsFromDeclarations,
31
+ flatIndexToVrp,
32
+ vrpToFlatIndex,
33
+ } from './models.mjs'
27
34
 
28
35
  /**
29
36
  * Icon bounding box, measured from every cable anchor in eNSP's own samples.
@@ -90,14 +97,30 @@ export function parseTopology(xml) {
90
97
  const version = attr(xml.match(/<topo[^>]*>/)?.[0] ?? '', 'version')
91
98
 
92
99
  const devices = []
100
+ const portsById = new Map()
93
101
  for (const m of xml.matchAll(/<dev\b([^>]*)>([\s\S]*?)<\/dev>/g)) {
94
102
  const head = m[1]
95
103
  const body = m[2]
96
104
  const model = attr(head, 'model')
97
- const declared = [...body.matchAll(/<interface\b[^>]*count="(\d+)"/g)]
98
- .reduce((sum, x) => sum + Number(x[1]), 0)
105
+ // The device's own <interface> block is the authoritative port layout: a lab
106
+ // can mount extra cards (a serial card on an AR2220, for instance), and then
107
+ // the flat indices no longer match the stock model table.
108
+ const declarations = [...body.matchAll(/<interface\b([^>]*)\/?>/g)].map((decl) => {
109
+ const tag = decl[1]
110
+ const firstNumber = attr(tag, 'firstnumber')
111
+ return {
112
+ sztype: attr(tag, 'sztype') ?? 'Ethernet',
113
+ name: attr(tag, 'interfacename') ?? 'GE',
114
+ count: Number(attr(tag, 'count') ?? 0),
115
+ firstNumber: firstNumber === null ? null : Number(firstNumber),
116
+ }
117
+ })
118
+ const declared = declarations.reduce((sum, x) => sum + x.count, 0)
119
+ const ports = portsFromDeclarations(declarations, model)
120
+ const id = attr(head, 'id')
121
+ if (id) portsById.set(id, ports)
99
122
  devices.push({
100
- id: attr(head, 'id'),
123
+ id,
101
124
  name: attr(head, 'name'),
102
125
  model,
103
126
  comPort: Number(attr(head, 'com_port') ?? 0) || null,
@@ -128,8 +151,8 @@ export function parseTopology(xml) {
128
151
  media: attr(pair, 'lineName') ?? 'Copper',
129
152
  srcName: src?.name ?? null,
130
153
  dstName: dst?.name ?? null,
131
- srcPort: src ? interfaceName(src.model, srcIndex) : null,
132
- dstPort: dst ? interfaceName(dst.model, dstIndex) : null,
154
+ srcPort: src ? (portsById.get(srcId)?.[srcIndex] ?? interfaceName(src.model, srcIndex)) : null,
155
+ dstPort: dst ? (portsById.get(dstId)?.[dstIndex] ?? interfaceName(dst.model, dstIndex)) : null,
133
156
  })
134
157
  }
135
158