packet-tracer-skill 0.2.2 → 0.3.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/CHANGELOG.md +424 -42
- package/README.md +535 -250
- package/SKILL.md +337 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/automation-controller-proof.md +35 -0
- package/docs/curated-donor-registry.md +11 -0
- package/docs/generate-ready-pilot-design.md +30 -0
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/industrial-programming-proof.md +48 -0
- package/docs/ipv4-routing-management-proof.md +37 -0
- package/docs/l2-resiliency-bgp-proof.md +60 -0
- package/docs/l2-security-qos-proof.md +59 -0
- package/docs/packet-tracer-feature-gap-atlas.md +174 -17
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +27 -13
- package/docs/release-notes-0.2.2.md +1 -1
- package/docs/release-notes-0.2.3.md +59 -0
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +65 -0
- package/docs/voice-collaboration-proof.md +38 -0
- package/docs/wan-security-donor-proof.md +20 -3
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +60 -44
- package/pytest.ini +9 -0
- package/references/packettracer-feature-atlas.json +67 -17
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +24 -44
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1319
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/feature_atlas.py +65 -1
- package/scripts/generate_pkt.py +8762 -4070
- package/scripts/intent_parser.py +2242 -1138
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2405 -1226
- package/scripts/pkt_transformer.py +1072 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/remote_search.py +197 -21
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1372 -1195
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
package/scripts/pkt_editor.py
CHANGED
|
@@ -1,1226 +1,2405 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import copy
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
from
|
|
11
|
-
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
for
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if
|
|
70
|
-
continue
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if
|
|
446
|
-
|
|
447
|
-
if
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if
|
|
473
|
-
|
|
474
|
-
if
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
)
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
)
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
for line in
|
|
688
|
-
|
|
689
|
-
if
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
if
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
return
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
return
|
|
935
|
-
|
|
936
|
-
for
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
def
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
"
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
def
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
if
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
continue
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
(
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
(
|
|
1200
|
-
(
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
from intent_parser import IntentPlan
|
|
13
|
+
from packet_tracer_env import resolve_sample_path
|
|
14
|
+
from sample_catalog import normalize_device_type
|
|
15
|
+
from pkt_codec import decode_pkt_auto, decode_pkt_modern, encode_pkt_modern, parse_pkt_xml, serialize_pkt_xml
|
|
16
|
+
from pkt_transformer import _device_type, _port_address_for_name, apply_cable_type, apply_host_ip, load_sample_root
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
FTP_SAMPLE = r"01 Networking\FTP\FTP.pkt"
|
|
20
|
+
SERVER_SAMPLE = r"01 Networking\DNS\Multilevel_DNS.pkt"
|
|
21
|
+
WIRELESS_SAMPLE = r"01 Networking\DHCP\dhcp_reservation.pkt"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=32)
|
|
25
|
+
def _decoded_pkt_xml(path_key: str, size: int, mtime_ns: int) -> bytes:
|
|
26
|
+
"""Decoded XML for a `.pkt`, cached on the file's identity.
|
|
27
|
+
|
|
28
|
+
Donor evaluation decodes the same donor several times per run, and decoding
|
|
29
|
+
costs ~42x more than parsing the result. Only the immutable bytes are
|
|
30
|
+
cached; every caller still gets a fresh tree, because callers mutate it.
|
|
31
|
+
"""
|
|
32
|
+
xml, _container = decode_pkt_auto(Path(path_key).read_bytes())
|
|
33
|
+
return xml
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decode_pkt_to_root(pkt_path: str | Path) -> ET.Element:
|
|
37
|
+
path = Path(pkt_path)
|
|
38
|
+
try:
|
|
39
|
+
stat = path.stat()
|
|
40
|
+
except OSError:
|
|
41
|
+
return parse_pkt_xml(decode_pkt_auto(path.read_bytes())[0])
|
|
42
|
+
return parse_pkt_xml(_decoded_pkt_xml(str(path), stat.st_size, stat.st_mtime_ns))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def inventory_devices(root: ET.Element) -> list[dict[str, str]]:
|
|
46
|
+
devices: list[dict[str, str]] = []
|
|
47
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
48
|
+
devices.append(
|
|
49
|
+
{
|
|
50
|
+
"name": device.findtext("./ENGINE/NAME", default=""),
|
|
51
|
+
"type": _device_type(device),
|
|
52
|
+
"model": device.find("./ENGINE/TYPE").get("model", "") if device.find("./ENGINE/TYPE") is not None else "",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return devices
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def inventory_links(root: ET.Element) -> list[dict[str, object]]:
|
|
59
|
+
devices = root.findall(".//DEVICES/DEVICE")
|
|
60
|
+
index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(devices)}
|
|
61
|
+
save_ref_to_name = {
|
|
62
|
+
device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
|
|
63
|
+
for device in devices
|
|
64
|
+
if device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
65
|
+
}
|
|
66
|
+
result: list[dict[str, object]] = []
|
|
67
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
68
|
+
cable = link.find("./CABLE")
|
|
69
|
+
if cable is None:
|
|
70
|
+
continue
|
|
71
|
+
ports = cable.findall("PORT")
|
|
72
|
+
from_ref = cable.findtext("FROM", default="")
|
|
73
|
+
to_ref = cable.findtext("TO", default="")
|
|
74
|
+
result.append(
|
|
75
|
+
{
|
|
76
|
+
"from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
|
|
77
|
+
"to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
|
|
78
|
+
"ports": [port.text or "" for port in ports],
|
|
79
|
+
"media": cable.findtext("TYPE", default=""),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def inventory_services(root: ET.Element) -> dict[str, list[str]]:
|
|
86
|
+
result: dict[str, list[str]] = {}
|
|
87
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
88
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
89
|
+
engine = device.find("./ENGINE")
|
|
90
|
+
if engine is None:
|
|
91
|
+
continue
|
|
92
|
+
enabled: list[str] = []
|
|
93
|
+
for tag, enabled_tag in [
|
|
94
|
+
("HTTP_SERVER", "ENABLED"),
|
|
95
|
+
("HTTPS_SERVER", "HTTPSENABLED"),
|
|
96
|
+
("DNS_SERVER", "ENABLED"),
|
|
97
|
+
("DHCP_SERVER", "ENABLED"),
|
|
98
|
+
("TFTP_SERVER", "ENABLED"),
|
|
99
|
+
("FTP_SERVER", "ENABLED"),
|
|
100
|
+
("NTP_SERVER", "ENABLED"),
|
|
101
|
+
("SYSLOG_SERVER", "ENABLED"),
|
|
102
|
+
("ACS_SERVER", "ENABLED"),
|
|
103
|
+
]:
|
|
104
|
+
node = engine.find(tag)
|
|
105
|
+
if node is not None and node.findtext(enabled_tag, default="0") in {"1", "true", "True"}:
|
|
106
|
+
enabled.append(tag.lower())
|
|
107
|
+
email_server = engine.find("EMAIL_SERVER")
|
|
108
|
+
if email_server is not None and any(
|
|
109
|
+
email_server.findtext(tag, default="0") in {"1", "true", "True"}
|
|
110
|
+
for tag in ["SMTP_ENABLED", "POP3_ENABLED"]
|
|
111
|
+
):
|
|
112
|
+
enabled.append("email_server")
|
|
113
|
+
if enabled:
|
|
114
|
+
result[name] = enabled
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def inventory_service_details(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
119
|
+
result: dict[str, dict[str, object]] = {}
|
|
120
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
121
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
122
|
+
engine = device.find("./ENGINE")
|
|
123
|
+
if engine is None:
|
|
124
|
+
continue
|
|
125
|
+
details: dict[str, object] = {}
|
|
126
|
+
email_server = engine.find("EMAIL_SERVER")
|
|
127
|
+
if email_server is not None:
|
|
128
|
+
domain = email_server.findtext("SMTP_DOMAIN", default="")
|
|
129
|
+
if domain:
|
|
130
|
+
details["email_domain"] = domain
|
|
131
|
+
user_count = email_server.findtext("NO_OF_USERS", default="")
|
|
132
|
+
if user_count:
|
|
133
|
+
details["email_user_count"] = user_count
|
|
134
|
+
acs_server = engine.find("ACS_SERVER")
|
|
135
|
+
if acs_server is not None:
|
|
136
|
+
auth_port = acs_server.findtext("./RADIUS_SETTINGS/AUTH_PORT", default="")
|
|
137
|
+
if auth_port:
|
|
138
|
+
details["aaa_auth_port"] = auth_port
|
|
139
|
+
if details:
|
|
140
|
+
result[name] = details
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def inventory_wireless(root: ET.Element) -> dict[str, dict[str, str]]:
|
|
145
|
+
result: dict[str, dict[str, str]] = {}
|
|
146
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
147
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
148
|
+
engine = device.find("./ENGINE")
|
|
149
|
+
if engine is None:
|
|
150
|
+
continue
|
|
151
|
+
if engine.find("WIRELESS_SERVER") is not None:
|
|
152
|
+
common = engine.find("./WIRELESS_SERVER/WIRELESS_COMMON")
|
|
153
|
+
result[name] = {
|
|
154
|
+
"mode": "ap",
|
|
155
|
+
"ssid": common.findtext("SSID", default="") if common is not None else "",
|
|
156
|
+
}
|
|
157
|
+
if engine.find("./CAPWAP_AC/WLANS/WLAN_CONFIG") is not None:
|
|
158
|
+
wlan = engine.find("./CAPWAP_AC/WLANS/WLAN_CONFIG")
|
|
159
|
+
result[name] = {
|
|
160
|
+
"mode": "controller",
|
|
161
|
+
"ssid": wlan.findtext("SSID", default="") if wlan is not None else "",
|
|
162
|
+
}
|
|
163
|
+
if engine.find("WIRELESS_CLIENT") is not None:
|
|
164
|
+
common = engine.find("./WIRELESS_CLIENT/WIRELESS_COMMON")
|
|
165
|
+
result[name] = {
|
|
166
|
+
"mode": "client",
|
|
167
|
+
"ssid": common.findtext("SSID", default="") if common is not None else "",
|
|
168
|
+
}
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def inventory_iot(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
173
|
+
result: dict[str, dict[str, object]] = {}
|
|
174
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
175
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
176
|
+
engine = device.find("./ENGINE")
|
|
177
|
+
if engine is None:
|
|
178
|
+
continue
|
|
179
|
+
raw_type = engine.findtext("TYPE", default="")
|
|
180
|
+
tags = {child.tag for child in list(engine)}
|
|
181
|
+
iot_role = None
|
|
182
|
+
if raw_type in {"HomeGateway", "WirelessRouterNewGeneration"}:
|
|
183
|
+
iot_role = "gateway"
|
|
184
|
+
elif raw_type in {"IoT", "MCUComponent", "Board", "Sensor", "Actuator"}:
|
|
185
|
+
iot_role = "thing"
|
|
186
|
+
elif {"IOE_USER_MANAGER", "IOX_SEVICE", "IOX_VM_MANAGER"} & tags:
|
|
187
|
+
iot_role = "server"
|
|
188
|
+
if iot_role is None:
|
|
189
|
+
continue
|
|
190
|
+
result[name] = {
|
|
191
|
+
"role": iot_role,
|
|
192
|
+
"type": raw_type,
|
|
193
|
+
"wireless_ssid": engine.findtext("./WIRELESS_CLIENT/WIRELESS_COMMON/SSID", default=""),
|
|
194
|
+
"http_enabled": engine.findtext("./HTTP_SERVER/ENABLED", default="0") in {"1", "true", "True"},
|
|
195
|
+
"client_mode": engine.findtext("./IOE_CLIENT/CLIENT_MODE", default=""),
|
|
196
|
+
"server_address": engine.findtext("./IOE_CLIENT/SERVER_ADDRESS", default=""),
|
|
197
|
+
"username": engine.findtext("./IOE_CLIENT/USERNAME", default=""),
|
|
198
|
+
}
|
|
199
|
+
if iot_role == "server":
|
|
200
|
+
rules: list[dict[str, object]] = []
|
|
201
|
+
for node in engine.findall("./IOE_USER_MANAGER/USERS/USER/IOE_RULES/IOE_RULE/JSON"):
|
|
202
|
+
try:
|
|
203
|
+
payload = json.loads(node.text or "{}")
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
continue
|
|
206
|
+
if payload.get("name"):
|
|
207
|
+
rules.append({"name": str(payload["name"]), "enabled": bool(payload.get("enabled", False))})
|
|
208
|
+
if rules:
|
|
209
|
+
result[name]["rules"] = rules
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _script_language(app_name: str, file_name: str, content: str) -> str:
|
|
214
|
+
lowered = " ".join([app_name.lower(), file_name.lower(), content[:500].lower()])
|
|
215
|
+
if file_name.lower().endswith(".py") or "python" in lowered:
|
|
216
|
+
return "python"
|
|
217
|
+
if file_name.lower().endswith(".js") or "javascript" in lowered:
|
|
218
|
+
return "javascript"
|
|
219
|
+
if file_name.lower().endswith(".visual") or "<xml" in lowered or "blockly" in lowered:
|
|
220
|
+
return "visual"
|
|
221
|
+
return "unknown"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _script_feature_tags(app_name: str, file_name: str, content: str) -> list[str]:
|
|
225
|
+
lowered = " ".join([app_name.lower(), file_name.lower(), content.lower()])
|
|
226
|
+
tags: set[str] = set()
|
|
227
|
+
if "mqtt" in lowered:
|
|
228
|
+
tags.add("mqtt")
|
|
229
|
+
if "realhttp" in lowered or "real http" in lowered:
|
|
230
|
+
tags.add("real_http")
|
|
231
|
+
if "realws" in lowered or "websocket" in lowered:
|
|
232
|
+
tags.add("real_websocket")
|
|
233
|
+
language = _script_language(app_name, file_name, content)
|
|
234
|
+
if language == "python":
|
|
235
|
+
tags.add("python_programming")
|
|
236
|
+
if language == "javascript":
|
|
237
|
+
tags.add("javascript_programming")
|
|
238
|
+
if language == "visual":
|
|
239
|
+
tags.add("blockly_programming")
|
|
240
|
+
if "tcp" in lowered or "udp" in lowered:
|
|
241
|
+
tags.add("tcp_udp_app")
|
|
242
|
+
return sorted(tags)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _file_text(file_node: ET.Element) -> str:
|
|
246
|
+
return file_node.findtext("./FILE_CONTENT/TEXT", default="")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def inventory_programming(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
250
|
+
result: dict[str, dict[str, object]] = {}
|
|
251
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
252
|
+
device_name = device.findtext("./ENGINE/NAME", default="")
|
|
253
|
+
apps: list[dict[str, object]] = []
|
|
254
|
+
for directory in device.findall(".//FILE[@class='CDirectory']"):
|
|
255
|
+
app_name = directory.findtext("NAME", default="").strip()
|
|
256
|
+
if not app_name:
|
|
257
|
+
continue
|
|
258
|
+
files: list[dict[str, object]] = []
|
|
259
|
+
app_tags: set[str] = set()
|
|
260
|
+
for file_node in directory.findall(".//FILE[@class='CFile']"):
|
|
261
|
+
file_name = file_node.findtext("NAME", default="").strip()
|
|
262
|
+
content = _file_text(file_node)
|
|
263
|
+
if not file_name or not content:
|
|
264
|
+
continue
|
|
265
|
+
language = _script_language(app_name, file_name, content)
|
|
266
|
+
feature_tags = _script_feature_tags(app_name, file_name, content)
|
|
267
|
+
app_tags.update(feature_tags)
|
|
268
|
+
files.append(
|
|
269
|
+
{
|
|
270
|
+
"name": file_name,
|
|
271
|
+
"language": language,
|
|
272
|
+
"content_length": len(content),
|
|
273
|
+
"content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
274
|
+
"feature_tags": feature_tags,
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
if files:
|
|
278
|
+
apps.append(
|
|
279
|
+
{
|
|
280
|
+
"app_name": app_name,
|
|
281
|
+
"file_count": len(files),
|
|
282
|
+
"feature_tags": sorted(app_tags),
|
|
283
|
+
"files": files,
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
if apps:
|
|
287
|
+
result[device_name] = {
|
|
288
|
+
"app_count": len(apps),
|
|
289
|
+
"feature_tags": sorted({tag for app in apps for tag in list(app.get("feature_tags", []))}),
|
|
290
|
+
"apps": apps,
|
|
291
|
+
}
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def inventory_vlans(root: ET.Element) -> dict[str, list[dict[str, str]]]:
|
|
296
|
+
result: dict[str, list[dict[str, str]]] = {}
|
|
297
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
298
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
299
|
+
vlans: list[dict[str, str]] = []
|
|
300
|
+
for vlan in device.findall(".//VLANS/VLAN"):
|
|
301
|
+
vlan_id = vlan.get("number") or vlan.findtext("ID", default="")
|
|
302
|
+
vlan_name = vlan.get("name") or vlan.findtext("NAME", default="")
|
|
303
|
+
if vlan_id:
|
|
304
|
+
vlans.append({"id": vlan_id, "name": vlan_name})
|
|
305
|
+
if vlans:
|
|
306
|
+
result[name] = vlans
|
|
307
|
+
continue
|
|
308
|
+
running = "\n".join(line.text or "" for line in device.findall(".//LINE"))
|
|
309
|
+
inferred: list[dict[str, str]] = []
|
|
310
|
+
for match in re.finditer(r"(?mi)^vlan\s+(\d+)\s*$", running):
|
|
311
|
+
vlan_id = match.group(1)
|
|
312
|
+
name_match = re.search(rf"(?mi)^vlan\s+{re.escape(vlan_id)}\s*$\n^\s*name\s+(.+?)\s*$", running)
|
|
313
|
+
inferred.append({"id": vlan_id, "name": name_match.group(1).strip() if name_match else ""})
|
|
314
|
+
if inferred:
|
|
315
|
+
unique: dict[str, dict[str, str]] = {entry["id"]: entry for entry in inferred}
|
|
316
|
+
result[name] = list(unique.values())
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def inventory_dhcp_pools(root: ET.Element) -> dict[str, list[str]]:
|
|
321
|
+
pools: dict[str, list[str]] = {}
|
|
322
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
323
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
324
|
+
device_pools: list[str] = []
|
|
325
|
+
for pool in device.findall(".//DHCP_SERVER/POOLS/POOL/NAME"):
|
|
326
|
+
if pool.text:
|
|
327
|
+
device_pools.append(pool.text)
|
|
328
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
329
|
+
for match in re.findall(r"ip dhcp pool\s+([A-Za-z0-9_-]+)", running):
|
|
330
|
+
if match not in device_pools:
|
|
331
|
+
device_pools.append(match)
|
|
332
|
+
if device_pools:
|
|
333
|
+
pools[name] = device_pools
|
|
334
|
+
return pools
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def inventory_acl_names(root: ET.Element) -> dict[str, list[str]]:
|
|
338
|
+
result: dict[str, list[str]] = {}
|
|
339
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
340
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
341
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
342
|
+
matches = re.findall(r"ip access-list\s+(?:standard|extended)\s+([A-Za-z0-9_-]+)", running)
|
|
343
|
+
if matches:
|
|
344
|
+
result[name] = sorted(dict.fromkeys(matches))
|
|
345
|
+
return result
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def inventory_management(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
349
|
+
result: dict[str, dict[str, object]] = {}
|
|
350
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
351
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
352
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
353
|
+
if not running:
|
|
354
|
+
continue
|
|
355
|
+
management_vlans = sorted(
|
|
356
|
+
{
|
|
357
|
+
match.group(1)
|
|
358
|
+
for match in re.finditer(r"(?mi)^interface\s+Vlan(\d+)\s*$", running)
|
|
359
|
+
},
|
|
360
|
+
key=int,
|
|
361
|
+
)
|
|
362
|
+
usernames = sorted(
|
|
363
|
+
{
|
|
364
|
+
match.group(1)
|
|
365
|
+
for match in re.finditer(r"(?mi)^username\s+(\S+)\s+(?:password|secret)\s+\S+\s*$", running)
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
default_gateway_match = re.search(r"(?mi)^ip default-gateway\s+(\S+)\s*$", running)
|
|
369
|
+
telnet_enabled = bool(re.search(r"(?mi)^\s*transport input .*telnet.*$", running))
|
|
370
|
+
enable_secret_present = bool(re.search(r"(?mi)^enable secret\s+\S+\s*$", running))
|
|
371
|
+
if not any([management_vlans, usernames, default_gateway_match, telnet_enabled, enable_secret_present]):
|
|
372
|
+
continue
|
|
373
|
+
result[name] = {
|
|
374
|
+
"management_vlans": management_vlans,
|
|
375
|
+
"default_gateway": default_gateway_match.group(1) if default_gateway_match else "",
|
|
376
|
+
"telnet_enabled": telnet_enabled,
|
|
377
|
+
"usernames": usernames,
|
|
378
|
+
"enable_secret_present": enable_secret_present,
|
|
379
|
+
}
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def inventory_routing(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
384
|
+
result: dict[str, dict[str, object]] = {}
|
|
385
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
386
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
387
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
388
|
+
if not running:
|
|
389
|
+
continue
|
|
390
|
+
capabilities: set[str] = set()
|
|
391
|
+
if re.search(r"(?mi)^\s*ipv6\s+unicast-routing\s*$", running) or re.search(r"(?mi)^\s*ipv6\s+(?:enable|address)\b", running):
|
|
392
|
+
capabilities.add("ipv6_slaac")
|
|
393
|
+
if re.search(r"(?mi)^\s*ipv6\s+dhcp\s+pool\b", running) or re.search(r"(?mi)^\s*ipv6\s+dhcp\s+server\b", running):
|
|
394
|
+
capabilities.add("dhcpv6_stateful")
|
|
395
|
+
if re.search(r"(?mi)^\s*ipv6\s+ospf\b", running) or re.search(r"(?mi)^\s*ipv6\s+router\s+ospf\b", running):
|
|
396
|
+
capabilities.add("ospfv3")
|
|
397
|
+
if re.search(r"(?mi)^\s*ipv6\s+eigrp\b", running) or re.search(r"(?mi)^\s*ipv6\s+router\s+eigrp\b", running):
|
|
398
|
+
capabilities.add("eigrp_ipv6")
|
|
399
|
+
if re.search(r"(?mi)^\s*ipv6\s+rip\b", running):
|
|
400
|
+
capabilities.add("ripng")
|
|
401
|
+
if re.search(r"(?mi)^\s*standby\s+\d+\s+ipv6\b", running):
|
|
402
|
+
capabilities.add("hsrp")
|
|
403
|
+
if re.search(r"(?mi)^\s*interface\s+Tunnel\d+\b", running) or re.search(r"(?mi)^\s*tunnel\s+(?:source|destination|mode\s+gre)\b", running):
|
|
404
|
+
capabilities.add("gre")
|
|
405
|
+
if re.search(r"(?mi)^\s*encapsulation\s+ppp\b", running):
|
|
406
|
+
capabilities.add("ppp")
|
|
407
|
+
if re.search(r"(?mi)^\s*crypto\s+ipsec\s+transform-set\b", running) or re.search(r"(?mi)^\s*crypto\s+map\b", running):
|
|
408
|
+
capabilities.add("ipsec")
|
|
409
|
+
if re.search(r"(?mi)^\s*crypto\s+map\b", running):
|
|
410
|
+
capabilities.add("vpn")
|
|
411
|
+
if re.search(r"(?mi)^\s*ip\s+inspect\s+name\b", running):
|
|
412
|
+
capabilities.add("cbac")
|
|
413
|
+
if (
|
|
414
|
+
re.search(r"(?mi)^zone\s+security\b", running)
|
|
415
|
+
or re.search(r"(?mi)^zone-pair\s+security\b", running)
|
|
416
|
+
or re.search(r"(?mi)^class-map\s+type\s+inspect\b", running)
|
|
417
|
+
or re.search(r"(?mi)^policy-map\s+type\s+inspect\b", running)
|
|
418
|
+
):
|
|
419
|
+
capabilities.add("zfw")
|
|
420
|
+
if capabilities:
|
|
421
|
+
result[name] = {"capabilities": sorted(capabilities)}
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def inventory_ipv4_routing_management(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
426
|
+
result: dict[str, dict[str, object]] = {}
|
|
427
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
428
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
429
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
430
|
+
if not running:
|
|
431
|
+
continue
|
|
432
|
+
capabilities: set[str] = set()
|
|
433
|
+
if re.search(r"(?mi)^\s*router\s+ospf\s+\d+\s*$", running):
|
|
434
|
+
capabilities.add("ospfv2")
|
|
435
|
+
if re.search(r"(?mi)^\s*router\s+eigrp\s+\d+\s*$", running):
|
|
436
|
+
capabilities.add("eigrp_ipv4")
|
|
437
|
+
if re.search(r"(?mi)^\s*router\s+rip\s*$", running):
|
|
438
|
+
capabilities.add("ripv2")
|
|
439
|
+
if re.search(r"(?mi)^\s*ip\s+route\s+\S+\s+\S+\s+\S+", running):
|
|
440
|
+
capabilities.add("static_route")
|
|
441
|
+
if re.search(r"(?mi)^\s*ip\s+route\s+0\.0\.0\.0\s+0\.0\.0\.0\s+\S+", running):
|
|
442
|
+
capabilities.add("default_route")
|
|
443
|
+
if re.search(r"(?mi)^\s*ip\s+helper-address\s+\d+\.\d+\.\d+\.\d+\s*$", running):
|
|
444
|
+
capabilities.add("dhcp_relay")
|
|
445
|
+
if re.search(r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+static\b", running):
|
|
446
|
+
capabilities.update({"nat_static", "nat"})
|
|
447
|
+
if re.search(r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+(?:list|route-map)\b", running):
|
|
448
|
+
capabilities.update({"nat_dynamic", "nat"})
|
|
449
|
+
if re.search(r"(?mi)^\s*ip\s+nat\s+inside\s+source\s+list\s+\S+\s+interface\s+\S+\s+overload\b", running):
|
|
450
|
+
capabilities.update({"pat", "nat"})
|
|
451
|
+
if re.search(r"(?mi)^\s*ip\s+ssh\b", running) or re.search(r"(?mi)^\s*crypto\s+key\s+generate\s+rsa\b", running):
|
|
452
|
+
capabilities.add("ssh_ios")
|
|
453
|
+
if re.search(r"(?mi)^\s*ntp\s+server\s+\d+\.\d+\.\d+\.\d+\s*$", running):
|
|
454
|
+
capabilities.add("ntp_ios")
|
|
455
|
+
if re.search(r"(?mi)^\s*logging\s+host\s+\d+\.\d+\.\d+\.\d+\s*$", running):
|
|
456
|
+
capabilities.add("syslog_ios")
|
|
457
|
+
if capabilities:
|
|
458
|
+
result[name] = {"capabilities": sorted(capabilities)}
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def inventory_l2_security_monitoring(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
463
|
+
result: dict[str, dict[str, object]] = {}
|
|
464
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
465
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
466
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
467
|
+
if not running:
|
|
468
|
+
continue
|
|
469
|
+
capabilities: set[str] = set()
|
|
470
|
+
if re.search(r"(?mi)^\s*ip\s+dhcp\s+snooping\b", running):
|
|
471
|
+
capabilities.add("dhcp_snooping")
|
|
472
|
+
if re.search(r"(?mi)^\s*ip\s+arp\s+inspection\b", running):
|
|
473
|
+
capabilities.add("dai")
|
|
474
|
+
if re.search(r"(?mi)^\s*(?:aaa\s+new-model|radius-server\s+host|authentication\s+port-control|dot1x\b)", running):
|
|
475
|
+
capabilities.add("dot1x")
|
|
476
|
+
if re.search(r"(?mi)^\s*lldp\s+run\b", running):
|
|
477
|
+
capabilities.add("lldp")
|
|
478
|
+
if re.search(r"(?mi)^\s*rep\s+segment\b", running):
|
|
479
|
+
capabilities.add("rep")
|
|
480
|
+
if re.search(r"(?mi)^\s*snmp-server\b", running):
|
|
481
|
+
capabilities.add("snmp")
|
|
482
|
+
if re.search(r"(?mi)^\s*ip\s+flow-export\b", running) or re.search(r"(?mi)^\s*ip\s+flow\s+(?:ingress|egress)\b", running):
|
|
483
|
+
capabilities.add("netflow")
|
|
484
|
+
if re.search(r"(?mi)^\s*monitor\s+session\b", running):
|
|
485
|
+
capabilities.add("span")
|
|
486
|
+
if re.search(r"(?mi)^\s*(?:mls\s+qos|auto\s+qos|class-map|policy-map|service-policy)\b", running):
|
|
487
|
+
capabilities.add("qos")
|
|
488
|
+
if re.search(r"(?mi)^\s*switchport\s+port-security\b", running):
|
|
489
|
+
capabilities.add("port_security")
|
|
490
|
+
if capabilities:
|
|
491
|
+
result[name] = {"capabilities": sorted(capabilities)}
|
|
492
|
+
return result
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def inventory_l2_resiliency_routing(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
496
|
+
result: dict[str, dict[str, object]] = {}
|
|
497
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
498
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
499
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
500
|
+
if not running:
|
|
501
|
+
continue
|
|
502
|
+
capabilities: set[str] = set()
|
|
503
|
+
if re.search(r"(?mi)^\s*router\s+bgp\s+\d+\s*$", running) or re.search(r"(?mi)^\s*neighbor\s+\S+\s+remote-as\s+\d+\s*$", running):
|
|
504
|
+
capabilities.add("bgp")
|
|
505
|
+
if re.search(r"(?mi)^\s*spanning-tree\b", running):
|
|
506
|
+
capabilities.add("stp")
|
|
507
|
+
if re.search(r"(?mi)^\s*spanning-tree\s+mode\s+(?:rapid-pvst|rstp)\b", running) or re.search(r"(?mi)\brapid-pvst\b", running):
|
|
508
|
+
capabilities.add("rstp")
|
|
509
|
+
if re.search(r"(?mi)^\s*interface\s+Port-channel\d+\b", running) or re.search(r"(?mi)^\s*channel-group\s+\d+\s+mode\b", running):
|
|
510
|
+
capabilities.add("etherchannel")
|
|
511
|
+
if re.search(r"(?mi)^\s*channel-group\s+\d+\s+mode\s+(?:active|passive)\b", running):
|
|
512
|
+
capabilities.add("lacp")
|
|
513
|
+
if re.search(r"(?mi)^\s*channel-group\s+\d+\s+mode\s+(?:desirable|auto)\b", running):
|
|
514
|
+
capabilities.add("pagp")
|
|
515
|
+
if re.search(r"(?mi)^\s*vtp\s+(?:domain|mode|version)\b", running):
|
|
516
|
+
capabilities.add("vtp")
|
|
517
|
+
if re.search(r"(?mi)^\s*switchport\s+mode\s+dynamic\s+(?:desirable|auto)\b", running) or re.search(r"(?mi)^\s*switchport\s+nonegotiate\b", running):
|
|
518
|
+
capabilities.add("dtp")
|
|
519
|
+
if capabilities:
|
|
520
|
+
result[name] = {"capabilities": sorted(capabilities)}
|
|
521
|
+
return result
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def inventory_voice(root: ET.Element) -> dict[str, dict[str, object]]:
|
|
525
|
+
result: dict[str, dict[str, object]] = {}
|
|
526
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
527
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
528
|
+
device_type = _device_type(device)
|
|
529
|
+
model = device.find("./ENGINE/TYPE").get("model", "") if device.find("./ENGINE/TYPE") is not None else ""
|
|
530
|
+
running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
531
|
+
capabilities: set[str] = set()
|
|
532
|
+
details: dict[str, object] = {"device_type": device_type, "model": model}
|
|
533
|
+
if device_type in {"IpPhone", "HomeVoip", "AnalogPhone"}:
|
|
534
|
+
capabilities.add("voip")
|
|
535
|
+
if device_type == "IpPhone":
|
|
536
|
+
capabilities.add("ip_phone")
|
|
537
|
+
if running:
|
|
538
|
+
extensions = sorted(dict.fromkeys(re.findall(r"(?mi)^\s*number\s+([A-Za-z0-9*+#.-]+)\s*$", running)))
|
|
539
|
+
ephones = sorted(dict.fromkeys(re.findall(r"(?mi)^ephone\s+(\d+)\s*$", running)), key=int)
|
|
540
|
+
dial_peers = sorted(dict.fromkeys(re.findall(r"(?mi)^dial-peer\s+voice\s+(\d+)\b", running)), key=int)
|
|
541
|
+
source_match = re.search(r"(?mi)^\s*ip\s+source-address\s+(\d+\.\d+\.\d+\.\d+)\s+port\s+(\d+)\s*$", running)
|
|
542
|
+
if re.search(r"(?mi)^telephony-service\s*$", running) or extensions or ephones:
|
|
543
|
+
capabilities.update({"voip", "call_manager", "ip_phone"})
|
|
544
|
+
if dial_peers:
|
|
545
|
+
capabilities.add("voip")
|
|
546
|
+
if extensions:
|
|
547
|
+
details["extensions"] = extensions
|
|
548
|
+
if ephones:
|
|
549
|
+
details["ephones"] = ephones
|
|
550
|
+
if dial_peers:
|
|
551
|
+
details["dial_peers"] = dial_peers
|
|
552
|
+
if source_match:
|
|
553
|
+
details["source_address"] = source_match.group(1)
|
|
554
|
+
details["source_port"] = int(source_match.group(2))
|
|
555
|
+
if capabilities:
|
|
556
|
+
details["capabilities"] = sorted(capabilities)
|
|
557
|
+
result[name] = details
|
|
558
|
+
return result
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def inventory_topology_summary(root: ET.Element) -> dict[str, object]:
|
|
562
|
+
devices = inventory_devices(root)
|
|
563
|
+
counts: dict[str, int] = {}
|
|
564
|
+
for device in devices:
|
|
565
|
+
counts[device["type"]] = counts.get(device["type"], 0) + 1
|
|
566
|
+
return {
|
|
567
|
+
"device_counts": counts,
|
|
568
|
+
"link_count": len(inventory_links(root)),
|
|
569
|
+
"has_wireless": bool(inventory_wireless(root)),
|
|
570
|
+
"has_services": bool(inventory_services(root)),
|
|
571
|
+
"has_vlans": bool(inventory_vlans(root)),
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def inventory_root(root: ET.Element) -> dict[str, object]:
|
|
576
|
+
return {
|
|
577
|
+
"devices": inventory_devices(root),
|
|
578
|
+
"links": inventory_links(root),
|
|
579
|
+
"services": inventory_services(root),
|
|
580
|
+
"service_details": inventory_service_details(root),
|
|
581
|
+
"wireless": inventory_wireless(root),
|
|
582
|
+
"iot": inventory_iot(root),
|
|
583
|
+
"vlans": inventory_vlans(root),
|
|
584
|
+
"dhcp_pools": inventory_dhcp_pools(root),
|
|
585
|
+
"acl_names": inventory_acl_names(root),
|
|
586
|
+
"management": inventory_management(root),
|
|
587
|
+
"routing": inventory_routing(root),
|
|
588
|
+
"ipv4_routing_management": inventory_ipv4_routing_management(root),
|
|
589
|
+
"l2_security_monitoring": inventory_l2_security_monitoring(root),
|
|
590
|
+
"l2_resiliency_routing": inventory_l2_resiliency_routing(root),
|
|
591
|
+
"voice": inventory_voice(root),
|
|
592
|
+
"programming": inventory_programming(root),
|
|
593
|
+
"topology_summary": inventory_topology_summary(root),
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _find_device(root: ET.Element, name: str) -> ET.Element | None:
|
|
598
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
599
|
+
if device.findtext("./ENGINE/NAME", default="") == name:
|
|
600
|
+
return device
|
|
601
|
+
return None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _ensure_text(parent: ET.Element, tag: str, value: str) -> ET.Element:
|
|
605
|
+
node = parent.find(tag)
|
|
606
|
+
if node is None:
|
|
607
|
+
node = ET.SubElement(parent, tag)
|
|
608
|
+
node.text = value
|
|
609
|
+
return node
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _replace_lines(target: ET.Element, lines: list[str]) -> None:
|
|
613
|
+
target.clear()
|
|
614
|
+
for line in lines:
|
|
615
|
+
node = ET.SubElement(target, "LINE")
|
|
616
|
+
node.text = line
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _splice_into_config(existing: list[str], additions: list[str]) -> list[str]:
|
|
620
|
+
"""Place new configuration where IOS will actually read it.
|
|
621
|
+
|
|
622
|
+
A running config ends with `end`, and everything appended after it is
|
|
623
|
+
ignored. Measured live: a generated lab carried
|
|
624
|
+
|
|
625
|
+
end
|
|
626
|
+
ip dhcp pool LAN
|
|
627
|
+
network 192.168.1.0 255.255.255.0
|
|
628
|
+
default-router 192.168.1.1
|
|
629
|
+
|
|
630
|
+
and its hosts sat on APIPA addresses, because the router had no pool at all.
|
|
631
|
+
The file looked configured; the device was not.
|
|
632
|
+
|
|
633
|
+
Interface settings were never affected -- those are written into a block
|
|
634
|
+
that already exists, ahead of `end` -- so the fault only ever showed on
|
|
635
|
+
newly added global configuration, which is why it looked intermittent.
|
|
636
|
+
"""
|
|
637
|
+
if not additions:
|
|
638
|
+
return list(existing)
|
|
639
|
+
|
|
640
|
+
# Before `end` is not far enough. Splicing there put `ip dhcp pool LAN`
|
|
641
|
+
# after `line vty 0 4 / login`, and the pool still did nothing: the parser
|
|
642
|
+
# is replaying a config, and a global command arriving while it is inside a
|
|
643
|
+
# `line` submode has nowhere sensible to go. Cisco's own labs put the pool
|
|
644
|
+
# near the top -- line 13 of a 68-line config, with the first interface at
|
|
645
|
+
# line 36 -- so that is where new configuration belongs.
|
|
646
|
+
for index, line in enumerate(existing):
|
|
647
|
+
if line.startswith("interface "):
|
|
648
|
+
return [*existing[:index], *additions, *existing[index:]]
|
|
649
|
+
for index in range(len(existing) - 1, -1, -1):
|
|
650
|
+
if existing[index].strip() == "end":
|
|
651
|
+
return [*existing[:index], *additions, *existing[index:]]
|
|
652
|
+
return [*existing, *additions]
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# Globals a device can only have one of. Everything else at the top level --
|
|
656
|
+
# `ip route`, `access-list`, `network`, `snmp-server host` -- legitimately
|
|
657
|
+
# repeats, so replacement has to be by name rather than a blanket rule.
|
|
658
|
+
_SINGLETON_GLOBALS = (
|
|
659
|
+
"hostname",
|
|
660
|
+
"ip domain-name",
|
|
661
|
+
"ip domain name",
|
|
662
|
+
"enable secret",
|
|
663
|
+
"enable password",
|
|
664
|
+
"banner motd",
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _singleton_global_prefix(line: str) -> str:
|
|
669
|
+
text = line.strip()
|
|
670
|
+
for prefix in sorted(_SINGLETON_GLOBALS, key=len, reverse=True):
|
|
671
|
+
if text.startswith(prefix + " "):
|
|
672
|
+
return prefix
|
|
673
|
+
return ""
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _append_unique_config_lines(parent: ET.Element | None, lines: list[str]) -> None:
|
|
677
|
+
if parent is None:
|
|
678
|
+
return
|
|
679
|
+
existing = [line.text or "" for line in parent.findall("./LINE")]
|
|
680
|
+
|
|
681
|
+
# A device can only have one hostname, so a new one has to replace the old
|
|
682
|
+
# rather than sit beside it. `cli R1: hostname CORE-R1` used to leave both
|
|
683
|
+
# `hostname Router` and `hostname CORE-R1` in the running config: IOS
|
|
684
|
+
# applies the last, so it worked, and the configuration still contradicted
|
|
685
|
+
# itself. Only the settings that genuinely cannot repeat are replaced.
|
|
686
|
+
replaced: set[int] = set()
|
|
687
|
+
for line in lines:
|
|
688
|
+
prefix = _singleton_global_prefix(line)
|
|
689
|
+
if not prefix:
|
|
690
|
+
continue
|
|
691
|
+
for index, current in enumerate(existing):
|
|
692
|
+
if index in replaced or current.startswith(" ") or current.startswith("\t"):
|
|
693
|
+
continue
|
|
694
|
+
if _singleton_global_prefix(current) == prefix:
|
|
695
|
+
existing[index] = line
|
|
696
|
+
replaced.add(index)
|
|
697
|
+
break
|
|
698
|
+
|
|
699
|
+
additions = [line for line in lines if line not in existing]
|
|
700
|
+
_replace_lines(parent, _splice_into_config(existing, additions))
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# Settings whose value is a word rather than a number, so stripping numeric
|
|
704
|
+
# tails cannot find where the name ends. `switchport mode access` and
|
|
705
|
+
# `switchport mode trunk` are the same setting and must replace each other.
|
|
706
|
+
_WORD_VALUED_SETTINGS = (
|
|
707
|
+
"switchport port-security violation",
|
|
708
|
+
"switchport mode",
|
|
709
|
+
"duplex",
|
|
710
|
+
"speed",
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _setting_name(line: str) -> str:
|
|
715
|
+
"""The part of a config line that identifies *which* setting it is.
|
|
716
|
+
|
|
717
|
+
Two lines share a name when one should overwrite the other. Most IOS
|
|
718
|
+
settings end in their value, so dropping the trailing tokens that carry
|
|
719
|
+
digits separates `switchport trunk allowed vlan 10,99` from
|
|
720
|
+
`switchport trunk native vlan 99` -- which a first attempt, keying on the
|
|
721
|
+
first two words alone, silently merged into one.
|
|
722
|
+
"""
|
|
723
|
+
text = line.strip()
|
|
724
|
+
if text.startswith("no "):
|
|
725
|
+
text = text[3:]
|
|
726
|
+
for prefix in sorted(_WORD_VALUED_SETTINGS, key=len, reverse=True):
|
|
727
|
+
if text == prefix or text.startswith(prefix + " "):
|
|
728
|
+
return prefix
|
|
729
|
+
tokens = text.split()
|
|
730
|
+
while tokens and any(character.isdigit() for character in tokens[-1]):
|
|
731
|
+
tokens.pop()
|
|
732
|
+
return " ".join(tokens) or text
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _set_config_block(parent: ET.Element | None, header: str, body: list[str]) -> None:
|
|
736
|
+
"""Write settings into an interface's existing block instead of beside it.
|
|
737
|
+
|
|
738
|
+
`_append_config_block` skips only when the whole block already matches, so
|
|
739
|
+
re-stating one setting with a different value leaves both copies:
|
|
740
|
+
|
|
741
|
+
interface FastEthernet0/1
|
|
742
|
+
switchport access vlan 11 <- donor's
|
|
743
|
+
...
|
|
744
|
+
interface FastEthernet0/1
|
|
745
|
+
switchport access vlan 20 <- ours
|
|
746
|
+
|
|
747
|
+
A lab generated that way opened fine and looked configured, but hosts could
|
|
748
|
+
not reach each other. Each body line replaces the line in the block that
|
|
749
|
+
shares its first two words, so a re-stated setting overwrites rather than
|
|
750
|
+
accumulates.
|
|
751
|
+
"""
|
|
752
|
+
if parent is None:
|
|
753
|
+
return
|
|
754
|
+
lines = [line.text or "" for line in parent.findall("./LINE")]
|
|
755
|
+
try:
|
|
756
|
+
start = lines.index(header)
|
|
757
|
+
except ValueError:
|
|
758
|
+
_append_config_block(parent, header, body)
|
|
759
|
+
return
|
|
760
|
+
|
|
761
|
+
end = start + 1
|
|
762
|
+
while end < len(lines) and lines[end].startswith(" "):
|
|
763
|
+
end += 1
|
|
764
|
+
block = lines[start + 1 : end]
|
|
765
|
+
|
|
766
|
+
for line in body:
|
|
767
|
+
name = _setting_name(line)
|
|
768
|
+
for index, existing in enumerate(block):
|
|
769
|
+
if _setting_name(existing) == name:
|
|
770
|
+
block[index] = line
|
|
771
|
+
break
|
|
772
|
+
else:
|
|
773
|
+
block.append(line)
|
|
774
|
+
|
|
775
|
+
_replace_lines(parent, [*lines[:start], header, *block, *lines[end:]])
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _append_config_block(parent: ET.Element | None, header: str, body: list[str]) -> None:
|
|
779
|
+
if parent is None:
|
|
780
|
+
return
|
|
781
|
+
existing = [line.text or "" for line in parent.findall("./LINE")]
|
|
782
|
+
block = [header, *body]
|
|
783
|
+
for index in range(0, max(len(existing) - len(block) + 1, 0)):
|
|
784
|
+
if existing[index : index + len(block)] == block:
|
|
785
|
+
return
|
|
786
|
+
_replace_lines(parent, _splice_into_config(existing, block))
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def apply_cli_lines(device: ET.Element, lines: list[str]) -> None:
|
|
790
|
+
"""Merge verbatim IOS configuration into a device that already has some.
|
|
791
|
+
|
|
792
|
+
`apply_router_config` replaces a device's whole configuration, which is what
|
|
793
|
+
the blueprint path wants and exactly wrong for "also run these commands".
|
|
794
|
+
This merges instead, and puts each piece where the parser will read it:
|
|
795
|
+
interface bodies go into the block for that interface, and everything else
|
|
796
|
+
is spliced ahead of the first interface, where Cisco's own labs keep global
|
|
797
|
+
configuration. Appending to the end would put it after `end`, where it is
|
|
798
|
+
ignored.
|
|
799
|
+
|
|
800
|
+
Indentation is normalised, since a user typing commands rarely reproduces
|
|
801
|
+
the single leading space a saved config uses.
|
|
802
|
+
"""
|
|
803
|
+
# Any unindented line can open a block, not just `interface`. Treating only
|
|
804
|
+
# interfaces as headers flattened `ip dhcp pool OFFICE` and its two indented
|
|
805
|
+
# lines into three globals, and a pool whose body is not indented under it
|
|
806
|
+
# is not a pool.
|
|
807
|
+
units: list[tuple[str, list[str]]] = []
|
|
808
|
+
for raw in lines:
|
|
809
|
+
text = str(raw).rstrip()
|
|
810
|
+
if not text.strip():
|
|
811
|
+
continue
|
|
812
|
+
stripped = text.strip()
|
|
813
|
+
if text.startswith((" ", " ")) and units:
|
|
814
|
+
units[-1][1].append(f" {stripped}")
|
|
815
|
+
continue
|
|
816
|
+
units.append((stripped, []))
|
|
817
|
+
|
|
818
|
+
for target in _config_targets(device):
|
|
819
|
+
for header, body in units:
|
|
820
|
+
if header.startswith("interface "):
|
|
821
|
+
# Merge into the block the device already has, so a description
|
|
822
|
+
# added here does not displace the address already on it.
|
|
823
|
+
_set_config_block(target, header, body)
|
|
824
|
+
else:
|
|
825
|
+
_append_unique_config_lines(target, [header, *body])
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _device_index_map(root: ET.Element) -> dict[str, int]:
|
|
829
|
+
return {device.findtext("./ENGINE/NAME", default=""): index for index, device in enumerate(root.findall(".//DEVICES/DEVICE"))}
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _device_refs(root: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
|
|
833
|
+
index_map = _device_index_map(root)
|
|
834
|
+
index_refs = {name: str(index) for name, index in index_map.items()}
|
|
835
|
+
save_refs = {}
|
|
836
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
837
|
+
name = device.findtext("./ENGINE/NAME", default="")
|
|
838
|
+
save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
839
|
+
if name and save_ref:
|
|
840
|
+
save_refs[name] = save_ref
|
|
841
|
+
return index_refs, save_refs
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _link_port_mem_map(root: ET.Element) -> dict[tuple[str, str], str]:
|
|
845
|
+
mapping: dict[tuple[str, str], str] = {}
|
|
846
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
847
|
+
cable = link.find("./CABLE")
|
|
848
|
+
if cable is None:
|
|
849
|
+
continue
|
|
850
|
+
ports = cable.findall("PORT")
|
|
851
|
+
if len(ports) < 2:
|
|
852
|
+
continue
|
|
853
|
+
from_ref = cable.findtext("FROM", default="")
|
|
854
|
+
to_ref = cable.findtext("TO", default="")
|
|
855
|
+
from_port = ports[0].text or ""
|
|
856
|
+
to_port = ports[1].text or ""
|
|
857
|
+
from_mem = cable.findtext("FROM_PORT_MEM_ADDR", default="").strip()
|
|
858
|
+
to_mem = cable.findtext("TO_PORT_MEM_ADDR", default="").strip()
|
|
859
|
+
if from_ref and from_port and from_mem:
|
|
860
|
+
mapping[(from_ref, from_port)] = from_mem
|
|
861
|
+
if to_ref and to_port and to_mem:
|
|
862
|
+
mapping[(to_ref, to_port)] = to_mem
|
|
863
|
+
return mapping
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _find_link_by_devices(root: ET.Element, left_name: str, right_name: str) -> ET.Element | None:
|
|
867
|
+
index_refs, save_refs = _device_refs(root)
|
|
868
|
+
left_candidates = {index_refs.get(left_name, ""), save_refs.get(left_name, "")}
|
|
869
|
+
right_candidates = {index_refs.get(right_name, ""), save_refs.get(right_name, "")}
|
|
870
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
871
|
+
cable = link.find("./CABLE")
|
|
872
|
+
if cable is None:
|
|
873
|
+
continue
|
|
874
|
+
from_idx = cable.findtext("FROM", default="")
|
|
875
|
+
to_idx = cable.findtext("TO", default="")
|
|
876
|
+
if from_idx in left_candidates and to_idx in right_candidates:
|
|
877
|
+
return link
|
|
878
|
+
if from_idx in right_candidates and to_idx in left_candidates:
|
|
879
|
+
return link
|
|
880
|
+
return None
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _first_link_prototype(root: ET.Element) -> ET.Element | None:
|
|
884
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
885
|
+
cable = link.find("./CABLE")
|
|
886
|
+
if cable is None:
|
|
887
|
+
continue
|
|
888
|
+
if cable.find("FUNCTIONAL") is not None and cable.find("GEO_VIEW_COLOR") is not None and cable.find("IS_MANAGED_IN_RACK_VIEW") is not None:
|
|
889
|
+
return link
|
|
890
|
+
return root.find(".//LINKS/LINK")
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _remove_links_for_device(root: ET.Element, device_name: str) -> None:
|
|
894
|
+
index_refs, save_refs = _device_refs(root)
|
|
895
|
+
refs = {index_refs.get(device_name, ""), save_refs.get(device_name, "")}
|
|
896
|
+
links_parent = root.find(".//LINKS")
|
|
897
|
+
if links_parent is None:
|
|
898
|
+
return
|
|
899
|
+
for link in list(links_parent.findall("./LINK")):
|
|
900
|
+
cable = link.find("./CABLE")
|
|
901
|
+
if cable is None:
|
|
902
|
+
continue
|
|
903
|
+
if cable.findtext("FROM", default="") in refs or cable.findtext("TO", default="") in refs:
|
|
904
|
+
links_parent.remove(link)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _find_parent_of_node(root: ET.Element, target: ET.Element) -> ET.Element | None:
|
|
908
|
+
for parent in root.iter():
|
|
909
|
+
for child in list(parent):
|
|
910
|
+
if child is target:
|
|
911
|
+
return parent
|
|
912
|
+
return None
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _remove_physical_leaf(root: ET.Element, device: ET.Element) -> None:
|
|
916
|
+
physical_path = device.findtext("./WORKSPACE/PHYSICAL", default="")
|
|
917
|
+
if not physical_path:
|
|
918
|
+
return
|
|
919
|
+
tokens = [token.strip() for token in physical_path.split(",") if token.strip()]
|
|
920
|
+
if not tokens:
|
|
921
|
+
return
|
|
922
|
+
leaf_token = tokens[-1]
|
|
923
|
+
for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
|
|
924
|
+
uuid = node.findtext("UUID_STR", default="").strip()
|
|
925
|
+
if uuid == leaf_token:
|
|
926
|
+
parent = _find_parent_of_node(root, node)
|
|
927
|
+
if parent is not None:
|
|
928
|
+
parent.remove(node)
|
|
929
|
+
return
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _node_contains_tokens(node: ET.Element, tokens: list[str]) -> bool:
|
|
933
|
+
if any(token and token in ((node.text or "").strip()) for token in tokens):
|
|
934
|
+
return True
|
|
935
|
+
for value in node.attrib.values():
|
|
936
|
+
if any(token and token in value for token in tokens):
|
|
937
|
+
return True
|
|
938
|
+
for child in list(node):
|
|
939
|
+
if _node_contains_tokens(child, tokens):
|
|
940
|
+
return True
|
|
941
|
+
return False
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _remove_runtime_references(root: ET.Element, device: ET.Element) -> None:
|
|
945
|
+
tokens = [
|
|
946
|
+
device.findtext("./ENGINE/SAVE_REF_ID", default="").strip(),
|
|
947
|
+
device.findtext("./ENGINE/NAME", default="").strip(),
|
|
948
|
+
device.findtext("./ENGINE/ORIGINAL_DEVICE_UUID", default="").strip(),
|
|
949
|
+
]
|
|
950
|
+
physical_path = device.findtext("./WORKSPACE/PHYSICAL", default="").strip()
|
|
951
|
+
if physical_path:
|
|
952
|
+
tokens.append(physical_path.split(",")[-1].strip())
|
|
953
|
+
tokens = [token for token in tokens if token]
|
|
954
|
+
if not tokens:
|
|
955
|
+
return
|
|
956
|
+
protected_tags = {"SCENARIOSET", "SCENARIO", "COMMAND_LOGS", "CEPS"}
|
|
957
|
+
for section_path in ["./SCENARIOSET", "./COMMAND_LOGS", "./CEPS"]:
|
|
958
|
+
section = root.find(section_path)
|
|
959
|
+
if section is None:
|
|
960
|
+
continue
|
|
961
|
+
stack = [section]
|
|
962
|
+
while stack:
|
|
963
|
+
parent = stack.pop()
|
|
964
|
+
for child in list(parent):
|
|
965
|
+
if child.tag in protected_tags:
|
|
966
|
+
stack.append(child)
|
|
967
|
+
continue
|
|
968
|
+
if _node_contains_tokens(child, tokens):
|
|
969
|
+
parent.remove(child)
|
|
970
|
+
continue
|
|
971
|
+
stack.append(child)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
DUPLICABLE_DEVICE_TYPES = {"Router", "Switch", "MultiLayerSwitch"}
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _align_hostname_with_name(device: ET.Element, name: str) -> None:
|
|
978
|
+
"""Make the device's CLI prompt say what the topology calls it.
|
|
979
|
+
|
|
980
|
+
The rename used to match on the old *device* name, which never fired: a
|
|
981
|
+
donor switch called `Multilayer Switch0` carries `hostname Switch`, so the
|
|
982
|
+
two never agreed and the line was left behind. Measured across the corpus,
|
|
983
|
+
84 of 90 configured devices answered to a hostname that was not their name,
|
|
984
|
+
and in a two-switch lab both prompts read `Switch`.
|
|
985
|
+
|
|
986
|
+
A clone needs this as much as a rename does -- it is a deep copy, so it
|
|
987
|
+
arrives announcing itself as its prototype. `R2` called itself `R1` and
|
|
988
|
+
`SW3` called itself `SW1` until this ran for them too.
|
|
989
|
+
|
|
990
|
+
A hostname the user asked for still wins, because `apply_cli_lines` runs
|
|
991
|
+
after both and replaces the line it finds. Names with whitespace --
|
|
992
|
+
`Patch Panel1`, `Power Distribution Device0` -- are not valid IOS hostnames,
|
|
993
|
+
and those devices carry no configuration anyway.
|
|
994
|
+
"""
|
|
995
|
+
if not name or any(character.isspace() for character in name):
|
|
996
|
+
return
|
|
997
|
+
for path in (
|
|
998
|
+
"./ENGINE/RUNNINGCONFIG/LINE",
|
|
999
|
+
"./ENGINE/STARTUPCONFIG/LINE",
|
|
1000
|
+
".//FILE_CONTENT/CONFIG/LINE",
|
|
1001
|
+
):
|
|
1002
|
+
for line in device.findall(path):
|
|
1003
|
+
if (line.text or "").startswith("hostname "):
|
|
1004
|
+
line.text = f"hostname {name}"
|
|
1005
|
+
break
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _duplicate_device(root: ET.Element, source_name: str, new_name: str, x: int, y: int) -> None:
|
|
1009
|
+
"""Copy an infrastructure device, giving the copy a fresh identity.
|
|
1010
|
+
|
|
1011
|
+
Donor-prune can only reuse what the donor contains, so a four-switch request
|
|
1012
|
+
against a three-switch donor was impossible. Duplicating a switch removes
|
|
1013
|
+
that cap. Verified against a real Packet Tracer open: a duplicated switch
|
|
1014
|
+
with a fresh `SAVE_REF_ID`, `MEM_ADDR`, name and position, joined by a
|
|
1015
|
+
created switch-to-switch link, opens.
|
|
1016
|
+
|
|
1017
|
+
Restricted to infrastructure devices on purpose. Hosts cannot take a created
|
|
1018
|
+
connection (see `_link_may_be_created`), so a duplicated host would have
|
|
1019
|
+
nothing valid to attach to.
|
|
1020
|
+
"""
|
|
1021
|
+
source = _find_device(root, source_name)
|
|
1022
|
+
if source is None or _find_device(root, new_name) is not None:
|
|
1023
|
+
return
|
|
1024
|
+
device_type = normalize_device_type(source.findtext("./ENGINE/TYPE", default=""))
|
|
1025
|
+
if device_type not in DUPLICABLE_DEVICE_TYPES:
|
|
1026
|
+
return
|
|
1027
|
+
|
|
1028
|
+
devices_parent = _find_parent_of_node(root, source)
|
|
1029
|
+
if devices_parent is None:
|
|
1030
|
+
return
|
|
1031
|
+
|
|
1032
|
+
used_refs = {
|
|
1033
|
+
device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
1034
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
1035
|
+
}
|
|
1036
|
+
used_mems: list[int] = []
|
|
1037
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
1038
|
+
logical = device.find("./WORKSPACE/LOGICAL")
|
|
1039
|
+
if logical is None:
|
|
1040
|
+
continue
|
|
1041
|
+
try:
|
|
1042
|
+
used_mems.append(int(logical.findtext("MEM_ADDR", default="0") or 0))
|
|
1043
|
+
except ValueError:
|
|
1044
|
+
continue
|
|
1045
|
+
|
|
1046
|
+
duplicate = copy.deepcopy(source)
|
|
1047
|
+
name_node = duplicate.find("./ENGINE/NAME")
|
|
1048
|
+
if name_node is not None:
|
|
1049
|
+
name_node.text = new_name
|
|
1050
|
+
_align_hostname_with_name(duplicate, new_name)
|
|
1051
|
+
|
|
1052
|
+
ref_node = duplicate.find("./ENGINE/SAVE_REF_ID")
|
|
1053
|
+
if ref_node is not None:
|
|
1054
|
+
candidate = f"save-ref-id:{_stable_ref_seed(new_name)}"
|
|
1055
|
+
while candidate in used_refs:
|
|
1056
|
+
candidate = f"save-ref-id:{_stable_ref_seed(new_name + candidate)}"
|
|
1057
|
+
ref_node.text = candidate
|
|
1058
|
+
|
|
1059
|
+
logical = duplicate.find("./WORKSPACE/LOGICAL")
|
|
1060
|
+
if logical is not None:
|
|
1061
|
+
mem_node = logical.find("MEM_ADDR")
|
|
1062
|
+
if mem_node is not None and used_mems:
|
|
1063
|
+
mem_node.text = str(max(used_mems) + 4096)
|
|
1064
|
+
for tag, value in (("X", x), ("Y", y)):
|
|
1065
|
+
node = logical.find(tag)
|
|
1066
|
+
if node is not None:
|
|
1067
|
+
node.text = str(value)
|
|
1068
|
+
|
|
1069
|
+
devices_parent.append(duplicate)
|
|
1070
|
+
_clone_physical_leaf(root, source, duplicate, new_name)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _fresh_guid(seed_text: str, taken: set[str]) -> str:
|
|
1074
|
+
"""A deterministic, correctly shaped `{8-4-4-4-12}` GUID not already in use."""
|
|
1075
|
+
attempt = 0
|
|
1076
|
+
while True:
|
|
1077
|
+
digest = hashlib.sha256(f"{seed_text}:{attempt}".encode("utf-8")).hexdigest()
|
|
1078
|
+
# Shape it as a RFC 4122 version-4 GUID: the donor's identifiers all
|
|
1079
|
+
# carry the `4` version nibble and an `8`-`b` variant nibble, so a
|
|
1080
|
+
# hash-shaped id with arbitrary nibbles is not the same kind of value.
|
|
1081
|
+
variant = "89ab"[int(digest[16], 16) % 4]
|
|
1082
|
+
candidate = (
|
|
1083
|
+
"{" + digest[0:8] + "-" + digest[8:12] + "-4" + digest[13:16]
|
|
1084
|
+
+ "-" + variant + digest[17:20] + "-" + digest[20:32] + "}"
|
|
1085
|
+
)
|
|
1086
|
+
if candidate not in taken:
|
|
1087
|
+
return candidate
|
|
1088
|
+
attempt += 1
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _clone_physical_leaf(root: ET.Element, source: ET.Element, duplicate: ET.Element, new_name: str) -> None:
|
|
1092
|
+
"""Give a duplicated device its own node in the physical workspace.
|
|
1093
|
+
|
|
1094
|
+
A device lives twice: in `DEVICES` and as a leaf in `PHYSICALWORKSPACE`,
|
|
1095
|
+
joined by the UUID path in `WORKSPACE/PHYSICAL`. Copying only the logical
|
|
1096
|
+
half left the clone pointing at the original's leaf, and workspace
|
|
1097
|
+
validation then reported "Generated device SW1 physical leaf name is SW4".
|
|
1098
|
+
"""
|
|
1099
|
+
physical_path = source.findtext("./WORKSPACE/PHYSICAL", default="")
|
|
1100
|
+
tokens = [token.strip() for token in physical_path.split(",") if token.strip()]
|
|
1101
|
+
if not tokens:
|
|
1102
|
+
return
|
|
1103
|
+
leaf_token = tokens[-1]
|
|
1104
|
+
|
|
1105
|
+
for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
|
|
1106
|
+
if node.findtext("UUID_STR", default="").strip() != leaf_token:
|
|
1107
|
+
continue
|
|
1108
|
+
parent = _find_parent_of_node(root, node)
|
|
1109
|
+
if parent is None:
|
|
1110
|
+
return
|
|
1111
|
+
clone_node = copy.deepcopy(node)
|
|
1112
|
+
# Every UUID inside the copied subtree must be fresh, and must keep the
|
|
1113
|
+
# braced `{8-4-4-4-12}` shape Packet Tracer writes. A bare hex id, or a
|
|
1114
|
+
# nested UUID reused from the original, makes Packet Tracer refuse the
|
|
1115
|
+
# file with "File contains corrupted Physical Workspace data".
|
|
1116
|
+
#
|
|
1117
|
+
# The identifier must also not merely extend the original's: workspace
|
|
1118
|
+
# validation matches by substring, so `<original>-1234` made a pruned
|
|
1119
|
+
# device look like it was still present.
|
|
1120
|
+
taken = {
|
|
1121
|
+
existing.findtext("UUID_STR", default="").strip()
|
|
1122
|
+
for existing in root.findall(".//PHYSICALWORKSPACE//NODE")
|
|
1123
|
+
}
|
|
1124
|
+
new_uuid = ""
|
|
1125
|
+
for nested in clone_node.iter("UUID_STR"):
|
|
1126
|
+
replacement = _fresh_guid(new_name + (nested.text or ""), taken)
|
|
1127
|
+
taken.add(replacement)
|
|
1128
|
+
nested.text = replacement
|
|
1129
|
+
if not new_uuid:
|
|
1130
|
+
new_uuid = replacement
|
|
1131
|
+
if not new_uuid:
|
|
1132
|
+
return
|
|
1133
|
+
name_node = clone_node.find("NAME")
|
|
1134
|
+
if name_node is not None:
|
|
1135
|
+
name_node.text = new_name
|
|
1136
|
+
parent.append(clone_node)
|
|
1137
|
+
|
|
1138
|
+
physical_node = duplicate.find("./WORKSPACE/PHYSICAL")
|
|
1139
|
+
if physical_node is not None:
|
|
1140
|
+
# Packet Tracer writes this list with no space after the comma.
|
|
1141
|
+
# A space made the leaf token parse as " {uuid}" and the file was
|
|
1142
|
+
# rejected with "File contains corrupted Physical Workspace data".
|
|
1143
|
+
physical_node.text = ",".join([*tokens[:-1], new_uuid])
|
|
1144
|
+
return
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _duplicate_group(
|
|
1148
|
+
root: ET.Element,
|
|
1149
|
+
switch_name: str,
|
|
1150
|
+
new_switch_name: str,
|
|
1151
|
+
host_names: list[str],
|
|
1152
|
+
x: int,
|
|
1153
|
+
y: int,
|
|
1154
|
+
new_host_names: list[str] | None = None,
|
|
1155
|
+
) -> None:
|
|
1156
|
+
"""Copy a switch together with its hosts and the links between them.
|
|
1157
|
+
|
|
1158
|
+
Duplicating the switch alone leaves a group with no hosts, so a request for
|
|
1159
|
+
more switches than the donor has still failed on host capacity. Copying the
|
|
1160
|
+
whole working unit — switch, its hosts, and the donor links joining them —
|
|
1161
|
+
was verified to open in Packet Tracer.
|
|
1162
|
+
|
|
1163
|
+
The distinction that matters: replicating an arrangement the donor already
|
|
1164
|
+
has is safe, while *moving* a host onto a different switch is not, because
|
|
1165
|
+
that needs a created host connection Packet Tracer rejects.
|
|
1166
|
+
"""
|
|
1167
|
+
source = _find_device(root, switch_name)
|
|
1168
|
+
if source is None or _find_device(root, new_switch_name) is not None:
|
|
1169
|
+
return
|
|
1170
|
+
|
|
1171
|
+
_duplicate_device(root, switch_name, new_switch_name, x, y)
|
|
1172
|
+
clone = _find_device(root, new_switch_name)
|
|
1173
|
+
if clone is None:
|
|
1174
|
+
return
|
|
1175
|
+
|
|
1176
|
+
source_ref = source.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
1177
|
+
clone_ref = clone.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
1178
|
+
links_parent = root.find(".//LINKS")
|
|
1179
|
+
if links_parent is None:
|
|
1180
|
+
return
|
|
1181
|
+
|
|
1182
|
+
for offset, host_name in enumerate(host_names):
|
|
1183
|
+
host = _find_device(root, host_name)
|
|
1184
|
+
if host is None:
|
|
1185
|
+
continue
|
|
1186
|
+
# Do not embed the source host's name: workspace validation matches by
|
|
1187
|
+
# substring, so `SW-COPY1-Admin` read as the pruned `Admin` still present.
|
|
1188
|
+
if new_host_names and offset < len(new_host_names):
|
|
1189
|
+
new_host_name = new_host_names[offset]
|
|
1190
|
+
else:
|
|
1191
|
+
new_host_name = f"{new_switch_name}-H{offset + 1}"
|
|
1192
|
+
_duplicate_host_for_group(root, host_name, new_host_name, x + offset * 90, y + 140)
|
|
1193
|
+
new_host = _find_device(root, new_host_name)
|
|
1194
|
+
if new_host is None:
|
|
1195
|
+
continue
|
|
1196
|
+
template = _find_link_between_refs(root, source_ref, host.findtext("./ENGINE/SAVE_REF_ID", default=""))
|
|
1197
|
+
if template is None:
|
|
1198
|
+
continue
|
|
1199
|
+
duplicate_link = copy.deepcopy(template)
|
|
1200
|
+
cable = duplicate_link.find("./CABLE")
|
|
1201
|
+
if cable is None:
|
|
1202
|
+
continue
|
|
1203
|
+
host_ref = new_host.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
1204
|
+
if cable.findtext("FROM", default="") == source_ref:
|
|
1205
|
+
cable.find("FROM").text = clone_ref
|
|
1206
|
+
cable.find("TO").text = host_ref
|
|
1207
|
+
else:
|
|
1208
|
+
cable.find("FROM").text = host_ref
|
|
1209
|
+
cable.find("TO").text = clone_ref
|
|
1210
|
+
links_parent.append(duplicate_link)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _duplicate_host_for_group(root: ET.Element, source_name: str, new_name: str, x: int, y: int) -> None:
|
|
1214
|
+
"""Clone a host device. Only valid as part of `_duplicate_group`."""
|
|
1215
|
+
source = _find_device(root, source_name)
|
|
1216
|
+
if source is None or _find_device(root, new_name) is not None:
|
|
1217
|
+
return
|
|
1218
|
+
devices_parent = _find_parent_of_node(root, source)
|
|
1219
|
+
if devices_parent is None:
|
|
1220
|
+
return
|
|
1221
|
+
|
|
1222
|
+
used_refs = {d.findtext("./ENGINE/SAVE_REF_ID", default="") for d in root.findall(".//DEVICES/DEVICE")}
|
|
1223
|
+
mems: list[int] = []
|
|
1224
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
1225
|
+
logical = device.find("./WORKSPACE/LOGICAL")
|
|
1226
|
+
if logical is None:
|
|
1227
|
+
continue
|
|
1228
|
+
try:
|
|
1229
|
+
mems.append(int(logical.findtext("MEM_ADDR", default="0") or 0))
|
|
1230
|
+
except ValueError:
|
|
1231
|
+
continue
|
|
1232
|
+
|
|
1233
|
+
duplicate = copy.deepcopy(source)
|
|
1234
|
+
duplicate.find("./ENGINE/NAME").text = new_name
|
|
1235
|
+
_align_hostname_with_name(duplicate, new_name)
|
|
1236
|
+
ref_node = duplicate.find("./ENGINE/SAVE_REF_ID")
|
|
1237
|
+
if ref_node is not None:
|
|
1238
|
+
candidate = f"save-ref-id:{_stable_ref_seed(new_name)}"
|
|
1239
|
+
while candidate in used_refs:
|
|
1240
|
+
candidate = f"save-ref-id:{_stable_ref_seed(new_name + candidate)}"
|
|
1241
|
+
ref_node.text = candidate
|
|
1242
|
+
logical = duplicate.find("./WORKSPACE/LOGICAL")
|
|
1243
|
+
if logical is not None:
|
|
1244
|
+
mem_node = logical.find("MEM_ADDR")
|
|
1245
|
+
if mem_node is not None and mems:
|
|
1246
|
+
mem_node.text = str(max(mems) + 4096)
|
|
1247
|
+
for tag, value in (("X", x), ("Y", y)):
|
|
1248
|
+
node = logical.find(tag)
|
|
1249
|
+
if node is not None:
|
|
1250
|
+
node.text = str(value)
|
|
1251
|
+
devices_parent.append(duplicate)
|
|
1252
|
+
_clone_physical_leaf(root, source, duplicate, new_name)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _duplicate_host_onto_switch(
|
|
1256
|
+
root: ET.Element,
|
|
1257
|
+
source_name: str,
|
|
1258
|
+
new_name: str,
|
|
1259
|
+
switch_name: str,
|
|
1260
|
+
switch_port: str,
|
|
1261
|
+
host_port: str,
|
|
1262
|
+
x: int,
|
|
1263
|
+
y: int,
|
|
1264
|
+
) -> None:
|
|
1265
|
+
"""Clone a host and attach the copy to a switch.
|
|
1266
|
+
|
|
1267
|
+
A target switch cannot carry more hosts than its donor group has, because
|
|
1268
|
+
donor-prune only reuses what the donor contains. Cloning a host and linking
|
|
1269
|
+
the copy lifts that: the link is new, which is fine now that `_ensure_link`
|
|
1270
|
+
no longer writes invented MEM_ADDR values into new links.
|
|
1271
|
+
"""
|
|
1272
|
+
_duplicate_host_for_group(root, source_name, new_name, x, y)
|
|
1273
|
+
if _find_device(root, new_name) is None:
|
|
1274
|
+
return
|
|
1275
|
+
if not switch_name:
|
|
1276
|
+
return # standalone clone: the topology's own link pass attaches it
|
|
1277
|
+
_ensure_link(root, switch_name, switch_port, new_name, host_port, "straight-through")
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def _find_link_between_refs(root: ET.Element, left_ref: str, right_ref: str) -> ET.Element | None:
|
|
1281
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
1282
|
+
cable = link.find("./CABLE")
|
|
1283
|
+
if cable is None:
|
|
1284
|
+
continue
|
|
1285
|
+
ends = {cable.findtext("FROM", default=""), cable.findtext("TO", default="")}
|
|
1286
|
+
if ends == {left_ref, right_ref}:
|
|
1287
|
+
return link
|
|
1288
|
+
return None
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _stable_ref_seed(text: str) -> int:
|
|
1292
|
+
"""A deterministic 63-bit id, so the same plan yields the same file."""
|
|
1293
|
+
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
|
1294
|
+
return int.from_bytes(digest[:8], "big") >> 1
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _renumber_index_links_after_removal(root: ET.Element, removed_index: int) -> None:
|
|
1298
|
+
"""Shift positional link endpoints down past a device that just went.
|
|
1299
|
+
|
|
1300
|
+
Not every donor gives its devices a `SAVE_REF_ID`. Where they do not, links
|
|
1301
|
+
address their endpoints by position in the DEVICES list, and removing a
|
|
1302
|
+
device silently re-points every cable that referred to a later one.
|
|
1303
|
+
|
|
1304
|
+
Measured on `1 router 1 switch 2 komputer 2 ip phone 1 home voip`: the
|
|
1305
|
+
generated lab carried a cable from the switch to the Power Distribution
|
|
1306
|
+
Device, which has no ports at all. The link had meant PC1. Packet Tracer
|
|
1307
|
+
refused the file, and the structural check saw nothing wrong -- every index
|
|
1308
|
+
was still inside the list, just pointing one device to the left.
|
|
1309
|
+
"""
|
|
1310
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
1311
|
+
cable = link.find("./CABLE")
|
|
1312
|
+
if cable is None:
|
|
1313
|
+
continue
|
|
1314
|
+
for tag in ("FROM", "TO"):
|
|
1315
|
+
node = cable.find(tag)
|
|
1316
|
+
if node is None or not (node.text or "").strip().isdigit():
|
|
1317
|
+
continue
|
|
1318
|
+
index = int(node.text.strip())
|
|
1319
|
+
if index > removed_index:
|
|
1320
|
+
node.text = str(index - 1)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _prune_device(root: ET.Element, device_name: str) -> None:
|
|
1324
|
+
device = _find_device(root, device_name)
|
|
1325
|
+
if device is None:
|
|
1326
|
+
return
|
|
1327
|
+
devices = root.findall(".//DEVICES/DEVICE")
|
|
1328
|
+
try:
|
|
1329
|
+
removed_index = devices.index(device)
|
|
1330
|
+
except ValueError: # pragma: no cover - the device came from this list
|
|
1331
|
+
removed_index = -1
|
|
1332
|
+
_remove_links_for_device(root, device_name)
|
|
1333
|
+
_remove_physical_leaf(root, device)
|
|
1334
|
+
_remove_runtime_references(root, device)
|
|
1335
|
+
parent = _find_parent_of_node(root, device)
|
|
1336
|
+
if parent is not None:
|
|
1337
|
+
parent.remove(device)
|
|
1338
|
+
if removed_index >= 0:
|
|
1339
|
+
_renumber_index_links_after_removal(root, removed_index)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _remove_link(root: ET.Element, left_name: str, right_name: str) -> None:
|
|
1343
|
+
links_parent = root.find(".//LINKS")
|
|
1344
|
+
if links_parent is None:
|
|
1345
|
+
return
|
|
1346
|
+
link = _find_link_by_devices(root, left_name, right_name)
|
|
1347
|
+
if link is not None:
|
|
1348
|
+
links_parent.remove(link)
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _ensure_link(
|
|
1352
|
+
root: ET.Element,
|
|
1353
|
+
left_name: str,
|
|
1354
|
+
left_port: str,
|
|
1355
|
+
right_name: str,
|
|
1356
|
+
right_port: str,
|
|
1357
|
+
media: str,
|
|
1358
|
+
port_mem_map: dict[tuple[str, str], str] | None = None,
|
|
1359
|
+
) -> None:
|
|
1360
|
+
existing = _find_link_by_devices(root, left_name, right_name)
|
|
1361
|
+
devices = {device.findtext("./ENGINE/NAME", default=""): device for device in root.findall(".//DEVICES/DEVICE")}
|
|
1362
|
+
index_refs, save_refs = _device_refs(root)
|
|
1363
|
+
left_device = devices.get(left_name)
|
|
1364
|
+
right_device = devices.get(right_name)
|
|
1365
|
+
if left_device is None or right_device is None:
|
|
1366
|
+
return
|
|
1367
|
+
link = existing
|
|
1368
|
+
if link is None:
|
|
1369
|
+
left_type = _device_type(left_device)
|
|
1370
|
+
right_type = _device_type(right_device)
|
|
1371
|
+
prototype = None
|
|
1372
|
+
for candidate in root.findall(".//LINKS/LINK"):
|
|
1373
|
+
cable = candidate.find("./CABLE")
|
|
1374
|
+
if cable is None:
|
|
1375
|
+
continue
|
|
1376
|
+
from_ref = cable.findtext("FROM", default="")
|
|
1377
|
+
to_ref = cable.findtext("TO", default="")
|
|
1378
|
+
from_name = next((name for name, ref in save_refs.items() if ref == from_ref), next((name for name, ref in index_refs.items() if ref == from_ref), ""))
|
|
1379
|
+
to_name = next((name for name, ref in save_refs.items() if ref == to_ref), next((name for name, ref in index_refs.items() if ref == to_ref), ""))
|
|
1380
|
+
if not from_name or not to_name:
|
|
1381
|
+
continue
|
|
1382
|
+
from_type = _device_type(devices[from_name])
|
|
1383
|
+
to_type = _device_type(devices[to_name])
|
|
1384
|
+
if {from_type, to_type} == {left_type, right_type}:
|
|
1385
|
+
prototype = candidate
|
|
1386
|
+
break
|
|
1387
|
+
if prototype is None:
|
|
1388
|
+
prototype = _first_link_prototype(root)
|
|
1389
|
+
if prototype is None:
|
|
1390
|
+
prototype_root = load_sample_root(resolve_sample_path(FTP_SAMPLE))
|
|
1391
|
+
prototype = prototype_root.find(".//LINKS/LINK")
|
|
1392
|
+
link = copy.deepcopy(prototype)
|
|
1393
|
+
if link is None:
|
|
1394
|
+
return
|
|
1395
|
+
links_parent = root.find(".//LINKS")
|
|
1396
|
+
if links_parent is None:
|
|
1397
|
+
return
|
|
1398
|
+
links_parent.append(link)
|
|
1399
|
+
cable = link.find("./CABLE")
|
|
1400
|
+
if cable is None:
|
|
1401
|
+
return
|
|
1402
|
+
from_ref = save_refs.get(left_name, index_refs[left_name])
|
|
1403
|
+
to_ref = save_refs.get(right_name, index_refs[right_name])
|
|
1404
|
+
_ensure_text(cable, "FROM", from_ref)
|
|
1405
|
+
_ensure_text(cable, "TO", to_ref)
|
|
1406
|
+
ports = cable.findall("PORT")
|
|
1407
|
+
if len(ports) < 2:
|
|
1408
|
+
while len(ports) < 2:
|
|
1409
|
+
ports.append(ET.SubElement(cable, "PORT"))
|
|
1410
|
+
ports[0].text = left_port
|
|
1411
|
+
ports[1].text = right_port
|
|
1412
|
+
# These four are runtime pointers from the session that saved the file, not
|
|
1413
|
+
# references into it: in working donors they match no device. Writing
|
|
1414
|
+
# invented values into a *new* link is what made Packet Tracer reject the
|
|
1415
|
+
# result — verified by building the same link with the fields omitted, which
|
|
1416
|
+
# opens. On an existing link they are left exactly as the donor wrote them.
|
|
1417
|
+
if existing is None:
|
|
1418
|
+
for node_name in (
|
|
1419
|
+
"FROM_DEVICE_MEM_ADDR",
|
|
1420
|
+
"TO_DEVICE_MEM_ADDR",
|
|
1421
|
+
"FROM_PORT_MEM_ADDR",
|
|
1422
|
+
"TO_PORT_MEM_ADDR",
|
|
1423
|
+
):
|
|
1424
|
+
stale = cable.find(node_name)
|
|
1425
|
+
if stale is not None:
|
|
1426
|
+
cable.remove(stale)
|
|
1427
|
+
else:
|
|
1428
|
+
resolved_port_mem_map = port_mem_map or {}
|
|
1429
|
+
for node_name, value in [
|
|
1430
|
+
("FROM_DEVICE_MEM_ADDR", left_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
|
|
1431
|
+
("TO_DEVICE_MEM_ADDR", right_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
|
|
1432
|
+
(
|
|
1433
|
+
"FROM_PORT_MEM_ADDR",
|
|
1434
|
+
_port_address_for_name(left_device, left_port)
|
|
1435
|
+
or resolved_port_mem_map.get((from_ref, left_port), ""),
|
|
1436
|
+
),
|
|
1437
|
+
(
|
|
1438
|
+
"TO_PORT_MEM_ADDR",
|
|
1439
|
+
_port_address_for_name(right_device, right_port)
|
|
1440
|
+
or resolved_port_mem_map.get((to_ref, right_port), ""),
|
|
1441
|
+
),
|
|
1442
|
+
]:
|
|
1443
|
+
if value:
|
|
1444
|
+
_ensure_text(cable, node_name, value)
|
|
1445
|
+
_ensure_text(cable, "FUNCTIONAL", cable.findtext("FUNCTIONAL", default="true") or "true")
|
|
1446
|
+
_ensure_text(cable, "GEO_VIEW_COLOR", cable.findtext("GEO_VIEW_COLOR", default="#6ba72e") or "#6ba72e")
|
|
1447
|
+
_ensure_text(
|
|
1448
|
+
cable,
|
|
1449
|
+
"IS_MANAGED_IN_RACK_VIEW",
|
|
1450
|
+
cable.findtext("IS_MANAGED_IN_RACK_VIEW", default="false") or "false",
|
|
1451
|
+
)
|
|
1452
|
+
apply_cable_type(cable, media, link)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _prefix_to_mask(prefix: int) -> str:
|
|
1456
|
+
bits = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
|
|
1457
|
+
return ".".join(str((bits >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _set_device_name(root: ET.Element, device: ET.Element, new_name: str) -> None:
|
|
1461
|
+
old_name = device.findtext("./ENGINE/NAME", default="")
|
|
1462
|
+
node = device.find("./ENGINE/NAME")
|
|
1463
|
+
if node is not None:
|
|
1464
|
+
node.text = new_name
|
|
1465
|
+
sys_name = device.find("./ENGINE/SYS_NAME")
|
|
1466
|
+
if sys_name is not None and (sys_name.text or "").strip() == old_name:
|
|
1467
|
+
sys_name.text = new_name
|
|
1468
|
+
|
|
1469
|
+
_align_hostname_with_name(device, new_name)
|
|
1470
|
+
|
|
1471
|
+
physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
|
|
1472
|
+
leaf_uuid = physical.split(",")[-1].strip() if physical else ""
|
|
1473
|
+
if leaf_uuid:
|
|
1474
|
+
for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
|
|
1475
|
+
uuid = node.findtext("UUID_STR", default="").strip()
|
|
1476
|
+
if uuid == leaf_uuid:
|
|
1477
|
+
leaf_name = node.find("./NAME")
|
|
1478
|
+
if leaf_name is not None:
|
|
1479
|
+
leaf_name.text = new_name
|
|
1480
|
+
break
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _set_device_position(device: ET.Element, x: int, y: int) -> None:
|
|
1484
|
+
workspace = device.find("./WORKSPACE/LOGICAL")
|
|
1485
|
+
if workspace is None:
|
|
1486
|
+
return
|
|
1487
|
+
_ensure_text(workspace, "X", str(x))
|
|
1488
|
+
_ensure_text(workspace, "Y", str(y))
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def _config_targets(device: ET.Element) -> list[ET.Element]:
|
|
1492
|
+
targets: list[ET.Element] = []
|
|
1493
|
+
for path in ["./ENGINE/RUNNINGCONFIG", "./ENGINE/STARTUPCONFIG"]:
|
|
1494
|
+
node = device.find(path)
|
|
1495
|
+
if node is None:
|
|
1496
|
+
continue
|
|
1497
|
+
# A donor can ship a device with an empty startup config. Writing into
|
|
1498
|
+
# it turned the router's saved configuration into three lines -- just
|
|
1499
|
+
# the DHCP pool that had been added -- so a reload would have wiped
|
|
1500
|
+
# every interface. An empty startup config is the donor's state; a stub
|
|
1501
|
+
# is worse than either leaving it alone or copying the whole running
|
|
1502
|
+
# config, so leave it alone.
|
|
1503
|
+
if path.endswith("STARTUPCONFIG") and not node.findall("./LINE"):
|
|
1504
|
+
continue
|
|
1505
|
+
targets.append(node)
|
|
1506
|
+
for node in device.findall(".//FILE_CONTENT/CONFIG"):
|
|
1507
|
+
targets.append(node)
|
|
1508
|
+
return targets
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
def _ensure_vlan_state(device: ET.Element, vlan_id: int, vlan_name: str) -> None:
|
|
1512
|
+
vlans = device.find(".//VLANS")
|
|
1513
|
+
if vlans is None:
|
|
1514
|
+
engine = device.find("./ENGINE")
|
|
1515
|
+
if engine is None:
|
|
1516
|
+
return
|
|
1517
|
+
vlans = ET.SubElement(engine, "VLANS")
|
|
1518
|
+
existing = next((node for node in vlans.findall("./VLAN") if node.get("number") == str(vlan_id)), None)
|
|
1519
|
+
if existing is None:
|
|
1520
|
+
existing = ET.SubElement(vlans, "VLAN")
|
|
1521
|
+
existing.set("number", str(vlan_id))
|
|
1522
|
+
existing.set("rspan", "0")
|
|
1523
|
+
existing.set("name", vlan_name)
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
1527
|
+
if operation["op"] == "set_vlan":
|
|
1528
|
+
lines = [f"vlan {operation['vlan']}", f" name {operation['name']}"]
|
|
1529
|
+
for target in _config_targets(device):
|
|
1530
|
+
_append_unique_config_lines(target, lines)
|
|
1531
|
+
_ensure_vlan_state(device, int(operation["vlan"]), str(operation["name"]))
|
|
1532
|
+
return
|
|
1533
|
+
elif operation["op"] == "set_access_port":
|
|
1534
|
+
for target in _config_targets(device):
|
|
1535
|
+
_set_config_block(
|
|
1536
|
+
target,
|
|
1537
|
+
f"interface {operation['port']}",
|
|
1538
|
+
[" switchport mode access", f" switchport access vlan {operation['vlan']}"],
|
|
1539
|
+
)
|
|
1540
|
+
return
|
|
1541
|
+
elif operation["op"] == "set_trunk_port":
|
|
1542
|
+
allowed = ",".join(str(vlan) for vlan in operation["allowed"])
|
|
1543
|
+
body = [" switchport mode trunk", f" switchport trunk allowed vlan {allowed}"]
|
|
1544
|
+
if operation.get("native"):
|
|
1545
|
+
body.append(f" switchport trunk native vlan {operation['native']}")
|
|
1546
|
+
for target in _config_targets(device):
|
|
1547
|
+
_set_config_block(target, f"interface {operation['port']}", body)
|
|
1548
|
+
return
|
|
1549
|
+
elif operation["op"] == "set_dhcp_snooping":
|
|
1550
|
+
for target in _config_targets(device):
|
|
1551
|
+
_append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}"])
|
|
1552
|
+
if operation.get("trust_port"):
|
|
1553
|
+
_append_config_block(target, f"interface {operation['trust_port']}", [" ip dhcp snooping trust"])
|
|
1554
|
+
return
|
|
1555
|
+
elif operation["op"] == "set_dai":
|
|
1556
|
+
for target in _config_targets(device):
|
|
1557
|
+
_append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}", f"ip arp inspection vlan {operation['vlan']}"])
|
|
1558
|
+
if operation.get("trust_port"):
|
|
1559
|
+
_append_config_block(target, f"interface {operation['trust_port']}", [" ip arp inspection trust", " ip dhcp snooping trust"])
|
|
1560
|
+
return
|
|
1561
|
+
elif operation["op"] == "set_port_security":
|
|
1562
|
+
body = [" switchport mode access", " switchport port-security"]
|
|
1563
|
+
if operation.get("maximum"):
|
|
1564
|
+
body.append(f" switchport port-security maximum {operation['maximum']}")
|
|
1565
|
+
if operation.get("violation"):
|
|
1566
|
+
body.append(f" switchport port-security violation {operation['violation']}")
|
|
1567
|
+
for target in _config_targets(device):
|
|
1568
|
+
_append_config_block(target, f"interface {operation['port']}", body)
|
|
1569
|
+
return
|
|
1570
|
+
elif operation["op"] == "set_lldp":
|
|
1571
|
+
for target in _config_targets(device):
|
|
1572
|
+
_append_unique_config_lines(target, ["lldp run"])
|
|
1573
|
+
return
|
|
1574
|
+
elif operation["op"] == "set_rep":
|
|
1575
|
+
for target in _config_targets(device):
|
|
1576
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" rep segment {operation['segment']}"])
|
|
1577
|
+
return
|
|
1578
|
+
elif operation["op"] == "set_span":
|
|
1579
|
+
for target in _config_targets(device):
|
|
1580
|
+
_append_unique_config_lines(
|
|
1581
|
+
target,
|
|
1582
|
+
[
|
|
1583
|
+
f"monitor session {operation['session']} source interface {operation['source']}",
|
|
1584
|
+
f"monitor session {operation['session']} destination interface {operation['destination']}",
|
|
1585
|
+
],
|
|
1586
|
+
)
|
|
1587
|
+
return
|
|
1588
|
+
elif operation["op"] == "set_dot1x":
|
|
1589
|
+
global_lines = ["aaa new-model", "dot1x system-auth-control"]
|
|
1590
|
+
if operation.get("radius_host") and operation.get("radius_key"):
|
|
1591
|
+
global_lines.append(f"radius-server host {operation['radius_host']} key {operation['radius_key']}")
|
|
1592
|
+
mode = str(operation.get("mode") or "auto")
|
|
1593
|
+
for target in _config_targets(device):
|
|
1594
|
+
_append_unique_config_lines(target, global_lines)
|
|
1595
|
+
_append_config_block(
|
|
1596
|
+
target,
|
|
1597
|
+
f"interface {operation['interface']}",
|
|
1598
|
+
[f" authentication port-control {mode}", " dot1x pae authenticator"],
|
|
1599
|
+
)
|
|
1600
|
+
return
|
|
1601
|
+
elif operation["op"] == "set_qos_policy":
|
|
1602
|
+
action = str(operation.get("action") or "priority")
|
|
1603
|
+
for target in _config_targets(device):
|
|
1604
|
+
_append_unique_config_lines(target, ["mls qos"])
|
|
1605
|
+
_append_config_block(
|
|
1606
|
+
target,
|
|
1607
|
+
f"class-map match-any {operation['class_map']}",
|
|
1608
|
+
[f" match {operation['match']}"],
|
|
1609
|
+
)
|
|
1610
|
+
_append_config_block(
|
|
1611
|
+
target,
|
|
1612
|
+
f"policy-map {operation['policy_map']}",
|
|
1613
|
+
[f" class {operation['class_map']}", f" {action}"],
|
|
1614
|
+
)
|
|
1615
|
+
_append_config_block(
|
|
1616
|
+
target,
|
|
1617
|
+
f"interface {operation['interface']}",
|
|
1618
|
+
[f" service-policy {operation['direction']} {operation['policy_map']}"],
|
|
1619
|
+
)
|
|
1620
|
+
return
|
|
1621
|
+
elif operation["op"] == "set_stp":
|
|
1622
|
+
lines = [f"spanning-tree mode {operation['mode']}"]
|
|
1623
|
+
if operation.get("vlan") and operation.get("root"):
|
|
1624
|
+
lines.append(f"spanning-tree vlan {operation['vlan']} root {operation['root']}")
|
|
1625
|
+
for target in _config_targets(device):
|
|
1626
|
+
_append_unique_config_lines(target, lines)
|
|
1627
|
+
return
|
|
1628
|
+
elif operation["op"] == "set_etherchannel":
|
|
1629
|
+
channel = int(operation["channel"])
|
|
1630
|
+
mode = str(operation.get("mode") or "active")
|
|
1631
|
+
interfaces = [str(interface) for interface in operation.get("interfaces", []) if str(interface).strip()]
|
|
1632
|
+
for target in _config_targets(device):
|
|
1633
|
+
for interface_name in interfaces:
|
|
1634
|
+
_append_config_block(target, f"interface {interface_name}", [f" channel-group {channel} mode {mode}"])
|
|
1635
|
+
_append_config_block(target, f"interface Port-channel{channel}", [" no shutdown"])
|
|
1636
|
+
return
|
|
1637
|
+
elif operation["op"] == "set_vtp":
|
|
1638
|
+
lines = [f"vtp domain {operation['domain']}", f"vtp mode {operation['mode']}"]
|
|
1639
|
+
if operation.get("version"):
|
|
1640
|
+
lines.append(f"vtp version {operation['version']}")
|
|
1641
|
+
for target in _config_targets(device):
|
|
1642
|
+
_append_unique_config_lines(target, lines)
|
|
1643
|
+
return
|
|
1644
|
+
elif operation["op"] == "set_dtp":
|
|
1645
|
+
for target in _config_targets(device):
|
|
1646
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" switchport mode {operation['mode']}"])
|
|
1647
|
+
return
|
|
1648
|
+
else:
|
|
1649
|
+
return
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
1653
|
+
"""Apply one router operation.
|
|
1654
|
+
|
|
1655
|
+
A missing field used to escape as a bare `KeyError` from inside donor
|
|
1656
|
+
validation, so `'DNS'`, `'virtual_ipv6'` and `'interface'` all surfaced as
|
|
1657
|
+
"no ranked donor candidate passed compatibility validation" -- three
|
|
1658
|
+
separate hours spent looking at donors when the fault was in the operation.
|
|
1659
|
+
"""
|
|
1660
|
+
try:
|
|
1661
|
+
return _apply_router_op_inner(device, operation)
|
|
1662
|
+
except KeyError as missing:
|
|
1663
|
+
raise KeyError(
|
|
1664
|
+
f"operation {operation.get('op', '?')!r} is missing field {missing.args[0]!r}"
|
|
1665
|
+
) from missing
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _apply_router_op_inner(device: ET.Element, operation: dict[str, object]) -> None:
|
|
1669
|
+
if operation["op"] == "set_subinterface":
|
|
1670
|
+
for target in _config_targets(device):
|
|
1671
|
+
_append_config_block(
|
|
1672
|
+
target,
|
|
1673
|
+
f"interface {operation['subinterface']}",
|
|
1674
|
+
[
|
|
1675
|
+
f" encapsulation dot1Q {operation['vlan']}",
|
|
1676
|
+
f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}",
|
|
1677
|
+
" no shutdown",
|
|
1678
|
+
],
|
|
1679
|
+
)
|
|
1680
|
+
return
|
|
1681
|
+
elif operation["op"] == "set_router_dhcp_pool":
|
|
1682
|
+
lines = [
|
|
1683
|
+
f"ip dhcp pool {operation['name']}",
|
|
1684
|
+
f" network {operation['network']} {_prefix_to_mask(int(operation['prefix']))}",
|
|
1685
|
+
f" default-router {operation['gateway']}",
|
|
1686
|
+
]
|
|
1687
|
+
if operation.get("dns"):
|
|
1688
|
+
lines.append(f" dns-server {operation['dns']}")
|
|
1689
|
+
elif operation["op"] == "set_acl":
|
|
1690
|
+
lines = [f"ip access-list {operation['acl_kind']} {operation['acl_name']}"]
|
|
1691
|
+
elif operation["op"] == "add_acl_rule":
|
|
1692
|
+
acl_kind = str(operation.get("acl_kind") or "standard")
|
|
1693
|
+
for target in _config_targets(device):
|
|
1694
|
+
_append_config_block(
|
|
1695
|
+
target,
|
|
1696
|
+
f"ip access-list {acl_kind} {operation['acl_name']}",
|
|
1697
|
+
[f" {operation['action']} {operation['source']} {operation['destination']}"] if operation.get("destination") else [f" {operation['action']} {operation['source']}"],
|
|
1698
|
+
)
|
|
1699
|
+
return
|
|
1700
|
+
elif operation["op"] == "apply_acl":
|
|
1701
|
+
for target in _config_targets(device):
|
|
1702
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ip access-group {operation['acl_name']} {operation['direction']}"])
|
|
1703
|
+
return
|
|
1704
|
+
elif operation["op"] == "enable_ipv6_unicast_routing":
|
|
1705
|
+
for target in _config_targets(device):
|
|
1706
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1707
|
+
return
|
|
1708
|
+
elif operation["op"] == "set_ipv6_address":
|
|
1709
|
+
for target in _config_targets(device):
|
|
1710
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1711
|
+
_append_config_block(
|
|
1712
|
+
target,
|
|
1713
|
+
f"interface {operation['interface']}",
|
|
1714
|
+
[f" ipv6 address {operation['address']}/{operation['prefix']}", " no shutdown"],
|
|
1715
|
+
)
|
|
1716
|
+
return
|
|
1717
|
+
elif operation["op"] == "set_ipv6_slaac":
|
|
1718
|
+
body = [" ipv6 enable", " no shutdown"]
|
|
1719
|
+
if operation.get("prefix") and operation.get("prefix_len"):
|
|
1720
|
+
body.insert(1, f" ipv6 nd prefix {operation['prefix']}/{operation['prefix_len']}")
|
|
1721
|
+
for target in _config_targets(device):
|
|
1722
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1723
|
+
_append_config_block(target, f"interface {operation['interface']}", body)
|
|
1724
|
+
return
|
|
1725
|
+
elif operation["op"] == "set_dhcpv6_pool":
|
|
1726
|
+
pool_body = [f" address prefix {operation['prefix']}/{operation['prefix_len']}"]
|
|
1727
|
+
if operation.get("dns"):
|
|
1728
|
+
pool_body.append(f" dns-server {operation['dns']}")
|
|
1729
|
+
if operation.get("domain"):
|
|
1730
|
+
pool_body.append(f" domain-name {operation['domain']}")
|
|
1731
|
+
interface_body = [f" ipv6 dhcp server {operation['name']}", " ipv6 nd managed-config-flag", " no shutdown"]
|
|
1732
|
+
for target in _config_targets(device):
|
|
1733
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1734
|
+
_append_config_block(target, f"ipv6 dhcp pool {operation['name']}", pool_body)
|
|
1735
|
+
_append_config_block(target, f"interface {operation['interface']}", interface_body)
|
|
1736
|
+
return
|
|
1737
|
+
elif operation["op"] == "set_ospfv3_interface":
|
|
1738
|
+
process_id = operation["process_id"]
|
|
1739
|
+
for target in _config_targets(device):
|
|
1740
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1741
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ipv6 ospf {process_id} area {operation['area']}", " no shutdown"])
|
|
1742
|
+
_append_config_block(target, f"ipv6 router ospf {process_id}", [])
|
|
1743
|
+
return
|
|
1744
|
+
elif operation["op"] == "set_eigrp_ipv6_interface":
|
|
1745
|
+
asn = operation["asn"]
|
|
1746
|
+
for target in _config_targets(device):
|
|
1747
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing", "no ipv6 cef"])
|
|
1748
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ipv6 eigrp {asn}", " no shutdown"])
|
|
1749
|
+
_append_config_block(target, f"ipv6 router eigrp {asn}", [" no shutdown"])
|
|
1750
|
+
return
|
|
1751
|
+
elif operation["op"] == "set_ripng_interface":
|
|
1752
|
+
for target in _config_targets(device):
|
|
1753
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1754
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ipv6 rip {operation['process_name']} enable", " no shutdown"])
|
|
1755
|
+
return
|
|
1756
|
+
elif operation["op"] == "set_hsrp_ipv6":
|
|
1757
|
+
# A standby group with no virtual address configures nothing, and the
|
|
1758
|
+
# KeyError surfaced as "no ranked donor candidate passed compatibility
|
|
1759
|
+
# validation: 'virtual_ipv6'" -- pointing at the donor rather than the
|
|
1760
|
+
# operation. Same shape as the `DNS` service-name failure.
|
|
1761
|
+
virtual = str(operation.get("virtual_ipv6") or "").strip()
|
|
1762
|
+
if not virtual:
|
|
1763
|
+
return
|
|
1764
|
+
body = [" standby version 2", f" standby {operation['group']} ipv6 {virtual}"]
|
|
1765
|
+
if operation.get("priority"):
|
|
1766
|
+
body.append(f" standby {operation['group']} priority {operation['priority']}")
|
|
1767
|
+
body.append(f" standby {operation['group']} preempt")
|
|
1768
|
+
for target in _config_targets(device):
|
|
1769
|
+
_append_unique_config_lines(target, ["ipv6 unicast-routing"])
|
|
1770
|
+
_append_config_block(target, f"interface {operation['interface']}", body)
|
|
1771
|
+
return
|
|
1772
|
+
elif operation["op"] == "set_ospfv2_network":
|
|
1773
|
+
for target in _config_targets(device):
|
|
1774
|
+
_append_config_block(
|
|
1775
|
+
target,
|
|
1776
|
+
f"router ospf {operation['process_id']}",
|
|
1777
|
+
[f" network {operation['network']} {operation['wildcard']} area {operation['area']}"],
|
|
1778
|
+
)
|
|
1779
|
+
return
|
|
1780
|
+
elif operation["op"] == "set_eigrp_ipv4_network":
|
|
1781
|
+
body = [f" network {operation['network']} {operation['wildcard']}"]
|
|
1782
|
+
if operation.get("no_auto_summary"):
|
|
1783
|
+
body.append(" no auto-summary")
|
|
1784
|
+
for target in _config_targets(device):
|
|
1785
|
+
_append_config_block(target, f"router eigrp {operation['asn']}", body)
|
|
1786
|
+
return
|
|
1787
|
+
elif operation["op"] == "set_ripv2_network":
|
|
1788
|
+
body = [" version 2", f" network {operation['network']}"]
|
|
1789
|
+
if operation.get("no_auto_summary"):
|
|
1790
|
+
body.append(" no auto-summary")
|
|
1791
|
+
for target in _config_targets(device):
|
|
1792
|
+
_append_config_block(target, "router rip", body)
|
|
1793
|
+
return
|
|
1794
|
+
elif operation["op"] == "set_static_route":
|
|
1795
|
+
for target in _config_targets(device):
|
|
1796
|
+
_append_unique_config_lines(target, [f"ip route {operation['network']} {_prefix_to_mask(int(operation['prefix']))} {operation['next_hop']}"])
|
|
1797
|
+
return
|
|
1798
|
+
elif operation["op"] == "set_dhcp_relay":
|
|
1799
|
+
for target in _config_targets(device):
|
|
1800
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ip helper-address {operation['helper']}"])
|
|
1801
|
+
return
|
|
1802
|
+
elif operation["op"] == "set_nat_interface":
|
|
1803
|
+
for target in _config_targets(device):
|
|
1804
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ip nat {operation['role']}"])
|
|
1805
|
+
return
|
|
1806
|
+
elif operation["op"] == "set_nat_static":
|
|
1807
|
+
for target in _config_targets(device):
|
|
1808
|
+
_append_unique_config_lines(target, [f"ip nat inside source static {operation['inside_local']} {operation['inside_global']}"])
|
|
1809
|
+
return
|
|
1810
|
+
elif operation["op"] == "set_pat_overload":
|
|
1811
|
+
suffix = " overload" if operation.get("overload") else ""
|
|
1812
|
+
for target in _config_targets(device):
|
|
1813
|
+
_append_unique_config_lines(target, [f"ip nat inside source list {operation['acl']} interface {operation['interface']}{suffix}"])
|
|
1814
|
+
return
|
|
1815
|
+
elif operation["op"] == "set_ssh_ios":
|
|
1816
|
+
lines = [
|
|
1817
|
+
f"ip domain-name {operation['domain']}",
|
|
1818
|
+
f"username {operation['username']} password {operation['password']}",
|
|
1819
|
+
f"crypto key generate rsa modulus {operation['modulus']}",
|
|
1820
|
+
"ip ssh version 2",
|
|
1821
|
+
]
|
|
1822
|
+
for target in _config_targets(device):
|
|
1823
|
+
_append_unique_config_lines(target, lines)
|
|
1824
|
+
return
|
|
1825
|
+
elif operation["op"] == "set_ntp_server":
|
|
1826
|
+
for target in _config_targets(device):
|
|
1827
|
+
_append_unique_config_lines(target, [f"ntp server {operation['server']}"])
|
|
1828
|
+
return
|
|
1829
|
+
elif operation["op"] == "set_syslog_server":
|
|
1830
|
+
for target in _config_targets(device):
|
|
1831
|
+
_append_unique_config_lines(target, [f"logging host {operation['server']}"])
|
|
1832
|
+
return
|
|
1833
|
+
elif operation["op"] == "set_bgp_neighbor":
|
|
1834
|
+
body = [f" neighbor {operation['neighbor']} remote-as {operation['remote_as']}"]
|
|
1835
|
+
if operation.get("network") and operation.get("mask"):
|
|
1836
|
+
body.append(f" network {operation['network']} mask {operation['mask']}")
|
|
1837
|
+
for target in _config_targets(device):
|
|
1838
|
+
_append_config_block(target, f"router bgp {operation['asn']}", body)
|
|
1839
|
+
return
|
|
1840
|
+
elif operation["op"] == "set_snmp_community":
|
|
1841
|
+
for target in _config_targets(device):
|
|
1842
|
+
_append_unique_config_lines(target, [f"snmp-server community {operation['community']} {operation['mode']}"])
|
|
1843
|
+
return
|
|
1844
|
+
elif operation["op"] == "set_netflow":
|
|
1845
|
+
global_lines = [
|
|
1846
|
+
f"ip flow-export destination {operation['destination']} {operation['port']}",
|
|
1847
|
+
f"ip flow-export version {operation['version']}",
|
|
1848
|
+
]
|
|
1849
|
+
for target in _config_targets(device):
|
|
1850
|
+
_append_unique_config_lines(target, global_lines)
|
|
1851
|
+
if operation.get("interface") and operation.get("direction"):
|
|
1852
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ip flow {operation['direction']}"])
|
|
1853
|
+
return
|
|
1854
|
+
elif operation["op"] == "set_gre_tunnel":
|
|
1855
|
+
body = []
|
|
1856
|
+
if operation.get("ip") and operation.get("prefix"):
|
|
1857
|
+
body.append(f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}")
|
|
1858
|
+
body.extend(
|
|
1859
|
+
[
|
|
1860
|
+
f" tunnel source {operation['source']}",
|
|
1861
|
+
f" tunnel destination {operation['destination']}",
|
|
1862
|
+
" tunnel mode gre ip",
|
|
1863
|
+
" no shutdown",
|
|
1864
|
+
]
|
|
1865
|
+
)
|
|
1866
|
+
for target in _config_targets(device):
|
|
1867
|
+
_append_config_block(target, f"interface {operation['interface']}", body)
|
|
1868
|
+
return
|
|
1869
|
+
elif operation["op"] == "set_ppp_interface":
|
|
1870
|
+
body = [" encapsulation ppp"]
|
|
1871
|
+
if operation.get("authentication"):
|
|
1872
|
+
body.append(f" ppp authentication {operation['authentication']}")
|
|
1873
|
+
body.append(" no shutdown")
|
|
1874
|
+
for target in _config_targets(device):
|
|
1875
|
+
_append_config_block(target, f"interface {operation['interface']}", body)
|
|
1876
|
+
return
|
|
1877
|
+
elif operation["op"] == "set_ipsec_transform_set":
|
|
1878
|
+
for target in _config_targets(device):
|
|
1879
|
+
_append_unique_config_lines(
|
|
1880
|
+
target,
|
|
1881
|
+
[f"crypto ipsec transform-set {operation['name']} {operation['encryption']} {operation['integrity']}"],
|
|
1882
|
+
)
|
|
1883
|
+
return
|
|
1884
|
+
elif operation["op"] == "set_crypto_map":
|
|
1885
|
+
for target in _config_targets(device):
|
|
1886
|
+
_append_config_block(
|
|
1887
|
+
target,
|
|
1888
|
+
f"crypto map {operation['map_name']} {operation['sequence']} ipsec-isakmp",
|
|
1889
|
+
[
|
|
1890
|
+
f" set peer {operation['peer']}",
|
|
1891
|
+
f" set transform-set {operation['transform_set']}",
|
|
1892
|
+
f" match address {operation['acl_name']}",
|
|
1893
|
+
],
|
|
1894
|
+
)
|
|
1895
|
+
if operation.get("interface"):
|
|
1896
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" crypto map {operation['map_name']}"])
|
|
1897
|
+
return
|
|
1898
|
+
elif operation["op"] == "set_cbac_inspect":
|
|
1899
|
+
for target in _config_targets(device):
|
|
1900
|
+
_append_unique_config_lines(target, [f"ip inspect name {operation['name']} {operation['protocol']}"])
|
|
1901
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" ip inspect {operation['name']} {operation['direction']}"])
|
|
1902
|
+
return
|
|
1903
|
+
elif operation["op"] == "set_zfw_zone_interface":
|
|
1904
|
+
for target in _config_targets(device):
|
|
1905
|
+
_append_config_block(target, f"zone security {operation['zone']}", [])
|
|
1906
|
+
_append_config_block(target, f"interface {operation['interface']}", [f" zone-member security {operation['zone']}"])
|
|
1907
|
+
return
|
|
1908
|
+
elif operation["op"] == "set_zfw_zone_pair":
|
|
1909
|
+
for target in _config_targets(device):
|
|
1910
|
+
_append_config_block(
|
|
1911
|
+
target,
|
|
1912
|
+
f"zone-pair security {operation['pair_name']} source {operation['source']} destination {operation['destination']}",
|
|
1913
|
+
[f" service-policy type inspect {operation['policy']}"],
|
|
1914
|
+
)
|
|
1915
|
+
return
|
|
1916
|
+
elif operation["op"] == "set_zfw_policy":
|
|
1917
|
+
for target in _config_targets(device):
|
|
1918
|
+
_append_config_block(
|
|
1919
|
+
target,
|
|
1920
|
+
f"class-map type inspect match-any {operation['class_map']}",
|
|
1921
|
+
[f" match protocol {operation['protocol']}"],
|
|
1922
|
+
)
|
|
1923
|
+
_append_config_block(
|
|
1924
|
+
target,
|
|
1925
|
+
f"policy-map type inspect {operation['policy_map']}",
|
|
1926
|
+
[f" class type inspect {operation['class_map']}", f" {operation['action']}"],
|
|
1927
|
+
)
|
|
1928
|
+
return
|
|
1929
|
+
elif operation["op"] == "set_telephony_service":
|
|
1930
|
+
body = [" no auto-reg-ephone"]
|
|
1931
|
+
if operation.get("max_ephones"):
|
|
1932
|
+
body.append(f" max-ephones {operation['max_ephones']}")
|
|
1933
|
+
if operation.get("max_dn"):
|
|
1934
|
+
body.append(f" max-dn {operation['max_dn']}")
|
|
1935
|
+
body.append(f" ip source-address {operation['source_address']} port {operation['port']}")
|
|
1936
|
+
for target in _config_targets(device):
|
|
1937
|
+
_append_config_block(target, "telephony-service", body)
|
|
1938
|
+
return
|
|
1939
|
+
elif operation["op"] == "set_ephone_dn":
|
|
1940
|
+
for target in _config_targets(device):
|
|
1941
|
+
_append_config_block(target, f"ephone-dn {operation['dn_id']}", [f" number {operation['number']}"])
|
|
1942
|
+
return
|
|
1943
|
+
elif operation["op"] == "set_ephone":
|
|
1944
|
+
for target in _config_targets(device):
|
|
1945
|
+
_append_config_block(
|
|
1946
|
+
target,
|
|
1947
|
+
f"ephone {operation['ephone_id']}",
|
|
1948
|
+
[f" mac-address {operation['mac']}", f" button {operation['button']}"],
|
|
1949
|
+
)
|
|
1950
|
+
return
|
|
1951
|
+
elif operation["op"] == "set_dial_peer_voice":
|
|
1952
|
+
for target in _config_targets(device):
|
|
1953
|
+
_append_config_block(
|
|
1954
|
+
target,
|
|
1955
|
+
f"dial-peer voice {operation['peer_id']} voip",
|
|
1956
|
+
[
|
|
1957
|
+
f" destination-pattern {operation['destination_pattern']}",
|
|
1958
|
+
f" session target ipv4:{operation['session_target']}",
|
|
1959
|
+
],
|
|
1960
|
+
)
|
|
1961
|
+
return
|
|
1962
|
+
else:
|
|
1963
|
+
return
|
|
1964
|
+
for target in _config_targets(device):
|
|
1965
|
+
_append_unique_config_lines(target, lines)
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
def _apply_management_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
1969
|
+
if operation["op"] == "set_management_vlan":
|
|
1970
|
+
for target in _config_targets(device):
|
|
1971
|
+
_append_config_block(
|
|
1972
|
+
target,
|
|
1973
|
+
f"interface Vlan{operation['vlan']}",
|
|
1974
|
+
[f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}", " no shutdown"],
|
|
1975
|
+
)
|
|
1976
|
+
_append_unique_config_lines(target, [f"ip default-gateway {operation['gateway']}"])
|
|
1977
|
+
return
|
|
1978
|
+
elif operation["op"] == "enable_telnet":
|
|
1979
|
+
for target in _config_targets(device):
|
|
1980
|
+
_append_unique_config_lines(target, [f"username {operation['username']} secret {operation['password']}", f"enable secret {operation['password']}"])
|
|
1981
|
+
_append_config_block(target, "line vty 0 4", [" login local", " transport input telnet"])
|
|
1982
|
+
return
|
|
1983
|
+
else:
|
|
1984
|
+
return
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
def _set_enabled_service(engine: ET.Element, service_name: str) -> None:
|
|
1988
|
+
mapping = {
|
|
1989
|
+
"dns": ("DNS_SERVER", "ENABLED"),
|
|
1990
|
+
"http": ("HTTP_SERVER", "ENABLED"),
|
|
1991
|
+
"https": ("HTTPS_SERVER", "HTTPSENABLED"),
|
|
1992
|
+
"ftp": ("FTP_SERVER", "ENABLED"),
|
|
1993
|
+
"tftp": ("TFTP_SERVER", "ENABLED"),
|
|
1994
|
+
"ntp": ("NTP_SERVER", "ENABLED"),
|
|
1995
|
+
"syslog": ("SYSLOG_SERVER", "ENABLED"),
|
|
1996
|
+
"aaa": ("ACS_SERVER", "ENABLED"),
|
|
1997
|
+
}
|
|
1998
|
+
service_name = service_name.strip().lower()
|
|
1999
|
+
if service_name == "email":
|
|
2000
|
+
email = engine.find("EMAIL_SERVER")
|
|
2001
|
+
if email is None:
|
|
2002
|
+
prototype = _server_engine_prototype_child("EMAIL_SERVER")
|
|
2003
|
+
email = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "EMAIL_SERVER")
|
|
2004
|
+
if email not in list(engine):
|
|
2005
|
+
engine.append(email)
|
|
2006
|
+
_ensure_text(email, "SMTP_ENABLED", "1")
|
|
2007
|
+
_ensure_text(email, "POP3_ENABLED", "1")
|
|
2008
|
+
return
|
|
2009
|
+
# Callers reach this with whatever the prompt produced, and an unknown name
|
|
2010
|
+
# used to raise KeyError out of the middle of donor validation -- surfacing
|
|
2011
|
+
# as "no ranked donor candidate passed compatibility validation: 'DNS'",
|
|
2012
|
+
# which points at the donor rather than at the service name.
|
|
2013
|
+
entry = mapping.get(service_name.strip().lower())
|
|
2014
|
+
if entry is None:
|
|
2015
|
+
return
|
|
2016
|
+
tag, enabled_tag = entry
|
|
2017
|
+
node = engine.find(tag)
|
|
2018
|
+
if node is None:
|
|
2019
|
+
prototype = _server_engine_prototype_child(tag)
|
|
2020
|
+
node = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, tag)
|
|
2021
|
+
if node not in list(engine):
|
|
2022
|
+
engine.append(node)
|
|
2023
|
+
_ensure_text(node, enabled_tag, "1")
|
|
2024
|
+
|
|
2025
|
+
|
|
2026
|
+
def _server_engine_prototype_child(tag: str) -> ET.Element | None:
|
|
2027
|
+
sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
|
|
2028
|
+
for device in sample_root.findall(".//DEVICES/DEVICE"):
|
|
2029
|
+
engine = device.find("ENGINE")
|
|
2030
|
+
if engine is None or _device_type(device) != "Server":
|
|
2031
|
+
continue
|
|
2032
|
+
child = engine.find(tag)
|
|
2033
|
+
if child is not None:
|
|
2034
|
+
return child
|
|
2035
|
+
return None
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def _apply_server_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
2039
|
+
engine = device.find("./ENGINE")
|
|
2040
|
+
if engine is None:
|
|
2041
|
+
return
|
|
2042
|
+
if operation["op"] == "set_server_dns_record":
|
|
2043
|
+
dns_server = engine.find("DNS_SERVER")
|
|
2044
|
+
if dns_server is None:
|
|
2045
|
+
prototype = _server_engine_prototype_child("DNS_SERVER")
|
|
2046
|
+
dns_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DNS_SERVER")
|
|
2047
|
+
engine.append(dns_server)
|
|
2048
|
+
_ensure_text(dns_server, "ENABLED", "1")
|
|
2049
|
+
database = dns_server.find("NAMESERVER-DATABASE")
|
|
2050
|
+
if database is None:
|
|
2051
|
+
database = ET.SubElement(dns_server, "NAMESERVER-DATABASE")
|
|
2052
|
+
record = ET.SubElement(database, "RESOURCE-RECORD")
|
|
2053
|
+
if operation["record_type"] == "A":
|
|
2054
|
+
_ensure_text(record, "TYPE", "A-REC")
|
|
2055
|
+
_ensure_text(record, "NAME", str(operation["name"]))
|
|
2056
|
+
_ensure_text(record, "TTL", "86400")
|
|
2057
|
+
_ensure_text(record, "IPADDRESS", str(operation["value"]))
|
|
2058
|
+
else:
|
|
2059
|
+
_ensure_text(record, "TYPE", "CNAME")
|
|
2060
|
+
_ensure_text(record, "NAME", str(operation["name"]))
|
|
2061
|
+
_ensure_text(record, "TTL", "86400")
|
|
2062
|
+
_ensure_text(record, "SERVER-NAME", str(operation["value"]))
|
|
2063
|
+
elif operation["op"] == "set_server_dhcp_pool":
|
|
2064
|
+
dhcp_server = engine.find("DHCP_SERVER")
|
|
2065
|
+
if dhcp_server is None:
|
|
2066
|
+
prototype = _server_engine_prototype_child("DHCP_SERVER")
|
|
2067
|
+
dhcp_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DHCP_SERVER")
|
|
2068
|
+
engine.append(dhcp_server)
|
|
2069
|
+
_ensure_text(dhcp_server, "ENABLED", "1")
|
|
2070
|
+
pools = dhcp_server.find("POOLS")
|
|
2071
|
+
if pools is None:
|
|
2072
|
+
pools = ET.SubElement(dhcp_server, "POOLS")
|
|
2073
|
+
pool = ET.SubElement(pools, "POOL")
|
|
2074
|
+
_ensure_text(pool, "NAME", str(operation["name"]))
|
|
2075
|
+
_ensure_text(pool, "NETWORK", str(operation["network"]))
|
|
2076
|
+
_ensure_text(pool, "MASK", _prefix_to_mask(int(operation["prefix"])))
|
|
2077
|
+
_ensure_text(pool, "DEFAULT_ROUTER", str(operation["gateway"]))
|
|
2078
|
+
_ensure_text(pool, "START_IP", str(operation.get("start") or operation["network"]))
|
|
2079
|
+
_ensure_text(pool, "END_IP", str(operation.get("start") or operation["network"]))
|
|
2080
|
+
_ensure_text(pool, "DNS_SERVER", str(operation.get("dns") or "0.0.0.0"))
|
|
2081
|
+
_ensure_text(pool, "MAX_USERS", str(operation.get("max_users") or 0))
|
|
2082
|
+
elif operation["op"] == "enable_server_service":
|
|
2083
|
+
_set_enabled_service(engine, str(operation["service"]))
|
|
2084
|
+
elif operation["op"] == "set_server_email_domain":
|
|
2085
|
+
email_server = engine.find("EMAIL_SERVER")
|
|
2086
|
+
if email_server is None:
|
|
2087
|
+
prototype = _server_engine_prototype_child("EMAIL_SERVER")
|
|
2088
|
+
email_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "EMAIL_SERVER")
|
|
2089
|
+
engine.append(email_server)
|
|
2090
|
+
_ensure_text(email_server, "SMTP_ENABLED", "1")
|
|
2091
|
+
_ensure_text(email_server, "POP3_ENABLED", "1")
|
|
2092
|
+
_ensure_text(email_server, "SMTP_DOMAIN", str(operation["domain"]))
|
|
2093
|
+
elif operation["op"] == "set_server_aaa_auth_port":
|
|
2094
|
+
acs_server = engine.find("ACS_SERVER")
|
|
2095
|
+
if acs_server is None:
|
|
2096
|
+
prototype = _server_engine_prototype_child("ACS_SERVER")
|
|
2097
|
+
acs_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "ACS_SERVER")
|
|
2098
|
+
engine.append(acs_server)
|
|
2099
|
+
_ensure_text(acs_server, "ENABLED", "1")
|
|
2100
|
+
radius_settings = acs_server.find("RADIUS_SETTINGS")
|
|
2101
|
+
if radius_settings is None:
|
|
2102
|
+
radius_settings = ET.SubElement(acs_server, "RADIUS_SETTINGS")
|
|
2103
|
+
_ensure_text(radius_settings, "AUTH_PORT", str(operation["auth_port"]))
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _wireless_common_nodes(engine: ET.Element) -> list[ET.Element]:
|
|
2107
|
+
nodes: list[ET.Element] = []
|
|
2108
|
+
for path in [
|
|
2109
|
+
"./WIRELESS_SERVER/WIRELESS_COMMON",
|
|
2110
|
+
"./WIRELESS_CLIENT/WIRELESS_COMMON",
|
|
2111
|
+
"./WLC/WLANS/WLAN_CONFIG",
|
|
2112
|
+
"./CAPWAP_AC/WLANS/WLAN_CONFIG",
|
|
2113
|
+
]:
|
|
2114
|
+
node = engine.find(path)
|
|
2115
|
+
if node is not None:
|
|
2116
|
+
nodes.append(node)
|
|
2117
|
+
return nodes
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
def _profile_nodes(engine: ET.Element) -> list[ET.Element]:
|
|
2121
|
+
return engine.findall("./WIRELESS_CLIENT/PROFILES/WIRELESS_PROFILE") + engine.findall("./WIRELESS_CLIENT/CURRENT_PROFILE/WIRELESS_PROFILE")
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _apply_wireless_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
2125
|
+
engine = device.find("./ENGINE")
|
|
2126
|
+
if engine is None:
|
|
2127
|
+
return
|
|
2128
|
+
if operation["op"] == "set_wireless_ssid":
|
|
2129
|
+
for node in _wireless_common_nodes(engine):
|
|
2130
|
+
_ensure_text(node, "SSID", str(operation["ssid"]))
|
|
2131
|
+
_ensure_text(node, "AUTHEN_TYPE", str(operation["auth_type"]))
|
|
2132
|
+
_ensure_text(node, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
|
|
2133
|
+
if node.find("STANDARD_CHANNEL") is not None:
|
|
2134
|
+
_ensure_text(node, "STANDARD_CHANNEL", str(operation["channel"]))
|
|
2135
|
+
if node.find("CHANNEL") is not None:
|
|
2136
|
+
_ensure_text(node, "CHANNEL", str(operation["channel"]))
|
|
2137
|
+
if operation.get("passphrase"):
|
|
2138
|
+
if node.find("WEP_KEY") is not None:
|
|
2139
|
+
_ensure_text(node, "WEP_KEY", str(operation["passphrase"]))
|
|
2140
|
+
if node.find("WPA_PASSPHRASE") is not None:
|
|
2141
|
+
_ensure_text(node, "WPA_PASSPHRASE", str(operation["passphrase"]))
|
|
2142
|
+
for profile in _profile_nodes(engine):
|
|
2143
|
+
_ensure_text(profile, "NAME", str(operation["ssid"]))
|
|
2144
|
+
_ensure_text(profile, "SSID", str(operation["ssid"]))
|
|
2145
|
+
_ensure_text(profile, "AUTHEN_TYPE", str(operation["auth_type"]))
|
|
2146
|
+
_ensure_text(profile, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
|
|
2147
|
+
_ensure_text(profile, "CHANNEL", str(operation["channel"]))
|
|
2148
|
+
if profile.find("WEP_KEY") is not None:
|
|
2149
|
+
_ensure_text(profile, "WEP_KEY", str(operation.get("passphrase") or ""))
|
|
2150
|
+
elif operation["op"] == "associate_wireless_client":
|
|
2151
|
+
for node in _wireless_common_nodes(engine):
|
|
2152
|
+
_ensure_text(node, "SSID", str(operation["ssid"]))
|
|
2153
|
+
for profile in _profile_nodes(engine):
|
|
2154
|
+
_ensure_text(profile, "NAME", str(operation["ssid"]))
|
|
2155
|
+
_ensure_text(profile, "SSID", str(operation["ssid"]))
|
|
2156
|
+
_ensure_text(profile, "DHCP_ENABLED", "1" if operation.get("ip_mode", "dhcp") == "dhcp" else "0")
|
|
2157
|
+
for port in device.findall(".//PORT"):
|
|
2158
|
+
if port.findtext("TYPE", default="").startswith("eHostWireless"):
|
|
2159
|
+
_ensure_text(port, "PORT_DHCP_ENABLE", "true" if operation.get("ip_mode", "dhcp") == "dhcp" else "false")
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _apply_end_device_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
2163
|
+
if operation["op"] == "set_host_ip":
|
|
2164
|
+
apply_host_ip(device, operation)
|
|
2165
|
+
elif operation["op"] == "set_host_dhcp":
|
|
2166
|
+
for port in device.findall(".//PORT"):
|
|
2167
|
+
if port.find("PORT_DHCP_ENABLE") is not None:
|
|
2168
|
+
_ensure_text(port, "PORT_DHCP_ENABLE", "true")
|
|
2169
|
+
for profile in _profile_nodes(device.find("./ENGINE") or ET.Element("ENGINE")):
|
|
2170
|
+
_ensure_text(profile, "DHCP_ENABLED", "1")
|
|
2171
|
+
elif operation["op"] == "set_host_dns":
|
|
2172
|
+
apply_host_ip(device, operation)
|
|
2173
|
+
|
|
2174
|
+
|
|
2175
|
+
def _apply_iot_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
2176
|
+
engine = device.find("./ENGINE")
|
|
2177
|
+
if engine is None:
|
|
2178
|
+
return
|
|
2179
|
+
if operation["op"] == "set_iot_registration":
|
|
2180
|
+
client = engine.find("IOE_CLIENT")
|
|
2181
|
+
if client is None:
|
|
2182
|
+
client = ET.SubElement(engine, "IOE_CLIENT")
|
|
2183
|
+
if operation.get("mode"):
|
|
2184
|
+
_ensure_text(client, "CLIENT_MODE", str(operation["mode"]))
|
|
2185
|
+
if operation.get("server_address"):
|
|
2186
|
+
_ensure_text(client, "SERVER_ADDRESS", str(operation["server_address"]))
|
|
2187
|
+
if operation.get("username"):
|
|
2188
|
+
_ensure_text(client, "USERNAME", str(operation["username"]))
|
|
2189
|
+
if operation.get("password"):
|
|
2190
|
+
_ensure_text(client, "PASSWORD", str(operation["password"]))
|
|
2191
|
+
return
|
|
2192
|
+
if operation["op"] == "set_iot_rule_state":
|
|
2193
|
+
for node in engine.findall("./IOE_USER_MANAGER/USERS/USER/IOE_RULES/IOE_RULE/JSON"):
|
|
2194
|
+
try:
|
|
2195
|
+
payload = json.loads(node.text or "{}")
|
|
2196
|
+
except json.JSONDecodeError:
|
|
2197
|
+
continue
|
|
2198
|
+
if str(payload.get("name", "")).strip() != str(operation["rule_name"]).strip():
|
|
2199
|
+
continue
|
|
2200
|
+
payload["enabled"] = bool(operation["enabled"])
|
|
2201
|
+
node.text = json.dumps(payload)
|
|
2202
|
+
return
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _find_script_file(device: ET.Element, app_name: str, file_name: str) -> ET.Element:
|
|
2206
|
+
app_matches = [
|
|
2207
|
+
directory
|
|
2208
|
+
for directory in device.findall(".//FILE[@class='CDirectory']")
|
|
2209
|
+
if directory.findtext("NAME", default="").strip() == app_name
|
|
2210
|
+
]
|
|
2211
|
+
if not app_matches:
|
|
2212
|
+
device_name = device.findtext("./ENGINE/NAME", default="")
|
|
2213
|
+
raise ValueError(f"Script app {app_name!r} was not found on device {device_name!r}.")
|
|
2214
|
+
file_matches: list[ET.Element] = []
|
|
2215
|
+
for directory in app_matches:
|
|
2216
|
+
file_matches.extend(
|
|
2217
|
+
file_node
|
|
2218
|
+
for file_node in directory.findall(".//FILE[@class='CFile']")
|
|
2219
|
+
if file_node.findtext("NAME", default="").strip() == file_name
|
|
2220
|
+
)
|
|
2221
|
+
if not file_matches:
|
|
2222
|
+
raise ValueError(f"Script file {file_name!r} was not found in app {app_name!r}.")
|
|
2223
|
+
if len(file_matches) > 1:
|
|
2224
|
+
raise ValueError(f"Script file {file_name!r} in app {app_name!r} is ambiguous.")
|
|
2225
|
+
return file_matches[0]
|
|
2226
|
+
|
|
2227
|
+
|
|
2228
|
+
def _apply_programming_op(device: ET.Element, operation: dict[str, object]) -> None:
|
|
2229
|
+
if operation["op"] != "set_script_file_content":
|
|
2230
|
+
return
|
|
2231
|
+
file_node = _find_script_file(device, str(operation["app_name"]), str(operation["file_name"]))
|
|
2232
|
+
content_node = file_node.find("FILE_CONTENT")
|
|
2233
|
+
if content_node is None:
|
|
2234
|
+
content_node = ET.SubElement(file_node, "FILE_CONTENT", {"class": "CTextFileContent"})
|
|
2235
|
+
_ensure_text(content_node, "TEXT", str(operation["content"]))
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
def apply_plan_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
|
|
2239
|
+
updated = copy.deepcopy(root)
|
|
2240
|
+
port_mem_map = _link_port_mem_map(updated)
|
|
2241
|
+
acl_kind_map: dict[str, str] = {}
|
|
2242
|
+
acl_device_map: dict[str, str] = {}
|
|
2243
|
+
for operation in plan.router_ops:
|
|
2244
|
+
if operation["op"] == "set_acl":
|
|
2245
|
+
acl_kind_map[str(operation["acl_name"])] = str(operation["acl_kind"])
|
|
2246
|
+
acl_device_map[str(operation["acl_name"])] = str(operation["device"])
|
|
2247
|
+
for operation in plan.router_ops:
|
|
2248
|
+
if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_kind_map:
|
|
2249
|
+
operation["acl_kind"] = acl_kind_map[str(operation["acl_name"])]
|
|
2250
|
+
if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_device_map:
|
|
2251
|
+
operation["device"] = acl_device_map[str(operation["acl_name"])]
|
|
2252
|
+
# Renaming a device onto a name another device still holds leaves two
|
|
2253
|
+
# devices answering to it, and every later lookup by name becomes a coin
|
|
2254
|
+
# toss. Measured: a plan renamed SWP1 to SW1 while the donor's own SW1 was
|
|
2255
|
+
# still there, waiting to be pruned two operations later. The prune then
|
|
2256
|
+
# stripped the *new* SW1's cables -- its two PCs ended up with no links at
|
|
2257
|
+
# all -- and left the old SW1's link pointing at a device that no longer
|
|
2258
|
+
# existed, which is the dangling SAVE_REF the workspace check reports.
|
|
2259
|
+
#
|
|
2260
|
+
# Prunes cannot simply run first: the ones recorded for clone collisions
|
|
2261
|
+
# name devices by their *final* name, so they depend on the renames. Pull
|
|
2262
|
+
# forward only the prune that a rename is about to collide with, and let
|
|
2263
|
+
# the original operation no-op when its turn comes.
|
|
2264
|
+
pending_prunes = Counter(
|
|
2265
|
+
str(operation.get("device"))
|
|
2266
|
+
for operation in plan.edit_operations
|
|
2267
|
+
if operation.get("op") == "prune_device"
|
|
2268
|
+
)
|
|
2269
|
+
prunes_pulled_forward: Counter[str] = Counter()
|
|
2270
|
+
|
|
2271
|
+
for operation in plan.edit_operations:
|
|
2272
|
+
if operation["op"] == "duplicate_host":
|
|
2273
|
+
_duplicate_host_onto_switch(
|
|
2274
|
+
updated,
|
|
2275
|
+
str(operation["device"]),
|
|
2276
|
+
str(operation["new_name"]),
|
|
2277
|
+
str(operation["switch"]),
|
|
2278
|
+
str(operation.get("switch_port", "")),
|
|
2279
|
+
str(operation.get("host_port", "FastEthernet0")),
|
|
2280
|
+
int(operation.get("x", 0)),
|
|
2281
|
+
int(operation.get("y", 0)),
|
|
2282
|
+
)
|
|
2283
|
+
continue
|
|
2284
|
+
if operation["op"] == "duplicate_group":
|
|
2285
|
+
_duplicate_group(
|
|
2286
|
+
updated,
|
|
2287
|
+
str(operation["device"]),
|
|
2288
|
+
str(operation["new_name"]),
|
|
2289
|
+
[str(name) for name in operation.get("hosts", [])],
|
|
2290
|
+
int(operation.get("x", 0)),
|
|
2291
|
+
int(operation.get("y", 0)),
|
|
2292
|
+
[str(name) for name in operation.get("new_hosts", [])] or None,
|
|
2293
|
+
)
|
|
2294
|
+
continue
|
|
2295
|
+
if operation["op"] == "duplicate_device":
|
|
2296
|
+
_duplicate_device(
|
|
2297
|
+
updated,
|
|
2298
|
+
str(operation["device"]),
|
|
2299
|
+
str(operation["new_name"]),
|
|
2300
|
+
int(operation.get("x", 0)),
|
|
2301
|
+
int(operation.get("y", 0)),
|
|
2302
|
+
)
|
|
2303
|
+
continue
|
|
2304
|
+
if operation["op"] == "prune_device":
|
|
2305
|
+
prune_name = str(operation["device"])
|
|
2306
|
+
pending_prunes[prune_name] -= 1
|
|
2307
|
+
if prunes_pulled_forward[prune_name] > 0:
|
|
2308
|
+
# Already done, ahead of the rename that needed it gone. Running
|
|
2309
|
+
# it again here would delete the device that now carries the
|
|
2310
|
+
# name -- the very device the plan renamed into place.
|
|
2311
|
+
prunes_pulled_forward[prune_name] -= 1
|
|
2312
|
+
continue
|
|
2313
|
+
_prune_device(updated, prune_name)
|
|
2314
|
+
continue
|
|
2315
|
+
if operation["op"] == "remove_link":
|
|
2316
|
+
_remove_link(updated, str(operation["a"]["dev"]), str(operation["b"]["dev"]))
|
|
2317
|
+
continue
|
|
2318
|
+
if operation["op"] == "apply_cli":
|
|
2319
|
+
device = _find_device(updated, str(operation["device"]))
|
|
2320
|
+
if device is not None:
|
|
2321
|
+
apply_cli_lines(device, [str(line) for line in operation.get("lines", [])])
|
|
2322
|
+
continue
|
|
2323
|
+
if operation["op"] == "set_link":
|
|
2324
|
+
_ensure_link(
|
|
2325
|
+
updated,
|
|
2326
|
+
str(operation["a"]["dev"]),
|
|
2327
|
+
str(operation["a"]["port"]),
|
|
2328
|
+
str(operation["b"]["dev"]),
|
|
2329
|
+
str(operation["b"]["port"]),
|
|
2330
|
+
str(operation.get("media", "copper")),
|
|
2331
|
+
port_mem_map=port_mem_map,
|
|
2332
|
+
)
|
|
2333
|
+
continue
|
|
2334
|
+
device = _find_device(updated, str(operation["device"]))
|
|
2335
|
+
if device is None:
|
|
2336
|
+
continue
|
|
2337
|
+
if operation["op"] == "rename_device":
|
|
2338
|
+
new_name = str(operation["new_name"])
|
|
2339
|
+
occupant = _find_device(updated, new_name)
|
|
2340
|
+
if (
|
|
2341
|
+
occupant is not None
|
|
2342
|
+
and occupant is not device
|
|
2343
|
+
and pending_prunes[new_name] > 0
|
|
2344
|
+
):
|
|
2345
|
+
_prune_device(updated, new_name)
|
|
2346
|
+
pending_prunes[new_name] -= 1
|
|
2347
|
+
prunes_pulled_forward[new_name] += 1
|
|
2348
|
+
_set_device_name(updated, device, new_name)
|
|
2349
|
+
elif operation["op"] == "reflow_layout":
|
|
2350
|
+
_set_device_position(device, int(operation["x"]), int(operation["y"]))
|
|
2351
|
+
|
|
2352
|
+
# Duplication has to run last, from devices carrying their final names, so
|
|
2353
|
+
# a link to a duplicated device is attempted before that device exists.
|
|
2354
|
+
# `_ensure_link` returns in silence when an endpoint is missing, and a
|
|
2355
|
+
# 22-switch lab lost every one of its nineteen core uplinks that way: 62
|
|
2356
|
+
# planned links arrived as 43, with no error anywhere. Re-running the link
|
|
2357
|
+
# pass once every device exists costs nothing, because `_ensure_link`
|
|
2358
|
+
# updates the link it already made for a pair rather than adding a second.
|
|
2359
|
+
for operation in plan.edit_operations:
|
|
2360
|
+
if operation.get("op") != "set_link":
|
|
2361
|
+
continue
|
|
2362
|
+
_ensure_link(
|
|
2363
|
+
updated,
|
|
2364
|
+
str(operation["a"]["dev"]),
|
|
2365
|
+
str(operation["a"]["port"]),
|
|
2366
|
+
str(operation["b"]["dev"]),
|
|
2367
|
+
str(operation["b"]["port"]),
|
|
2368
|
+
str(operation.get("media", "copper")),
|
|
2369
|
+
port_mem_map=port_mem_map,
|
|
2370
|
+
)
|
|
2371
|
+
|
|
2372
|
+
for bucket, handler in [
|
|
2373
|
+
(plan.switch_ops, _apply_switch_op),
|
|
2374
|
+
(plan.router_ops, _apply_router_op),
|
|
2375
|
+
(plan.server_ops, _apply_server_op),
|
|
2376
|
+
(plan.wireless_ops, _apply_wireless_op),
|
|
2377
|
+
(plan.end_device_ops, _apply_end_device_op),
|
|
2378
|
+
(plan.management_ops, _apply_management_op),
|
|
2379
|
+
(plan.iot_ops, _apply_iot_op),
|
|
2380
|
+
(plan.programming_ops, _apply_programming_op),
|
|
2381
|
+
]:
|
|
2382
|
+
for operation in bucket:
|
|
2383
|
+
device = _find_device(updated, str(operation["device"]))
|
|
2384
|
+
if device is None:
|
|
2385
|
+
continue
|
|
2386
|
+
handler(device, operation)
|
|
2387
|
+
return updated
|
|
2388
|
+
|
|
2389
|
+
|
|
2390
|
+
def apply_edit_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
|
|
2391
|
+
return apply_plan_operations(root, plan)
|
|
2392
|
+
|
|
2393
|
+
|
|
2394
|
+
def edit_pkt_file(pkt_path: str | Path, plan: IntentPlan, output_path: str | Path, xml_out_path: str | Path | None = None) -> Path:
|
|
2395
|
+
root = decode_pkt_to_root(pkt_path)
|
|
2396
|
+
updated = apply_plan_operations(root, plan)
|
|
2397
|
+
xml_bytes = serialize_pkt_xml(updated)
|
|
2398
|
+
output_path = Path(output_path)
|
|
2399
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2400
|
+
output_path.write_bytes(encode_pkt_modern(xml_bytes))
|
|
2401
|
+
if xml_out_path is not None:
|
|
2402
|
+
xml_path = Path(xml_out_path)
|
|
2403
|
+
xml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2404
|
+
xml_path.write_bytes(xml_bytes)
|
|
2405
|
+
return output_path
|