dsh-ensp 0.1.3 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,299 +1,218 @@
1
- /**
2
- * Topology construction helpers.
3
- *
4
- * Building a `.topo` by hand is error-prone because eNSP validates a surprising
5
- * amount of derived state on load. The rules below were reverse-engineered from
6
- * files eNSP itself wrote and confirmed by opening generated topologies
7
- * (`docs/UI-ANCHORS.md` records the surrounding experiments):
8
- *
9
- * | Rule | Value |
10
- * |---|---|
11
- * | XML declaration | says `encoding="UNICODE"`, file is actually UTF-8 |
12
- * | `edit_left` / `edit_top` | `cx + 27` / `cy + 54` |
13
- * | attribute order | `id name model settings com_port bootmode cx cy edit_left edit_top` |
14
- * | switch interfaces | one slot, `interfacename="GE"`, index base 1 |
15
- * | PC / Server interfaces | `interfacename="Ethernet"`, index base 1 |
16
- * | router / firewall interfaces | `interfacename="GE"`, index base 0 |
17
- * | cable anchors | on the icon's bounding box: `x-16.2` / `x+70.2`, `y-16.2` / `y+70.2` |
18
- *
19
- * Two mistakes cost real debugging time and are called out in the code:
20
- * declaring PC interfaces as `GE` (eNSP silently renders an empty canvas) and
21
- * omitting `system_mac` handling (the attribute is absent from native files).
22
- *
23
- * @module dsh-ensp/build
24
- */
25
-
26
- import { randomUUID } from 'node:crypto'
27
-
28
- import { serialiseTopology, interfaceIndex } from './topology.mjs'
29
-
30
- /**
31
- * Interface declarations per model, in slot order, plus the index base.
32
- *
33
- * `prefix` is what eNSP writes in `interfacename=`; `vrp` is the name the device
34
- * itself uses, which differs for switches (`GE` in the file, `GigabitEthernet`
35
- * on the CLI).
36
- */
37
- /**
38
- * Interface declarations per model, **in the exact grouping eNSP writes**.
39
- *
40
- * This grouping is not cosmetic. eNSP reconstructs a device's port list from these
41
- * `<interface>` elements; a single `count="3"` on an AR2220 does not parse into
42
- * GE0/0/0 + GE0/0/1..2 and the device silently fails to render (empty canvas, no
43
- * error). The groups below were counted across all 40 bundled sample topologies.
44
- *
45
- * `vrp` is the CLI prefix; indices walk the groups in order from 0.
46
- */
47
- export const MODEL_SPECS = {
48
- // 24 GE ports, all in one group.
49
- S5700: { groups: [['Ethernet', 'GE', 24, 1]], base: 1, vrp: 'GigabitEthernet0/0/', console: true },
50
- // 22 fast ports (Ethernet0/0/1..22) then 2 gigabit (GE0/0/1..2).
51
- S3700: {
52
- groups: [['Ethernet', 'Ethernet', 22, 1], ['Ethernet', 'GE', 2, 1]],
53
- base: 1,
54
- vrp: 'Ethernet0/0/',
55
- console: true,
56
- },
57
- // 1 + 2: GE0/0/0, then GE0/0/1..2 — the split is required, and the second group
58
- // starts numbering at 1, not 0.
59
- AR2220: { groups: [['Ethernet', 'GE', 1, 0], ['Ethernet', 'GE', 2, 1]], base: 0, vrp: 'GigabitEthernet0/0/', console: true },
60
- AR2240: { groups: [['Ethernet', 'GE', 4, 0]], base: 0, vrp: 'GigabitEthernet0/0/', console: true },
61
- // 2 GE (GE0/0/0..1) + 8 Ethernet (Ethernet0/0/0..7).
62
- AR1220: {
63
- groups: [['Ethernet', 'GE', 2, 0], ['Ethernet', 'Ethernet', 8, 0]],
64
- base: 0,
65
- vrp: 'GigabitEthernet0/0/',
66
- console: true,
67
- },
68
- USG5500: { groups: [['Ethernet', 'GE', 9, 0]], base: 0, vrp: 'GigabitEthernet1/0/', console: true },
69
- USG6000V: { groups: [['Ethernet', 'GE', 9, 0]], base: 0, vrp: 'GigabitEthernet1/0/', console: true },
70
- AC6005: { groups: [['Ethernet', 'GE', 8, 1]], base: 1, vrp: 'GigabitEthernet0/0/', console: true },
71
- // NOTE: the file says `interfacename="GE"` but the CLI port is Ethernet0/0/1 —
72
- // eNSP's file vocabulary and VRP's CLI vocabulary differ for this model.
73
- // Declares `interfacename="GE"` but the CLI port is Ethernet0/0/1 — eNSP's file
74
- // vocabulary and VRP's vocabulary differ here, so the CLI family is explicit.
75
- PC: { groups: [['Ethernet', 'GE', 1, 1, 'Ethernet0/0/']], base: 1, vrp: 'Ethernet0/0/', console: false },
76
- Server: { groups: [['Ethernet', 'GE', 1, 1, 'Ethernet0/0/']], base: 1, vrp: 'Ethernet0/0/', console: false },
77
- Cloud: { groups: [['Ethernet', 'Ethernet', 3, 0]], base: 0, vrp: 'Ethernet0/0/', console: false },
78
- }
79
-
80
- /** Total ports across groups. */
81
- function totalPorts(spec) {
82
- return spec.groups.reduce((n, g) => n + g[2], 0)
83
- }
84
-
85
- /**
86
- * Resolve a VRP interface name to the flat index eNSP stores.
87
- *
88
- * The flat index walks every declared group, so on an AR1220 index 2 is the first
89
- * `Ethernet` port, not `GE0/0/2`.
90
- */
91
- export function vrpToFlatIndex(model, port) {
92
- const spec = MODEL_SPECS[model]
93
- if (!spec) return null
94
-
95
- // A model can expose several CLI families (AR1220: GE for its 2 gigabit ports,
96
- // Ethernet for its 8 fast ports), and the flat index simply continues across them
97
- // in declaration order. Walk the groups and test each group's own CLI prefix.
98
- let offset = 0
99
- for (const group of spec.groups) {
100
- const [, enspPrefix, count] = group
101
- const firstNumber = group[3] ?? spec.base
102
- const cliPrefix = group[4] ?? cliPrefixFor(enspPrefix)
103
- if (port.startsWith(cliPrefix)) {
104
- const tail = port.slice(cliPrefix.length)
105
- if (/^\d+$/.test(tail)) {
106
- const n = Number(tail) - firstNumber
107
- if (n >= 0 && n < count) return offset + n
108
- }
109
- }
110
- offset += count
111
- }
112
- return null
113
- }
114
-
115
- /** CLI prefix for an eNSP interface-name group. */
116
- function cliPrefixFor(enspPrefix) {
117
- return enspPrefix === 'GE' ? 'GigabitEthernet0/0/' : 'Ethernet0/0/'
118
- }
119
-
120
- /** Canvas geometry, matching eNSP's own writer. */
121
- export const ICON = { w: 86, h: 86, left: -16.2, right: 70.2, top: -16.2, bottom: 70.2 }
122
-
123
- /**
124
- * `settings` blob eNSP writes for a simulated PC. Empty for every other model.
125
- * Without it eNSP still shows the PC but with no address, which breaks the
126
- * "ping between the two PCs" step.
127
- */
128
- function pcSettings({ ip = '192.168.10.1', mask = '255.255.255.0', gateway = '192.168.10.254', mac } = {}) {
129
- const macAddr = mac ?? `54-89-98-${Array.from({ length: 3 }, () =>
130
- Math.floor(Math.random() * 256).toString(16).toUpperCase().padStart(2, '0')).join('-')}`
131
- return (
132
- ` -simpc_ip ${ip} -simpc_mask ${mask} -simpc_gateway ${gateway} -simpc_mac ${macAddr}` +
133
- ' -simpc_mc_dstip 0.0.0.0 -simpc_mc_dstmac 00-00-00-00-00-00' +
134
- ' -simpc_dns1 0.0.0.0 -simpc_dns2 0.0.0.0' +
135
- ' -simpc_ipv6 :: -simpc_prefix 128 -simpc_gatewayv6 ::' +
136
- ' -simpc_dhcp_state 0 -simpc_dhcpv6_state 0 -simpc_dns_auto_state 0 '
137
- )
138
- }
139
-
140
- /**
141
- * Fluent topology builder.
142
- */
143
- export class TopologyBuilder {
144
- #devices = []
145
- #links = []
146
- #nextComPort
147
-
148
- constructor({ firstComPort = 2000 } = {}) {
149
- this.#nextComPort = firstComPort
150
- }
151
-
152
- /**
153
- * Add a device.
154
- *
155
- * @param {string} name
156
- * @param {string} model - key of {@link MODEL_SPECS}
157
- * @param {{x?: number, y?: number, comPort?: number|null, bootmode?: number,
158
- * pc?: {ip?: string, mask?: string, gateway?: string, mac?: string}}} [options]
159
- */
160
- add(name, model, options = {}) {
161
- const spec = MODEL_SPECS[model]
162
- if (!spec) throw new Error(`unknown model "${model}"; known: ${Object.keys(MODEL_SPECS).join(', ')}`)
163
- if (this.#devices.some((d) => d.name === name)) throw new Error(`duplicate device name "${name}"`)
164
-
165
- const comPort =
166
- options.comPort !== undefined
167
- ? options.comPort
168
- : spec.console
169
- ? this.#nextComPort++
170
- : 0
171
-
172
- this.#devices.push({
173
- id: randomUUID(),
174
- name,
175
- model,
176
- settings: model === 'PC' ? pcSettings(options.pc) : '',
177
- comPort,
178
- bootmode: options.bootmode ?? 1,
179
- x: options.x ?? 100,
180
- y: options.y ?? 100,
181
- })
182
- return this
183
- }
184
-
185
- /**
186
- * Connect two devices using **VRP interface names** (what you type on the CLI).
187
- *
188
- * @param {string} aName
189
- * @param {string} aPort e.g. `GigabitEthernet0/0/1` or `Ethernet0/0/1`
190
- * @param {string} bName
191
- * @param {string} bPort
192
- * @param {{media?: string}} [options]
193
- */
194
- link(aName, aPort, bName, bPort, options = {}) {
195
- const a = this.#devices.find((d) => d.name === aName)
196
- const b = this.#devices.find((d) => d.name === bName)
197
- if (!a) throw new Error(`no device "${aName}"`)
198
- if (!b) throw new Error(`no device "${bName}"`)
199
-
200
- const ai = this.#vrpToIndex(a, aPort)
201
- const bi = this.#vrpToIndex(b, bPort)
202
-
203
- this.#links.push({
204
- srcId: a.id,
205
- dstId: b.id,
206
- srcIndex: ai,
207
- dstIndex: bi,
208
- media: options.media ?? 'Copper',
209
- })
210
- return this
211
- }
212
-
213
- /** Map a VRP interface name to the flat slot index eNSP stores. */
214
- #vrpToIndex(device, port) {
215
- const spec = MODEL_SPECS[device.model]
216
- if (!spec) throw new Error(`unknown model "${device.model}"`)
217
- const index = vrpToFlatIndex(device.model, port)
218
- if (index === null) {
219
- const all = spec.groups
220
- .map((g) => {
221
- const first = g[3] ?? spec.base
222
- return `${cliPrefixFor(g[1])}${first}..${first + g[2] - 1}`
223
- })
224
- .join(', ')
225
- throw new Error(
226
- `${device.name} (${device.model}) has no interface ${port}; expected one of: ${all}`,
227
- )
228
- }
229
- return index
230
- }
231
-
232
- finish() {
233
- return {
234
- devices: this.#devices.map((d) => ({ ...d })),
235
- links: this.#links.map((l) => ({ ...l })),
236
- }
237
- }
238
-
239
- toXml() {
240
- return serialiseTopology(this.finish())
241
- }
242
- }
243
-
244
- /** Start a builder. */
245
- export function buildTopology(options) {
246
- return new TopologyBuilder(options)
247
- }
248
-
249
- /**
250
- * Deterministic grid layout.
251
- * @param {object} topo
252
- */
253
- export function autoLayout(topo, { columns = 4, paddingX = 220, paddingY = 200, originX = 120, originY = 80 } = {}) {
254
- topo.devices.forEach((device, i) => {
255
- device.x = originX + (i % columns) * paddingX
256
- device.y = originY + Math.floor(i / columns) * paddingY
257
- })
258
- return topo
259
- }
260
-
261
- /**
262
- * Two PCs on one Layer-2 switch — the smallest lab that proves the whole chain
263
- * (generate → open → boot → configure → verify).
264
- *
265
- * @param {{vlan?: number, subnet?: string}} [options]
266
- */
267
- export function twoPcLab({ vlan = 10, subnet = '192.168.10' } = {}) {
268
- const topo = buildTopology({ firstComPort: 2000 })
269
- .add('SW1', 'S5700', { x: 300, y: 120 })
270
- .add('PC1', 'PC', { x: 180, y: 320, pc: { ip: `${subnet}.1`, gateway: `${subnet}.254` } })
271
- .add('PC2', 'PC', { x: 420, y: 320, pc: { ip: `${subnet}.2`, gateway: `${subnet}.254` } })
272
- .link('PC1', 'Ethernet0/0/1', 'SW1', 'GigabitEthernet0/0/1')
273
- .link('PC2', 'Ethernet0/0/1', 'SW1', 'GigabitEthernet0/0/2')
274
- .finish()
275
-
276
- topo.meta = { vlan, subnet, pc1: `${subnet}.1`, pc2: `${subnet}.2` }
277
- return topo
278
- }
279
-
280
- /**
281
- * Switch configuration for {@link twoPcLab}: both ports into one VLAN.
282
- * @param {{vlan?: number}} [options]
283
- */
284
- export function twoPcLabConfig({ vlan = 10 } = {}) {
285
- return [
286
- 'system-view',
287
- `vlan ${vlan}`,
288
- ' quit',
289
- 'interface GigabitEthernet0/0/1',
290
- ' port link-type access',
291
- ` port default vlan ${vlan}`,
292
- ' quit',
293
- 'interface GigabitEthernet0/0/2',
294
- ' port link-type access',
295
- ` port default vlan ${vlan}`,
296
- ' quit',
297
- 'return',
298
- ].join('\n')
299
- }
1
+ /**
2
+ * Topology construction helpers.
3
+ *
4
+ * Building a `.topo` by hand is error-prone because eNSP validates a surprising
5
+ * amount of derived state on load. The rules below were reverse-engineered from
6
+ * files eNSP itself wrote and confirmed by opening generated topologies
7
+ * (`docs/UI-ANCHORS.md` records the surrounding experiments):
8
+ *
9
+ * | Rule | Value |
10
+ * |---|---|
11
+ * | XML declaration | says `encoding="UNICODE"`, file is actually UTF-8 |
12
+ * | `edit_left` / `edit_top` | `cx + 27` / `cy + 54` |
13
+ * | attribute order | `id name model settings com_port bootmode cx cy edit_left edit_top` |
14
+ * | switch interfaces | one slot, `interfacename="GE"`, index base 1 |
15
+ * | PC / Server interfaces | `interfacename="Ethernet"`, index base 1 |
16
+ * | router / firewall interfaces | `interfacename="GE"`, index base 0 |
17
+ * | cable anchors | on the icon's bounding box: `x-16.2` / `x+70.2`, `y-16.2` / `y+70.2` |
18
+ *
19
+ * Two mistakes cost real debugging time and are called out in the code:
20
+ * declaring PC interfaces as `GE` (eNSP silently renders an empty canvas) and
21
+ * omitting `system_mac` handling (the attribute is absent from native files).
22
+ *
23
+ * @module dsh-ensp/build
24
+ */
25
+
26
+ import { randomUUID } from 'node:crypto'
27
+
28
+ import {
29
+ MODEL_SPECS,
30
+ ICON,
31
+ describePorts,
32
+ vrpToFlatIndex,
33
+ } from './models.mjs'
34
+ import { serialiseTopology } from './topology.mjs'
35
+
36
+ // Re-exported so callers that only need the table plus the builder keep one
37
+ // import. The authoritative definitions live in models.mjs.
38
+ export { MODEL_SPECS, ICON, vrpToFlatIndex, flatIndexToVrp } from './models.mjs'
39
+
40
+ /**
41
+ * `settings` blob eNSP writes for a simulated PC. Empty for every other model.
42
+ * Without it eNSP still shows the PC but with no address, which breaks the
43
+ * "ping between the two PCs" step.
44
+ */
45
+ function pcSettings({ ip = '192.168.10.1', mask = '255.255.255.0', gateway = '192.168.10.254', mac, dhcp = false } = {}) {
46
+ const macAddr = mac ?? `54-89-98-${Array.from({ length: 3 }, () =>
47
+ Math.floor(Math.random() * 256).toString(16).toUpperCase().padStart(2, '0')).join('-')}`
48
+ // A DHCP client must not carry a static address, or eNSP keeps using it and the
49
+ // pool is never exercised. -simpc_dhcp_state 1 is what makes the PC ask.
50
+ const addressing = dhcp
51
+ ? ' -simpc_ip 0.0.0.0 -simpc_mask 0.0.0.0 -simpc_gateway 0.0.0.0'
52
+ : ` -simpc_ip ${ip} -simpc_mask ${mask} -simpc_gateway ${gateway}`
53
+ return (
54
+ `${addressing} -simpc_mac ${macAddr}` +
55
+ ' -simpc_mc_dstip 0.0.0.0 -simpc_mc_dstmac 00-00-00-00-00-00' +
56
+ ' -simpc_dns1 0.0.0.0 -simpc_dns2 0.0.0.0' +
57
+ ' -simpc_ipv6 :: -simpc_prefix 128 -simpc_gatewayv6 ::' +
58
+ ` -simpc_dhcp_state ${dhcp ? 1 : 0} -simpc_dhcpv6_state 0 -simpc_dns_auto_state 0 `
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Fluent topology builder.
64
+ */
65
+ export class TopologyBuilder {
66
+ #devices = []
67
+ #links = []
68
+ #nextComPort
69
+
70
+ constructor({ firstComPort = 2000 } = {}) {
71
+ this.#nextComPort = firstComPort
72
+ }
73
+
74
+ /**
75
+ * Add a device.
76
+ *
77
+ * @param {string} name
78
+ * @param {string} model - key of {@link MODEL_SPECS}
79
+ * @param {{x?: number, y?: number, comPort?: number|null, bootmode?: number,
80
+ * pc?: {ip?: string, mask?: string, gateway?: string, mac?: string}}} [options]
81
+ */
82
+ add(name, model, options = {}) {
83
+ const spec = MODEL_SPECS[model]
84
+ if (!spec) throw new Error(`unknown model "${model}"; known: ${Object.keys(MODEL_SPECS).join(', ')}`)
85
+ if (this.#devices.some((d) => d.name === name)) throw new Error(`duplicate device name "${name}"`)
86
+
87
+ const comPort =
88
+ options.comPort !== undefined
89
+ ? options.comPort
90
+ : spec.console
91
+ ? this.#nextComPort++
92
+ : 0
93
+
94
+ this.#devices.push({
95
+ id: randomUUID(),
96
+ name,
97
+ model,
98
+ settings: model === 'PC' ? pcSettings(options.pc).trim() : '',
99
+ comPort,
100
+ bootmode: options.bootmode ?? 1,
101
+ x: options.x ?? 100,
102
+ y: options.y ?? 100,
103
+ })
104
+ return this
105
+ }
106
+
107
+ /**
108
+ * Connect two devices using **VRP interface names** (what you type on the CLI).
109
+ *
110
+ * @param {string} aName
111
+ * @param {string} aPort e.g. `GigabitEthernet0/0/1` or `Ethernet0/0/1`
112
+ * @param {string} bName
113
+ * @param {string} bPort
114
+ * @param {{media?: string}} [options]
115
+ */
116
+ link(aName, aPort, bName, bPort, options = {}) {
117
+ const a = this.#devices.find((d) => d.name === aName)
118
+ const b = this.#devices.find((d) => d.name === bName)
119
+ if (!a) throw new Error(`no device "${aName}"`)
120
+ if (!b) throw new Error(`no device "${bName}"`)
121
+
122
+ const ai = this.#vrpToIndex(a, aPort)
123
+ const bi = this.#vrpToIndex(b, bPort)
124
+
125
+ this.#links.push({
126
+ srcId: a.id,
127
+ dstId: b.id,
128
+ srcIndex: ai,
129
+ dstIndex: bi,
130
+ media: options.media ?? 'Copper',
131
+ })
132
+ return this
133
+ }
134
+
135
+ /** Map a VRP interface name to the flat slot index eNSP stores. */
136
+ #vrpToIndex(device, port) {
137
+ const spec = MODEL_SPECS[device.model]
138
+ if (!spec) throw new Error(`unknown model "${device.model}"`)
139
+ const index = vrpToFlatIndex(device.model, port)
140
+ if (index === null) {
141
+ // Firewalls and PCs override the CLI family, so the shared helper is the
142
+ // only reliable way to describe what this model actually accepts.
143
+ const all = describePorts(device.model)
144
+ throw new Error(
145
+ `${device.name} (${device.model}) has no interface ${port}; expected one of: ${all}`,
146
+ )
147
+ }
148
+ return index
149
+ }
150
+
151
+ finish() {
152
+ return {
153
+ devices: this.#devices.map((d) => ({ ...d })),
154
+ links: this.#links.map((l) => ({ ...l })),
155
+ }
156
+ }
157
+
158
+ toXml() {
159
+ return serialiseTopology(this.finish())
160
+ }
161
+ }
162
+
163
+ /** Start a builder. */
164
+ export function buildTopology(options) {
165
+ return new TopologyBuilder(options)
166
+ }
167
+
168
+ /**
169
+ * Deterministic grid layout.
170
+ * @param {object} topo
171
+ */
172
+ export function autoLayout(topo, { columns = 4, paddingX = 220, paddingY = 200, originX = 120, originY = 80 } = {}) {
173
+ topo.devices.forEach((device, i) => {
174
+ device.x = originX + (i % columns) * paddingX
175
+ device.y = originY + Math.floor(i / columns) * paddingY
176
+ })
177
+ return topo
178
+ }
179
+
180
+ /**
181
+ * Two PCs on one Layer-2 switch — the smallest lab that proves the whole chain
182
+ * (generate → open → boot → configure → verify).
183
+ *
184
+ * @param {{vlan?: number, subnet?: string}} [options]
185
+ */
186
+ export function twoPcLab({ vlan = 10, subnet = '192.168.10' } = {}) {
187
+ const topo = buildTopology({ firstComPort: 2000 })
188
+ .add('SW1', 'S5700', { x: 300, y: 120 })
189
+ .add('PC1', 'PC', { x: 180, y: 320, pc: { ip: `${subnet}.1`, gateway: `${subnet}.254` } })
190
+ .add('PC2', 'PC', { x: 420, y: 320, pc: { ip: `${subnet}.2`, gateway: `${subnet}.254` } })
191
+ .link('PC1', 'Ethernet0/0/1', 'SW1', 'GigabitEthernet0/0/1')
192
+ .link('PC2', 'Ethernet0/0/1', 'SW1', 'GigabitEthernet0/0/2')
193
+ .finish()
194
+
195
+ topo.meta = { vlan, subnet, pc1: `${subnet}.1`, pc2: `${subnet}.2` }
196
+ return topo
197
+ }
198
+
199
+ /**
200
+ * Switch configuration for {@link twoPcLab}: both ports into one VLAN.
201
+ * @param {{vlan?: number}} [options]
202
+ */
203
+ export function twoPcLabConfig({ vlan = 10 } = {}) {
204
+ return [
205
+ 'system-view',
206
+ `vlan ${vlan}`,
207
+ ' quit',
208
+ 'interface GigabitEthernet0/0/1',
209
+ ' port link-type access',
210
+ ` port default vlan ${vlan}`,
211
+ ' quit',
212
+ 'interface GigabitEthernet0/0/2',
213
+ ' port link-type access',
214
+ ` port default vlan ${vlan}`,
215
+ ' quit',
216
+ 'return',
217
+ ].join('\n')
218
+ }