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 +43 -32
- package/docs/ENSP-FORMAT.md +15 -0
- package/docs/UI-ANCHORS.md +9 -9
- package/docs/plugin-detail.en.md +5 -4
- package/docs/plugin-detail.zh.md +7 -6
- package/package.json +1 -1
- package/src/cli/ensp.mjs +3 -1
- package/src/core/acceptance.mjs +20 -1
- package/src/core/console.mjs +81 -4
- package/src/core/doctor.mjs +1 -1
- package/src/core/index.mjs +4 -1
- package/src/core/launcher.mjs +15 -5
- package/src/core/models.mjs +77 -0
- package/src/core/topology.mjs +29 -6
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
131
|
+
部分机型只有设备目录条目、没有随装镜像,最典型的是 `USG6000V`(eNSP 安装时需勾选该组件)。这类机型可以正常放入画布并保存为 `.topo`,但 eNSP 打开后会先渲染再自行退出,表现为控制台全部无响应。
|
|
130
132
|
|
|
131
|
-
`ensp up`
|
|
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`
|
|
143
|
+
`ensp preset ... --topo` 生成文件时给出同样的提示,`ensp_build_topology` 的返回值包含 `unsupportedDevices`。该检查只读取本机文件,不启动 eNSP。各场景的设备清单见 [docs/SCENARIOS.md](docs/SCENARIOS.md) 中的“设备镜像”一行。
|
|
142
144
|
|
|
143
145
|
### 窗口遮挡检查
|
|
144
146
|
|
|
145
|
-
|
|
147
|
+
启动设备需要点击 eNSP 工具栏按钮,而合成点击由持有该像素的窗口接收。其他程序覆盖 eNSP 时,鼠标事件被该程序接收,eNSP 收不到任何输入。
|
|
146
148
|
|
|
147
|
-
|
|
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
|
-
|
|
158
|
+
标定前会以 5×4 采样网格检查窗口可见性,任一采样点被其他窗口占用即停止。标定读取屏幕像素,被遮挡时得到的是其他窗口的画面。点击完成后再次校验落点归属。
|
|
159
|
+
|
|
160
|
+
插件无法将 eNSP 窗口提到前台。Windows 仅允许持有前台权限的进程切换前台窗口,跨进程调用 `SetForegroundWindow` 会被拒绝。运行前请将 eNSP 置于最上层。
|
|
161
|
+
|
|
162
|
+
### 运行权限
|
|
157
163
|
|
|
158
|
-
|
|
164
|
+
本工具需要以管理员身份运行。
|
|
159
165
|
|
|
160
|
-
|
|
166
|
+
eNSP 客户端要求管理员权限,始终以高完整性级别运行。Windows 的用户界面特权隔离(UIPI)会丢弃低完整性进程发出的输入消息,模拟点击也在其中。普通权限下 `SendInput` 返回成功但输入队列不变,eNSP 收不到事件,表现为点击无效;以管理员身份启动后可正常注入输入。
|
|
161
167
|
|
|
162
|
-
|
|
168
|
+
管理员权限解决输入注入,不解决窗口置顶。eNSP 仍需保持可见并在最上层。
|
|
169
|
+
|
|
170
|
+
服务、计划任务、SSH 会话等非交互上下文不具备输入注入能力,提升权限也无法改变。`ensp doctor` 的 `input-injection` 项用于判定当前会话是否可用。
|
|
163
171
|
|
|
164
172
|
### 排错自检
|
|
165
173
|
|
|
166
|
-
|
|
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:
|
|
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
|
-
|
|
189
|
+
该命令不启动 eNSP,只读取本机文件并执行一次合成输入探测,可在任意失败之后或操作之前运行。`--shallow` 跳过较慢的输入探测,`--json` 输出完整结构。对应工具为 `ensp_doctor`。
|
|
183
190
|
|
|
184
|
-
|
|
191
|
+
检查顺序固定:客户端可执行文件、VirtualBox、拓扑所需镜像、控制台端口、输入注入、内存、锚点。定位到哪一项失败,只需修复该项。
|
|
185
192
|
|
|
186
193
|
### 验收
|
|
187
194
|
|
|
188
|
-
|
|
195
|
+
配置下发成功不等于实验通过。`ensp_verify` 从 `.topo` 读取各设备的控制台端口,逐条执行该场景的验收命令,按输出内容判定,不依据命令是否报错:
|
|
189
196
|
|
|
190
197
|
```console
|
|
191
198
|
$ ensp verify ospf-single --topology lab.topo
|
|
192
|
-
ospf-single
|
|
193
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
以下两类失败不进入重试循环,重试不会改变结果:控制台不可达(设备未启动)与 VRP 拒绝命令(命令有误)。前者报告 `console unreachable: ... ECONNREFUSED`,后者报告 `VRP rejected the command`,均不伪装为断言不符。
|
|
213
222
|
|
|
214
|
-
|
|
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
|
-
| 权限 |
|
|
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
|
|
package/docs/ENSP-FORMAT.md
CHANGED
|
@@ -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
|
package/docs/UI-ANCHORS.md
CHANGED
|
@@ -95,10 +95,10 @@
|
|
|
95
95
|
| `GetCursorPos` / `SetCursorPos` | `err=203` |
|
|
96
96
|
|
|
97
97
|
**第二层:前台锁。** 用 `Start-Process -Verb RunAs` 提到 High 之后(本机 UAC 是
|
|
98
|
-
`ConsentPromptBehaviorAdmin=0`,提升不弹窗),UIPI
|
|
99
|
-
`BringWindowToTop`
|
|
100
|
-
`SetCursorPos` 依然 `False`。原因是 Windows
|
|
101
|
-
|
|
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
|
-
|
|
112
|
-
|
|
111
|
+
结论:eNSP 窗口必须由用户自行保持在最上层,并且用户处于那个交互桌面。插件能做的是尽早、
|
|
112
|
+
明确、可操作地报错,而不是点空了继续等。这也是 `ensp up` 要求启动时短暂占用前台的原因。
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
`
|
|
116
|
-
|
|
114
|
+
提权的作用需要与置顶区分开。提到 High 不会让插件取得前台权限,`SetForegroundWindow` 仍然失败,
|
|
115
|
+
所以置顶问题必须由用户解决。但提权是输入注入的前提:Medium 上下文里 `SendInput` 虽然返回成功,
|
|
116
|
+
输入队列却不变,eNSP 收不到点击;以管理员身份运行后输入注入恢复正常。因此本工具需要以管理员身份运行。
|
|
117
117
|
|
|
118
118
|
## 设备控制台
|
|
119
119
|
|
package/docs/plugin-detail.en.md
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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.
|
package/docs/plugin-detail.zh.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
|
|
2
2
|
# dsh-ensp
|
|
3
3
|
|
|
4
|
-
面向华为 eNSP
|
|
4
|
+
面向华为 eNSP 的自动化工具:生成拓扑、启动设备、逐条下发配置,并在每条命令后核对结果。
|
|
5
5
|
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
6
|
+
- 生成 eNSP 可直接打开的 `.topo` 文件,可用场景蓝图(含设备、链路、地址规划)或自定义设备与连线
|
|
7
|
+
- 自动定位工具栏按钮完成设备启停,启动后按设备逐一确认就绪,随后最小化窗口
|
|
8
|
+
- 逐条下发配置,任一命令出错即中断,并回报行号、命令与设备原始输出
|
|
9
9
|
- 内置 50 个场景预设,覆盖 VLAN、STP、堆叠、链路聚合、路由、BGP、MPLS、组播、NAT、ACL、QoS、IPsec、IPv6、WLAN、防火墙与高可用等主题
|
|
10
|
-
-
|
|
11
|
-
-
|
|
10
|
+
- 启动前检查机型镜像是否齐全,缺镜像时说明缺失组件,避免 eNSP 打开后静默退出
|
|
11
|
+
- 提供七项环境自检(镜像、端口、内存、输入注入能力),逐条给出结论与修法;配置下发后按场景断言验收
|
|
12
|
+
- 需要以管理员身份运行,并保持 eNSP 窗口可见(详见仓库文档“运行权限”)
|
|
12
13
|
- 提供 CLI、MCP Server 与 DeepSeek Harness 插件三种调用方式
|
|
13
14
|
|
|
14
15
|
向下翻页后点击“包含的组件”内的“dsh-ensp>”进入设置界面。
|
package/package.json
CHANGED
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
|
|
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.
|
package/src/core/acceptance.mjs
CHANGED
|
@@ -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]
|
package/src/core/console.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
package/src/core/doctor.mjs
CHANGED
|
@@ -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
|
-
: '
|
|
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
|
}
|
package/src/core/index.mjs
CHANGED
|
@@ -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,
|
package/src/core/launcher.mjs
CHANGED
|
@@ -814,8 +814,15 @@ export async function startAllDevices({
|
|
|
814
814
|
}
|
|
815
815
|
|
|
816
816
|
report({ phase: 'calibrate', hwnd })
|
|
817
|
-
//
|
|
818
|
-
//
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
if (!focused)
|
|
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 })
|
package/src/core/models.mjs
CHANGED
|
@@ -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]
|
package/src/core/topology.mjs
CHANGED
|
@@ -23,7 +23,14 @@
|
|
|
23
23
|
|
|
24
24
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
25
25
|
|
|
26
|
-
import {
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|