dsh-ensp 0.1.7 → 0.1.9

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.
Files changed (2) hide show
  1. package/package.json +70 -70
  2. package/src/core/compat.mjs +227 -226
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "dsh-ensp",
3
- "version": "0.1.7",
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
- "keywords": [
6
- "dsh",
7
- "dsh-plugin",
8
- "deepseek-harness",
9
- "ensp",
10
- "huawei",
11
- "vrp",
12
- "network-simulation",
13
- "mcp",
14
- "automation"
15
- ],
16
- "type": "module",
17
- "license": "GPL-3.0-or-later",
18
- "main": "./src/dsh/index.mjs",
19
- "exports": {
20
- ".": "./src/dsh/index.mjs",
21
- "./dsh": "./src/dsh/index.mjs",
22
- "./core": "./src/core/index.mjs",
23
- "./topology": "./src/core/topology.mjs",
24
- "./build": "./src/core/build.mjs",
25
- "./console": "./src/core/console.mjs",
26
- "./launcher": "./src/core/launcher.mjs",
27
- "./provision": "./src/core/provision.mjs",
28
- "./presets": "./src/core/presets.mjs",
29
- "./calibrate": "./src/core/calibrate.mjs",
30
- "./mcp": "./src/mcp/server.mjs"
31
- },
32
- "bin": {
33
- "ensp": "src/cli/ensp.mjs",
34
- "dsh-ensp-mcp": "src/mcp/server.mjs"
35
- },
36
- "files": [
37
- "src",
38
- "docs",
39
- "scripts",
40
- "cordis.patch.yml",
41
- "screenshots.json",
42
- "README.md",
43
- "LICENSE"
44
- ],
45
- "scripts": {
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"
50
- },
51
- "engines": {
52
- "node": ">=18"
53
- },
54
- "dsh": {
55
- "bundle": {
56
- "patch": "./cordis.patch.yml"
57
- }
58
- },
59
- "author": {
60
- "name": "lizi600jin"
61
- },
62
- "repository": {
63
- "type": "git",
64
- "url": "git+https://github.com/lizi600jin/dsh-ensp.git"
65
- },
66
- "homepage": "https://github.com/lizi600jin/dsh-ensp#readme",
67
- "bugs": {
68
- "url": "https://github.com/lizi600jin/dsh-ensp/issues"
69
- }
70
- }
1
+ {
2
+ "name": "dsh-ensp",
3
+ "version": "0.1.9",
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
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "ensp",
10
+ "huawei",
11
+ "vrp",
12
+ "network-simulation",
13
+ "mcp",
14
+ "automation"
15
+ ],
16
+ "type": "module",
17
+ "license": "GPL-3.0-or-later",
18
+ "main": "./src/dsh/index.mjs",
19
+ "exports": {
20
+ ".": "./src/dsh/index.mjs",
21
+ "./dsh": "./src/dsh/index.mjs",
22
+ "./core": "./src/core/index.mjs",
23
+ "./topology": "./src/core/topology.mjs",
24
+ "./build": "./src/core/build.mjs",
25
+ "./console": "./src/core/console.mjs",
26
+ "./launcher": "./src/core/launcher.mjs",
27
+ "./provision": "./src/core/provision.mjs",
28
+ "./presets": "./src/core/presets.mjs",
29
+ "./calibrate": "./src/core/calibrate.mjs",
30
+ "./mcp": "./src/mcp/server.mjs"
31
+ },
32
+ "bin": {
33
+ "ensp": "src/cli/ensp.mjs",
34
+ "dsh-ensp-mcp": "src/mcp/server.mjs"
35
+ },
36
+ "files": [
37
+ "src",
38
+ "docs",
39
+ "scripts",
40
+ "cordis.patch.yml",
41
+ "screenshots.json",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "scripts": {
46
+ "test": "node --test tests/core.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"
50
+ },
51
+ "engines": {
52
+ "node": ">=18"
53
+ },
54
+ "dsh": {
55
+ "bundle": {
56
+ "patch": "./cordis.patch.yml"
57
+ }
58
+ },
59
+ "author": {
60
+ "name": "lizi600jin"
61
+ },
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "git+https://github.com/lizi600jin/dsh-ensp.git"
65
+ },
66
+ "homepage": "https://github.com/lizi600jin/dsh-ensp#readme",
67
+ "bugs": {
68
+ "url": "https://github.com/lizi600jin/dsh-ensp/issues"
69
+ }
70
+ }
@@ -1,226 +1,227 @@
1
- /**
2
- * Device availability checks.
3
- *
4
- * Some eNSP models are only stubs in the device catalogue: the entry exists, you
5
- * can drop it on the canvas and save the topology, but the vendor image behind it
6
- * is not installed. Placing one is not a soft failure — **the client opens the
7
- * topology, paints it, and then exits by itself**, so the launch looks successful
8
- * for a few seconds and everything downstream fails with a confusing
9
- * "console never answered".
10
- *
11
- * That is exactly what happens here with `USG6000V`: `res/items.xml` declares it
12
- * as `<executable isplugin="1" path="ngfw\ngfw_Plugin.dll"/>`, the plugin DLL is
13
- * present, and `plugin/ngfw/DataBase/` is empty — the `.vdi` the plugin's VirtualBox
14
- * template points at was never installed. `USG5500` is retired outright
15
- * ("eNSP don't maintained this device any more, just keep it.") and has no image
16
- * either.
17
- *
18
- * Rather than guess, this module reads the catalogue and checks the files each
19
- * plugin model actually needs, so a topology can be rejected *before* the client is
20
- * started.
21
- *
22
- * @module dsh-ensp/compat
23
- */
24
-
25
- import { existsSync, readFileSync } from 'node:fs'
26
- import path from 'node:path'
27
-
28
- /** Default install root; override with `DSH_ENSP_EXE`'s directory or the `root` option. */
29
- export const DEFAULT_ENSP_ROOT = 'E:\\Huawei\\eNSP'
30
-
31
- /**
32
- * Files a plugin device needs beyond its catalogue entry.
33
- *
34
- * `USG6000V`'s VirtualBox template (`plugin/ngfw/tools/ngfw/vfw_usg_for_vbox5.0.vbox`)
35
- * carries `<HardDisk ... location="../../DataBase/vfw_usg.vdi"/>`, which resolves to
36
- * `plugin/ngfw/DataBase/vfw_usg.vdi` relative to that template.
37
- */
38
- const PLUGIN_ASSETS = {
39
- USG6000V: [
40
- {
41
- rel: 'plugin/ngfw/DataBase/vfw_usg.vdi',
42
- why: 'NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)',
43
- },
44
- ],
45
- }
46
-
47
- /** Models the vendor has withdrawn; usable only if an image happens to exist. */
48
- const RETIRED = {
49
- USG5500: 'eNSP 已停止维护 USG5500,安装包不再附带镜像,请改用 USG6000V',
50
- }
51
-
52
- /**
53
- * Static notes about devices that need an extra image, for documentation.
54
- *
55
- * Kept independent of the local installation so generated docs say the same thing
56
- * on every machine; `probeDevice` is the machine-specific check.
57
- */
58
- export const IMAGE_NOTES = {
59
- USG6000V:
60
- '需要 eNSP 安装时勾选 USG6000V 组件(NGFW 镜像,约 1 GB)。缺镜像时 eNSP 会打开拓扑后自行退出,' +
61
- '`ensp up` 会在启动前直接报错说明缺什么。',
62
- USG5500: 'eNSP 已停止维护该机型,安装包不再附带镜像,建议改用 USG6000V。',
63
- }
64
-
65
- /**
66
- * Device notes for a list of models, as `[model, note]` pairs.
67
- *
68
- * @param {string[]} models
69
- * @returns {Array<[string, string]>}
70
- */
71
- export function imageNotesFor(models) {
72
- return [...new Set(models)].filter((m) => IMAGE_NOTES[m]).map((m) => [m, IMAGE_NOTES[m]])
73
- }
74
-
75
- const cache = new Map()
76
-
77
- /**
78
- * Parse `res/items.xml` into `{ model: { plugin, pluginPath, needRegister } }`.
79
- *
80
- * @param {string} root eNSP install directory
81
- * @returns {Map<string, {plugin: boolean, pluginPath: string, needRegister: boolean}>}
82
- */
83
- function readCatalogue(root) {
84
- const file = path.join(root, 'res', 'items.xml')
85
- if (cache.has(file)) return cache.get(file)
86
-
87
- const table = new Map()
88
- let xml = ''
89
- try {
90
- xml = readFileSync(file, 'utf8')
91
- } catch {
92
- cache.set(file, table)
93
- return table
94
- }
95
-
96
- for (const m of xml.matchAll(/<model name="([^"]+)">([\s\S]*?)<\/model>/g)) {
97
- const body = m[2]
98
- const exe = /<executable isplugin="(\d)" path="([^"]*)"/.exec(body)
99
- const reg = /<needRegister>(\d+)<\/needRegister>/.exec(body)
100
- table.set(m[1], {
101
- plugin: exe ? exe[1] === '1' : false,
102
- pluginPath: exe ? exe[2] : '',
103
- needRegister: reg ? reg[1] === '1' : false,
104
- })
105
- }
106
- cache.set(file, table)
107
- return table
108
- }
109
-
110
- /** Derive the install root from an explicit executable path. */
111
- export function rootFromExe(exe) {
112
- return exe ? path.dirname(exe) : DEFAULT_ENSP_ROOT
113
- }
114
-
115
- /**
116
- * Can this model actually be started on this machine?
117
- *
118
- * @param {string} model
119
- * @param {{root?: string}} [options]
120
- * @returns {{model: string, available: boolean, reason?: string}}
121
- */
122
- export function probeDevice(model, options = {}) {
123
- const root = options.root ?? DEFAULT_ENSP_ROOT
124
- const entry = readCatalogue(root).get(model)
125
-
126
- if (!entry) {
127
- // Models missing from the catalogue are handled by the caller (a topology that
128
- // names an unknown model is a different error); stay permissive here.
129
- return { model, available: true }
130
- }
131
-
132
- if (entry.plugin && entry.pluginPath) {
133
- const dll = path.join(root, 'plugin', entry.pluginPath.replace(/\\/g, '/'))
134
- if (!existsSync(dll)) {
135
- return { model, available: false, reason: `插件缺失:${path.relative(root, dll)}` }
136
- }
137
- }
138
-
139
- for (const asset of PLUGIN_ASSETS[model] ?? []) {
140
- if (!existsSync(path.join(root, asset.rel.replace(/\//g, path.sep)))) {
141
- return { model, available: false, reason: asset.why }
142
- }
143
- }
144
-
145
- if (RETIRED[model] && !hasAnyImage(root, model)) {
146
- return { model, available: false, reason: RETIRED[model] }
147
- }
148
-
149
- return { model, available: true }
150
- }
151
-
152
- /**
153
- * Does any VirtualBox image under the eNSP tree look like it belongs to `model`?
154
- *
155
- * Used only for retired models, where the catalogue says nothing about the image.
156
- *
157
- * @param {string} root
158
- * @param {string} model
159
- */
160
- function hasAnyImage(root, model) {
161
- const stem = model.toLowerCase()
162
- const candidates = [
163
- path.join(root, 'vboxserver'),
164
- path.join(root, 'plugin'),
165
- ]
166
- for (const dir of candidates) {
167
- if (!existsSync(dir)) continue
168
- if (existsSync(path.join(dir, stem))) return true
169
- }
170
- return false
171
- }
172
-
173
- /**
174
- * Check every device in a parsed topology.
175
- *
176
- * @param {{devices: Array<{name: string, model: string}>}} topo
177
- * @param {{root?: string}} [options]
178
- * @returns {{ok: boolean, blocked: Array<{name: string, model: string, reason: string}>, checked: string[]}}
179
- */
180
- export function checkTopologySupport(topo, options = {}) {
181
- const blocked = []
182
- for (const device of topo.devices ?? []) {
183
- const result = probeDevice(device.model, options)
184
- if (!result.available) {
185
- blocked.push({ name: device.name, model: device.model, reason: result.reason })
186
- }
187
- }
188
- return {
189
- ok: blocked.length === 0,
190
- blocked,
191
- checked: [...new Set((topo.devices ?? []).map((d) => d.model))].sort(),
192
- }
193
- }
194
-
195
- /**
196
- * Build the error text for a blocked topology.
197
- *
198
- * Names the device, the model and what is missing, then says what to do. Repeated
199
- * models are grouped so a five-firewall lab prints one explanation, not five.
200
- *
201
- * @param {{blocked: Array<{name: string, model: string, reason: string}>}} report
202
- * @returns {string}
203
- */
204
- export function describeBlocked(report) {
205
- const byModel = new Map()
206
- for (const item of report.blocked) {
207
- const group = byModel.get(item.model) ?? { reason: item.reason, names: [] }
208
- group.names.push(item.name)
209
- byModel.set(item.model, group)
210
- }
211
- const lines = [...byModel].map(
212
- ([model, { reason, names }]) => ` ${model} (${names.join(', ')}): ${reason}`,
213
- )
214
- return (
215
- 'topology uses devices this eNSP installation cannot start:\n' +
216
- lines.join('\n') +
217
- '\n\nThis is not a bug in the topology: eNSP opens it, renders it, then quits. ' +
218
- 'Install the missing device image in eNSP (工具 → 注册设备, or reinstall eNSP with ' +
219
- 'the device component selected), or remove those devices from the topology.'
220
- )
221
- }
222
-
223
- /** Forget cached catalogue reads (tests, and after installing a device image). */
224
- export function clearCatalogueCache() {
225
- cache.clear()
226
- }
1
+ /**
2
+ * Device availability checks.
3
+ *
4
+ * Some eNSP models are only stubs in the device catalogue: the entry exists, you
5
+ * can drop it on the canvas and save the topology, but the vendor image behind it
6
+ * is not installed. Placing one is not a soft failure — **the client opens the
7
+ * topology, paints it, and then exits by itself**, so the launch looks successful
8
+ * for a few seconds and everything downstream fails with a confusing
9
+ * "console never answered".
10
+ *
11
+ * That is exactly what happens here with `USG6000V`: `res/items.xml` declares it
12
+ * as `<executable isplugin="1" path="ngfw\ngfw_Plugin.dll"/>`, the plugin DLL is
13
+ * present, and `plugin/ngfw/DataBase/` is empty — the `.vdi` the plugin's VirtualBox
14
+ * template points at was never installed. `USG5500` is retired outright
15
+ * ("eNSP don't maintained this device any more, just keep it.") and has no image
16
+ * either.
17
+ *
18
+ * Rather than guess, this module reads the catalogue and checks the files each
19
+ * plugin model actually needs, so a topology can be rejected *before* the client is
20
+ * started.
21
+ *
22
+ * @module dsh-ensp/compat
23
+ */
24
+
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import path from 'node:path'
27
+
28
+ /** Default install root; override with `DSH_ENSP_EXE`'s directory or the `root` option. */
29
+ export const DEFAULT_ENSP_ROOT = 'E:\\Huawei\\eNSP'
30
+
31
+ /**
32
+ * Files a plugin device needs beyond its catalogue entry.
33
+ *
34
+ * `USG6000V`'s VirtualBox template (`plugin/ngfw/tools/ngfw/vfw_usg_for_vbox5.0.vbox`)
35
+ * carries `<HardDisk ... location="../../DataBase/vfw_usg.vdi"/>`, which resolves to
36
+ * `plugin/ngfw/DataBase/vfw_usg.vdi` relative to that template.
37
+ */
38
+ const PLUGIN_ASSETS = {
39
+ USG6000V: [
40
+ {
41
+ rel: 'plugin/ngfw/DataBase/vfw_usg.vdi',
42
+ why: 'NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)',
43
+ },
44
+ ],
45
+ }
46
+
47
+ /** Models the vendor has withdrawn; usable only if an image happens to exist. */
48
+ const RETIRED = {
49
+ USG5500: 'eNSP 已停止维护 USG5500,安装包不再附带镜像,请改用 USG6000V',
50
+ }
51
+
52
+ /**
53
+ * Static notes about devices that need an extra image, for documentation.
54
+ *
55
+ * Kept independent of the local installation so generated docs say the same thing
56
+ * on every machine; `probeDevice` is the machine-specific check.
57
+ */
58
+ export const IMAGE_NOTES = {
59
+ USG6000V:
60
+ '需要 eNSP 安装时勾选 USG6000V 组件(NGFW 镜像,约 1 GB)。缺镜像时 eNSP 会打开拓扑后自行退出,' +
61
+ '`ensp up` 会在启动前直接报错说明缺什么。',
62
+ USG5500: 'eNSP 已停止维护该机型,安装包不再附带镜像,建议改用 USG6000V。',
63
+ }
64
+
65
+ /**
66
+ * Device notes for a list of models, as `[model, note]` pairs.
67
+ *
68
+ * @param {string[]} models
69
+ * @returns {Array<[string, string]>}
70
+ */
71
+ export function imageNotesFor(models) {
72
+ return [...new Set(models)].filter((m) => IMAGE_NOTES[m]).map((m) => [m, IMAGE_NOTES[m]])
73
+ }
74
+
75
+ const cache = new Map()
76
+
77
+ /**
78
+ * Parse `res/items.xml` into `{ model: { plugin, pluginPath, needRegister } }`.
79
+ *
80
+ * @param {string} root eNSP install directory
81
+ * @returns {Map<string, {plugin: boolean, pluginPath: string, needRegister: boolean}>}
82
+ */
83
+ function readCatalogue(root) {
84
+ const file = path.join(root, 'res', 'items.xml')
85
+ if (cache.has(file)) return cache.get(file)
86
+
87
+ const table = new Map()
88
+ let xml = ''
89
+ try {
90
+ xml = readFileSync(file, 'utf8')
91
+ } catch {
92
+ cache.set(file, table)
93
+ return table
94
+ }
95
+
96
+ for (const m of xml.matchAll(/<model name="([^"]+)">([\s\S]*?)<\/model>/g)) {
97
+ const body = m[2]
98
+ const exe = /<executable isplugin="(\d)" path="([^"]*)"/.exec(body)
99
+ const reg = /<needRegister>(\d+)<\/needRegister>/.exec(body)
100
+ table.set(m[1], {
101
+ plugin: exe ? exe[1] === '1' : false,
102
+ pluginPath: exe ? exe[2] : '',
103
+ needRegister: reg ? reg[1] === '1' : false,
104
+ })
105
+ }
106
+ cache.set(file, table)
107
+ return table
108
+ }
109
+
110
+ /** Derive the install root from an explicit executable path. */
111
+ export function rootFromExe(exe) {
112
+ return exe ? path.dirname(exe) : DEFAULT_ENSP_ROOT
113
+ }
114
+
115
+ /**
116
+ * Can this model actually be started on this machine?
117
+ *
118
+ * @param {string} model
119
+ * @param {{root?: string}} [options]
120
+ * @returns {{model: string, available: boolean, reason?: string}}
121
+ */
122
+ export function probeDevice(model, options = {}) {
123
+ const root = options.root ?? DEFAULT_ENSP_ROOT
124
+ const entry = readCatalogue(root).get(model)
125
+
126
+ if (entry?.plugin && entry.pluginPath) {
127
+ const dll = path.join(root, 'plugin', entry.pluginPath.split('\\').join('/'))
128
+ if (!existsSync(dll)) {
129
+ return { model, available: false, reason: `插件缺失:${path.relative(root, dll)}` }
130
+ }
131
+ }
132
+
133
+ // Checked before the catalogue entry on purpose: the assets a model needs are a
134
+ // property of the model, not of the catalogue file. A machine with no eNSP
135
+ // installed at all still cannot start a USG6000V, and answering "available"
136
+ // because items.xml was unreadable would be the exact false negative this module
137
+ // exists to prevent.
138
+ for (const asset of PLUGIN_ASSETS[model] ?? []) {
139
+ if (!existsSync(path.join(root, asset.rel))) {
140
+ return { model, available: false, reason: asset.why }
141
+ }
142
+ }
143
+
144
+ if (RETIRED[model] && !hasAnyImage(root, model)) {
145
+ return { model, available: false, reason: RETIRED[model] }
146
+ }
147
+
148
+ // Unknown models are the caller's problem (a topology naming them is a different
149
+ // error), so stay permissive rather than blocking every lab on a stale catalogue.
150
+ return { model, available: true }
151
+ }
152
+
153
+ /**
154
+ * Does any VirtualBox image under the eNSP tree look like it belongs to `model`?
155
+ *
156
+ * Used only for retired models, where the catalogue says nothing about the image.
157
+ *
158
+ * @param {string} root
159
+ * @param {string} model
160
+ */
161
+ function hasAnyImage(root, model) {
162
+ const stem = model.toLowerCase()
163
+ const candidates = [
164
+ path.join(root, 'vboxserver'),
165
+ path.join(root, 'plugin'),
166
+ ]
167
+ for (const dir of candidates) {
168
+ if (!existsSync(dir)) continue
169
+ if (existsSync(path.join(dir, stem))) return true
170
+ }
171
+ return false
172
+ }
173
+
174
+ /**
175
+ * Check every device in a parsed topology.
176
+ *
177
+ * @param {{devices: Array<{name: string, model: string}>}} topo
178
+ * @param {{root?: string}} [options]
179
+ * @returns {{ok: boolean, blocked: Array<{name: string, model: string, reason: string}>, checked: string[]}}
180
+ */
181
+ export function checkTopologySupport(topo, options = {}) {
182
+ const blocked = []
183
+ for (const device of topo.devices ?? []) {
184
+ const result = probeDevice(device.model, options)
185
+ if (!result.available) {
186
+ blocked.push({ name: device.name, model: device.model, reason: result.reason })
187
+ }
188
+ }
189
+ return {
190
+ ok: blocked.length === 0,
191
+ blocked,
192
+ checked: [...new Set((topo.devices ?? []).map((d) => d.model))].sort(),
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Build the error text for a blocked topology.
198
+ *
199
+ * Names the device, the model and what is missing, then says what to do. Repeated
200
+ * models are grouped so a five-firewall lab prints one explanation, not five.
201
+ *
202
+ * @param {{blocked: Array<{name: string, model: string, reason: string}>}} report
203
+ * @returns {string}
204
+ */
205
+ export function describeBlocked(report) {
206
+ const byModel = new Map()
207
+ for (const item of report.blocked) {
208
+ const group = byModel.get(item.model) ?? { reason: item.reason, names: [] }
209
+ group.names.push(item.name)
210
+ byModel.set(item.model, group)
211
+ }
212
+ const lines = [...byModel].map(
213
+ ([model, { reason, names }]) => ` ${model} (${names.join(', ')}): ${reason}`,
214
+ )
215
+ return (
216
+ 'topology uses devices this eNSP installation cannot start:\n' +
217
+ lines.join('\n') +
218
+ '\n\nThis is not a bug in the topology: eNSP opens it, renders it, then quits. ' +
219
+ 'Install the missing device image in eNSP (工具 → 注册设备, or reinstall eNSP with ' +
220
+ 'the device component selected), or remove those devices from the topology.'
221
+ )
222
+ }
223
+
224
+ /** Forget cached catalogue reads (tests, and after installing a device image). */
225
+ export function clearCatalogueCache() {
226
+ cache.clear()
227
+ }