dsh-ensp 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +90 -16
- package/README.md +89 -15
- package/assets/icon.webp +0 -0
- package/docs/ENSP-FORMAT.md +8 -8
- package/docs/SCENARIOS.md +332 -286
- package/docs/UI-ANCHORS.md +21 -0
- package/docs/plugin-detail.en.md +2 -0
- package/docs/plugin-detail.zh.md +2 -0
- package/package.json +4 -4
- package/scripts/gen-scenarios-doc.mjs +7 -3
- package/scripts/survey-topologies.mjs +15 -6
- package/src/cli/ensp.mjs +252 -28
- package/src/core/acceptance.mjs +258 -53
- package/src/core/build.mjs +15 -1
- package/src/core/compat.mjs +249 -17
- package/src/core/console.mjs +4 -0
- package/src/core/doctor.mjs +9 -12
- package/src/core/docx.mjs +359 -0
- package/src/core/index.mjs +28 -9
- package/src/core/labconfig.mjs +98 -0
- package/src/core/labs.mjs +12 -12
- package/src/core/launcher.mjs +96 -37
- package/src/core/models.mjs +27 -18
- package/src/core/ooxml.mjs +430 -0
- package/src/core/paths.mjs +59 -10
- package/src/core/presets.mjs +9 -5
- package/src/core/report.mjs +411 -0
- package/src/dsh/index.mjs +626 -652
- package/src/mcp/server.mjs +213 -356
- package/src/tools.mjs +271 -0
- package/screenshots.json +0 -3
- package/scripts/gen-knowledge.mjs +0 -196
- package/src/core/knowledge.json +0 -959
- package/src/core/knowledge.mjs +0 -103
package/src/core/compat.mjs
CHANGED
|
@@ -22,28 +22,27 @@
|
|
|
22
22
|
* @module dsh-ensp/compat
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
25
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
|
|
28
28
|
import { resolveEnspRoot } from './paths.mjs'
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/** Last-resort install root, used only when nothing on this machine can be found. */
|
|
31
31
|
export const DEFAULT_ENSP_ROOT = 'E:\\Huawei\\eNSP'
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* Human wording for a plugin model whose image is missing.
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* The *paths* are never taken from here: they are read out of the VirtualBox
|
|
37
|
+
* templates that ship with the installation, so a machine that keeps eNSP
|
|
38
|
+
* somewhere else still gets the right answer. The relative path below is only a
|
|
39
|
+
* name to print in the message, and the reason text is what the user reads.
|
|
39
40
|
*/
|
|
40
41
|
const PLUGIN_ASSETS = {
|
|
41
|
-
USG6000V:
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
],
|
|
42
|
+
USG6000V: {
|
|
43
|
+
rel: 'plugin/ngfw/DataBase/vfw_usg.vdi',
|
|
44
|
+
why: 'NGFW 镜像未安装(eNSP 安装时需勾选 USG6000V 组件)',
|
|
45
|
+
},
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
/** Models the vendor has withdrawn; usable only if an image happens to exist. */
|
|
@@ -74,6 +73,88 @@ export function imageNotesFor(models) {
|
|
|
74
73
|
return [...new Set(models)].filter((m) => IMAGE_NOTES[m]).map((m) => [m, IMAGE_NOTES[m]])
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
/** `<HardDisk ... location="...">` inside a VirtualBox machine template. */
|
|
77
|
+
const MEDIA_TAG = /<HardDisk\b[^>]*\blocation="([^"]+)"/g
|
|
78
|
+
|
|
79
|
+
const TEMPLATE_EXT = new Set(['.vbox', '.xml'])
|
|
80
|
+
|
|
81
|
+
const mediaCache = new Map()
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every file a plugin model's VirtualBox template points at, as absolute paths.
|
|
85
|
+
*
|
|
86
|
+
* The plugin models (NE40E, NE5000E, NE9000, CX, CE6800, CE12800, USG6000V) each
|
|
87
|
+
* ship a VirtualBox machine template whose `<HardDisk location="...">` is relative
|
|
88
|
+
* to that template. The disk itself lives in a `Database` directory next to the
|
|
89
|
+
* plugin, but the exact spelling (`Database` vs `DataBase`) and the number of `..`
|
|
90
|
+
* differ per plugin, so the path is read out of the template every time instead of
|
|
91
|
+
* being reconstructed. A machine that installed eNSP somewhere else therefore gets
|
|
92
|
+
* the same answer as this one.
|
|
93
|
+
*
|
|
94
|
+
* Templates under a `backup` directory are ignored: they are stale copies kept by
|
|
95
|
+
* the vendor, and one of them (`vfw_usg_for_vbox4.0`) points at a layout no current
|
|
96
|
+
* installation has.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} model
|
|
99
|
+
* @param {{root?: string}} [options]
|
|
100
|
+
* @returns {string[]} absolute media paths, empty when the model has no template
|
|
101
|
+
*/
|
|
102
|
+
export function pluginMediaFiles(model, options = {}) {
|
|
103
|
+
const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
|
|
104
|
+
const entry = readCatalogue(root).get(model)
|
|
105
|
+
if (!entry?.plugin || !entry.pluginPath) return []
|
|
106
|
+
|
|
107
|
+
const key = `${root}\u0000${entry.pluginPath}`
|
|
108
|
+
if (mediaCache.has(key)) return mediaCache.get(key)
|
|
109
|
+
|
|
110
|
+
const dll = path.join(root, 'plugin', entry.pluginPath.split('\\').join('/'))
|
|
111
|
+
const found = []
|
|
112
|
+
for (const template of templatesUnder(path.dirname(dll))) {
|
|
113
|
+
let xml = ''
|
|
114
|
+
try {
|
|
115
|
+
xml = readFileSync(template, 'utf8')
|
|
116
|
+
} catch {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
for (const m of xml.matchAll(MEDIA_TAG)) {
|
|
120
|
+
found.push(path.resolve(path.dirname(template), m[1]))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const unique = [...new Set(found)]
|
|
125
|
+
mediaCache.set(key, unique)
|
|
126
|
+
return unique
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Collect `.vbox` / `.xml` templates below `dir`, skipping vendor backups.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} dir
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
function templatesUnder(dir) {
|
|
136
|
+
const out = []
|
|
137
|
+
const walk = (current) => {
|
|
138
|
+
let entries = []
|
|
139
|
+
try {
|
|
140
|
+
entries = readdirSync(current, { withFileTypes: true })
|
|
141
|
+
} catch {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const full = path.join(current, entry.name)
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
if (entry.name.toLowerCase() === 'backup') continue
|
|
148
|
+
walk(full)
|
|
149
|
+
} else if (TEMPLATE_EXT.has(path.extname(entry.name).toLowerCase())) {
|
|
150
|
+
out.push(full)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
walk(dir)
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
77
158
|
const cache = new Map()
|
|
78
159
|
|
|
79
160
|
/**
|
|
@@ -124,12 +205,17 @@ export function rootFromExe(exe) {
|
|
|
124
205
|
/**
|
|
125
206
|
* Can this model actually be started on this machine?
|
|
126
207
|
*
|
|
208
|
+
* `root` defaults to the installation found on this machine, not to a fixed
|
|
209
|
+
* path. A hardcoded `E:\Huawei\eNSP` made every check on a machine with a
|
|
210
|
+
* normal install read the wrong catalogue and look for device images that
|
|
211
|
+
* were never there, so a correctly installed USG6000V was reported missing.
|
|
212
|
+
*
|
|
127
213
|
* @param {string} model
|
|
128
214
|
* @param {{root?: string}} [options]
|
|
129
215
|
* @returns {{model: string, available: boolean, reason?: string}}
|
|
130
216
|
*/
|
|
131
217
|
export function probeDevice(model, options = {}) {
|
|
132
|
-
const root = options.root ?? DEFAULT_ENSP_ROOT
|
|
218
|
+
const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
|
|
133
219
|
const entry = readCatalogue(root).get(model)
|
|
134
220
|
|
|
135
221
|
if (entry?.plugin && entry.pluginPath) {
|
|
@@ -144,9 +230,28 @@ export function probeDevice(model, options = {}) {
|
|
|
144
230
|
// installed at all still cannot start a USG6000V, and answering "available"
|
|
145
231
|
// because items.xml was unreadable would be the exact false negative this module
|
|
146
232
|
// exists to prevent.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
233
|
+
const note = PLUGIN_ASSETS[model]
|
|
234
|
+
if (note) {
|
|
235
|
+
// Fall back to the documented relative path when no template survives, so an
|
|
236
|
+
// installation whose template was deleted but whose image is in place still
|
|
237
|
+
// starts instead of being blocked on a missing file that is right there.
|
|
238
|
+
const media = pluginMediaFiles(model, { root })
|
|
239
|
+
const candidates = media.length ? media : [path.join(root, note.rel)]
|
|
240
|
+
if (!candidates.some((file) => existsSync(file))) {
|
|
241
|
+
return { model, available: false, reason: note.why, missing: note.rel }
|
|
242
|
+
}
|
|
243
|
+
} else if (entry?.plugin) {
|
|
244
|
+
// Every other plugin model is judged the same way, from its own template. Only
|
|
245
|
+
// the wording differs, because there is no hand-written sentence for it.
|
|
246
|
+
const media = pluginMediaFiles(model, { root })
|
|
247
|
+
if (media.length && !media.some((file) => existsSync(file))) {
|
|
248
|
+
const named = media.map((file) => path.relative(root, file)).join(', ')
|
|
249
|
+
return {
|
|
250
|
+
model,
|
|
251
|
+
available: false,
|
|
252
|
+
reason: `设备镜像未安装:缺少 ${named}`,
|
|
253
|
+
missing: named,
|
|
254
|
+
}
|
|
150
255
|
}
|
|
151
256
|
}
|
|
152
257
|
|
|
@@ -230,7 +335,134 @@ export function describeBlocked(report) {
|
|
|
230
335
|
)
|
|
231
336
|
}
|
|
232
337
|
|
|
233
|
-
/** Forget cached catalogue reads (tests, and after installing a device image). */
|
|
338
|
+
/** Forget cached catalogue and template reads (tests, and after installing a device image). */
|
|
234
339
|
export function clearCatalogueCache() {
|
|
235
340
|
cache.clear()
|
|
341
|
+
mediaCache.clear()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Every plugin model this installation knows about, sorted.
|
|
346
|
+
*
|
|
347
|
+
* Read out of `res/items.xml`, so it grows and shrinks with the installation
|
|
348
|
+
* instead of being a list maintained here.
|
|
349
|
+
*
|
|
350
|
+
* @param {{root?: string}} [options]
|
|
351
|
+
* @returns {string[]}
|
|
352
|
+
*/
|
|
353
|
+
export function pluginModels(options = {}) {
|
|
354
|
+
const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
|
|
355
|
+
return [...readCatalogue(root)]
|
|
356
|
+
.filter(([, entry]) => entry.plugin && entry.pluginPath)
|
|
357
|
+
.map(([model]) => model)
|
|
358
|
+
.sort()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* What a plugin model needs on disk, and what of that is actually there.
|
|
363
|
+
*
|
|
364
|
+
* `media` is derived from the model's own VirtualBox template, so the answer holds
|
|
365
|
+
* on any machine regardless of where eNSP is installed. `target` is where an image
|
|
366
|
+
* has to go if one is missing: the first path the template names.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} model
|
|
369
|
+
* @param {{root?: string}} [options]
|
|
370
|
+
* @returns {{model: string, plugin: boolean, target: string|null,
|
|
371
|
+
* media: Array<{path: string, present: boolean, relative: string}>, ready: boolean}}
|
|
372
|
+
*/
|
|
373
|
+
export function pluginMediaPlan(model, options = {}) {
|
|
374
|
+
const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
|
|
375
|
+
const entry = readCatalogue(root).get(model)
|
|
376
|
+
const template = pluginMediaFiles(model, { root })
|
|
377
|
+
const media = template.map((file) => ({
|
|
378
|
+
path: file,
|
|
379
|
+
relative: path.relative(root, file),
|
|
380
|
+
present: existsSync(file),
|
|
381
|
+
}))
|
|
382
|
+
return {
|
|
383
|
+
model,
|
|
384
|
+
plugin: Boolean(entry?.plugin),
|
|
385
|
+
target: media.length ? media[0].path : null,
|
|
386
|
+
media,
|
|
387
|
+
ready: media.some((item) => item.present),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Put a device image where this machine's eNSP expects it.
|
|
393
|
+
*
|
|
394
|
+
* The destination is not passed in: it is the path the model's VirtualBox template
|
|
395
|
+
* already points at, which is the only place the emulator will look. Copying
|
|
396
|
+
* elsewhere and hoping is how people end up with a firewall that opens a topology
|
|
397
|
+
* and then quits. The plugin's `system.ini` is updated as well when it has an
|
|
398
|
+
* `ImgFile` line, because that is what the plugin reads for single-image models.
|
|
399
|
+
*
|
|
400
|
+
* @param {string} model
|
|
401
|
+
* @param {string} source image file to install
|
|
402
|
+
* @param {{root?: string, overwrite?: boolean}} [options]
|
|
403
|
+
* @returns {{model: string, target: string, bytes: number, iniUpdated: boolean}}
|
|
404
|
+
*/
|
|
405
|
+
export function installPluginMedia(model, source, options = {}) {
|
|
406
|
+
const root = options.root ?? resolveEnspRoot() ?? DEFAULT_ENSP_ROOT
|
|
407
|
+
if (!existsSync(source)) throw new Error(`the image file does not exist: ${source}`)
|
|
408
|
+
|
|
409
|
+
const plan = pluginMediaPlan(model, { root })
|
|
410
|
+
if (!plan.plugin) throw new Error(`${model} is not a plugin model in this eNSP installation`)
|
|
411
|
+
if (!plan.target) {
|
|
412
|
+
throw new Error(
|
|
413
|
+
`${model} has no VirtualBox template under ${root}, so there is nowhere to put an image. ` +
|
|
414
|
+
'The installation looks incomplete; reinstall eNSP.',
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
if (plan.ready && !options.overwrite) {
|
|
418
|
+
return { model, target: plan.target, bytes: 0, iniUpdated: false }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
mkdirSync(path.dirname(plan.target), { recursive: true })
|
|
422
|
+
copyFileSync(source, plan.target)
|
|
423
|
+
|
|
424
|
+
// `<plugin>/System/system.ini` carries `ImgFile =` for the plugins that boot a
|
|
425
|
+
// single image, and an empty value there is why a correctly placed image can
|
|
426
|
+
// still leave the device unusable.
|
|
427
|
+
const iniUpdated = updateImgFile(path.join(root, 'plugin', pluginDir(plan.target, root) ?? '', 'System', 'system.ini'), path.basename(plan.target))
|
|
428
|
+
|
|
429
|
+
mediaCache.clear()
|
|
430
|
+
cache.clear()
|
|
431
|
+
return { model, target: plan.target, bytes: statSync(plan.target).size, iniUpdated }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The plugin directory an image sits under (`plugin/<name>/...`), or null. */
|
|
435
|
+
function pluginDir(file, root) {
|
|
436
|
+
const relative = path.relative(path.join(root, 'plugin'), file)
|
|
437
|
+
if (relative.startsWith('..')) return null
|
|
438
|
+
return relative.split(path.sep)[0]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Point `ImgFile` in a plugin `system.ini` at a file that now exists.
|
|
443
|
+
*
|
|
444
|
+
* Left alone when the file or the key is absent: some plugin versions do not use
|
|
445
|
+
* it, and inventing a section would be worse than doing nothing.
|
|
446
|
+
*
|
|
447
|
+
* @param {string} ini
|
|
448
|
+
* @param {string} value
|
|
449
|
+
* @returns {boolean} whether the file was changed
|
|
450
|
+
*/
|
|
451
|
+
function updateImgFile(ini, value) {
|
|
452
|
+
if (!ini || !existsSync(ini)) return false
|
|
453
|
+
let text = ''
|
|
454
|
+
try {
|
|
455
|
+
text = readFileSync(ini, 'utf8')
|
|
456
|
+
} catch {
|
|
457
|
+
return false
|
|
458
|
+
}
|
|
459
|
+
if (!/^ImgFile\s*=/m.test(text)) return false
|
|
460
|
+
const next = text.replace(/^ImgFile\s*=.*$/m, `ImgFile = ${value}`)
|
|
461
|
+
if (next === text) return false
|
|
462
|
+
try {
|
|
463
|
+
writeFileSync(ini, next)
|
|
464
|
+
return true
|
|
465
|
+
} catch {
|
|
466
|
+
return false
|
|
467
|
+
}
|
|
236
468
|
}
|
package/src/core/console.mjs
CHANGED
|
@@ -504,6 +504,10 @@ export function openConsole({ port, host = '127.0.0.1', label, timeoutMs = 10000
|
|
|
504
504
|
/**
|
|
505
505
|
* Probe a console port without keeping a session.
|
|
506
506
|
*
|
|
507
|
+
* Kept as the documented way to ask "is VRP answering on this port" for callers
|
|
508
|
+
* that need the prompt; `waitForConsole` in `launcher.mjs` is the polling form
|
|
509
|
+
* used while devices boot.
|
|
510
|
+
*
|
|
507
511
|
* @param {{port: number, host?: string, timeoutMs?: number}} options
|
|
508
512
|
* @returns {Promise<{port: number, reachable: boolean, prompt: string|null, error?: string}>}
|
|
509
513
|
*/
|
package/src/core/doctor.mjs
CHANGED
|
@@ -303,25 +303,30 @@ export function doctor(options = {}) {
|
|
|
303
303
|
checks.push(check('anchors', 'warn', `not measured: ${error.message}`, 'Run `ensp probe` with eNSP visible.'))
|
|
304
304
|
}
|
|
305
305
|
} else {
|
|
306
|
+
// Nothing was measured, so there is nothing to report on. Comparing the two
|
|
307
|
+
// fallback constants with each other only proves they are ordered, which is
|
|
308
|
+
// true by construction and says nothing about this machine.
|
|
306
309
|
const a = DEFAULT_ANCHORS.start
|
|
307
310
|
const b = DEFAULT_ANCHORS.stop
|
|
308
311
|
checks.push(
|
|
309
312
|
check(
|
|
310
313
|
'anchors',
|
|
311
|
-
|
|
312
|
-
`fallback start (${a.x},${a.y}) stop (${b.x},${b.y})
|
|
313
|
-
|
|
314
|
+
'skip',
|
|
315
|
+
`not measured: eNSP is not running; fallback is start (${a.x},${a.y}) stop (${b.x},${b.y})`,
|
|
316
|
+
'Open eNSP, load a topology and run `ensp doctor --topology <file>` to measure the toolbar.',
|
|
314
317
|
),
|
|
315
318
|
)
|
|
316
319
|
}
|
|
317
320
|
|
|
318
321
|
const failed = checks.filter((c) => c.status === 'fail')
|
|
319
322
|
const warned = checks.filter((c) => c.status === 'warn')
|
|
323
|
+
const skipped = checks.filter((c) => c.status === 'skip')
|
|
320
324
|
return {
|
|
321
325
|
ok: failed.length === 0,
|
|
322
326
|
checks,
|
|
323
327
|
failed: failed.map((c) => c.id),
|
|
324
328
|
warned: warned.map((c) => c.id),
|
|
329
|
+
skipped: skipped.map((c) => c.id),
|
|
325
330
|
}
|
|
326
331
|
}
|
|
327
332
|
|
|
@@ -339,7 +344,7 @@ function safeWindow() {
|
|
|
339
344
|
|
|
340
345
|
/** Render a doctor report for a terminal. */
|
|
341
346
|
export function formatDoctor(report) {
|
|
342
|
-
const mark = { pass: 'ok ', warn: 'warn', fail: 'FAIL' }
|
|
347
|
+
const mark = { pass: 'ok ', warn: 'warn', fail: 'FAIL', skip: 'skip' }
|
|
343
348
|
const lines = []
|
|
344
349
|
for (const c of report.checks) {
|
|
345
350
|
lines.push(`[${mark[c.status] || c.status}] ${c.id}: ${c.detail}`)
|
|
@@ -354,12 +359,6 @@ export function formatDoctor(report) {
|
|
|
354
359
|
return lines.join('\n')
|
|
355
360
|
}
|
|
356
361
|
|
|
357
|
-
/** Convenience wrapper used by the CLI, MCP server and plugin. */
|
|
358
|
-
export function checkEnvironment(options = {}) {
|
|
359
|
-
const report = doctor(options)
|
|
360
|
-
return { ...report, text: formatDoctor(report) }
|
|
361
|
-
}
|
|
362
|
-
|
|
363
362
|
/**
|
|
364
363
|
* Models a topology needs, so `doctor()` can check the right images.
|
|
365
364
|
* Accepts a parsed topology (from `readTopology`).
|
|
@@ -368,5 +367,3 @@ export function modelsOf(topology) {
|
|
|
368
367
|
const devices = topology?.devices ?? []
|
|
369
368
|
return [...new Set(devices.map((d) => d.model))].sort()
|
|
370
369
|
}
|
|
371
|
-
|
|
372
|
-
/** Free-memory constant, exported so tests can assert against it. */
|