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.
Files changed (66) hide show
  1. package/CHANGELOG.md +424 -42
  2. package/README.md +535 -250
  3. package/SKILL.md +337 -262
  4. package/bin/packet-tracer-skill.js +29 -2
  5. package/docs/automation-controller-proof.md +35 -0
  6. package/docs/curated-donor-registry.md +11 -0
  7. package/docs/generate-ready-pilot-design.md +30 -0
  8. package/docs/github-launch-ops-0.2.3.md +37 -0
  9. package/docs/github-metadata.md +6 -4
  10. package/docs/hero-demo-plan.md +1 -1
  11. package/docs/home-iot-donor-proof.md +4 -4
  12. package/docs/industrial-programming-proof.md +48 -0
  13. package/docs/ipv4-routing-management-proof.md +37 -0
  14. package/docs/l2-resiliency-bgp-proof.md +60 -0
  15. package/docs/l2-security-qos-proof.md +59 -0
  16. package/docs/packet-tracer-feature-gap-atlas.md +174 -17
  17. package/docs/post-launch-follow-up.md +9 -5
  18. package/docs/proof-readiness-dashboard.md +69 -0
  19. package/docs/publish-preview-roadmap.md +6 -5
  20. package/docs/release-checklist.md +27 -13
  21. package/docs/release-notes-0.2.2.md +1 -1
  22. package/docs/release-notes-0.2.3.md +59 -0
  23. package/docs/release-notes-0.2.4.md +20 -0
  24. package/docs/runtime-truth.md +33 -8
  25. package/docs/security-edge-deepening-proof.md +65 -0
  26. package/docs/voice-collaboration-proof.md +38 -0
  27. package/docs/wan-security-donor-proof.md +20 -3
  28. package/examples/README.md +98 -69
  29. package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
  30. package/examples/gallery.md +94 -6
  31. package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
  32. package/examples/index.json +932 -4
  33. package/examples/local-sample-evidence.json +24 -0
  34. package/examples/proof-cards.json +117 -0
  35. package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
  36. package/package.json +60 -44
  37. package/pytest.ini +9 -0
  38. package/references/packettracer-feature-atlas.json +67 -17
  39. package/references/packettracer-sample-catalog.json +45287 -4525
  40. package/references/packettracer-sample-catalog.md +599 -259
  41. package/references/proof-readiness-candidates.json +352 -0
  42. package/scripts/build_examples_index.py +228 -35
  43. package/scripts/build_sample_catalog.py +24 -44
  44. package/scripts/corpus_runner.py +430 -0
  45. package/scripts/coverage_matrix.py +1842 -1319
  46. package/scripts/donor_cache.py +354 -0
  47. package/scripts/donor_diagnostics.py +3 -1
  48. package/scripts/feature_atlas.py +65 -1
  49. package/scripts/generate_pkt.py +8762 -4070
  50. package/scripts/intent_parser.py +2242 -1138
  51. package/scripts/local_donors.py +340 -0
  52. package/scripts/packet_tracer_env.py +846 -391
  53. package/scripts/pkt_annotate.py +218 -0
  54. package/scripts/pkt_codec.py +420 -181
  55. package/scripts/pkt_editor.py +2405 -1226
  56. package/scripts/pkt_transformer.py +1072 -727
  57. package/scripts/pkt_verify.py +461 -0
  58. package/scripts/remote_search.py +197 -21
  59. package/scripts/runtime_doctor.py +80 -29
  60. package/scripts/sample_catalog.py +1372 -1195
  61. package/scripts/twofish_diagnostics.py +48 -31
  62. package/scripts/usage_ledger.py +218 -0
  63. package/scripts/vendor/README.md +44 -37
  64. package/scripts/vendor/twofish_pure.py +321 -0
  65. package/scripts/workspace_repair.py +548 -508
  66. package/templates/pt900/donors/README.md +15 -0
@@ -1,1226 +1,2405 @@
1
- from __future__ import annotations
2
-
3
- import copy
4
- import json
5
- import re
6
- from pathlib import Path
7
- import xml.etree.ElementTree as ET
8
-
9
- from intent_parser import IntentPlan
10
- from packet_tracer_env import resolve_sample_path
11
- from pkt_codec import decode_pkt_modern, encode_pkt_modern
12
- from pkt_transformer import _device_type, _port_address_for_name, apply_cable_type, apply_host_ip, load_sample_root
13
-
14
-
15
- FTP_SAMPLE = r"01 Networking\FTP\FTP.pkt"
16
- SERVER_SAMPLE = r"01 Networking\DNS\Multilevel_DNS.pkt"
17
- WIRELESS_SAMPLE = r"01 Networking\DHCP\dhcp_reservation.pkt"
18
-
19
-
20
- def decode_pkt_to_root(pkt_path: str | Path) -> ET.Element:
21
- return ET.fromstring(decode_pkt_modern(Path(pkt_path).read_bytes()))
22
-
23
-
24
- def inventory_devices(root: ET.Element) -> list[dict[str, str]]:
25
- devices: list[dict[str, str]] = []
26
- for device in root.findall(".//DEVICES/DEVICE"):
27
- devices.append(
28
- {
29
- "name": device.findtext("./ENGINE/NAME", default=""),
30
- "type": _device_type(device),
31
- "model": device.find("./ENGINE/TYPE").get("model", "") if device.find("./ENGINE/TYPE") is not None else "",
32
- }
33
- )
34
- return devices
35
-
36
-
37
- def inventory_links(root: ET.Element) -> list[dict[str, object]]:
38
- devices = root.findall(".//DEVICES/DEVICE")
39
- index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(devices)}
40
- save_ref_to_name = {
41
- device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
42
- for device in devices
43
- if device.findtext("./ENGINE/SAVE_REF_ID", default="")
44
- }
45
- result: list[dict[str, object]] = []
46
- for link in root.findall(".//LINKS/LINK"):
47
- cable = link.find("./CABLE")
48
- if cable is None:
49
- continue
50
- ports = cable.findall("PORT")
51
- from_ref = cable.findtext("FROM", default="")
52
- to_ref = cable.findtext("TO", default="")
53
- result.append(
54
- {
55
- "from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
56
- "to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
57
- "ports": [port.text or "" for port in ports],
58
- "media": cable.findtext("TYPE", default=""),
59
- }
60
- )
61
- return result
62
-
63
-
64
- def inventory_services(root: ET.Element) -> dict[str, list[str]]:
65
- result: dict[str, list[str]] = {}
66
- for device in root.findall(".//DEVICES/DEVICE"):
67
- name = device.findtext("./ENGINE/NAME", default="")
68
- engine = device.find("./ENGINE")
69
- if engine is None:
70
- continue
71
- enabled: list[str] = []
72
- for tag, enabled_tag in [
73
- ("HTTP_SERVER", "ENABLED"),
74
- ("HTTPS_SERVER", "HTTPSENABLED"),
75
- ("DNS_SERVER", "ENABLED"),
76
- ("DHCP_SERVER", "ENABLED"),
77
- ("TFTP_SERVER", "ENABLED"),
78
- ("FTP_SERVER", "ENABLED"),
79
- ("NTP_SERVER", "ENABLED"),
80
- ("SYSLOG_SERVER", "ENABLED"),
81
- ("ACS_SERVER", "ENABLED"),
82
- ]:
83
- node = engine.find(tag)
84
- if node is not None and node.findtext(enabled_tag, default="0") in {"1", "true", "True"}:
85
- enabled.append(tag.lower())
86
- email_server = engine.find("EMAIL_SERVER")
87
- if email_server is not None and any(
88
- email_server.findtext(tag, default="0") in {"1", "true", "True"}
89
- for tag in ["SMTP_ENABLED", "POP3_ENABLED"]
90
- ):
91
- enabled.append("email_server")
92
- if enabled:
93
- result[name] = enabled
94
- return result
95
-
96
-
97
- def inventory_service_details(root: ET.Element) -> dict[str, dict[str, object]]:
98
- result: dict[str, dict[str, object]] = {}
99
- for device in root.findall(".//DEVICES/DEVICE"):
100
- name = device.findtext("./ENGINE/NAME", default="")
101
- engine = device.find("./ENGINE")
102
- if engine is None:
103
- continue
104
- details: dict[str, object] = {}
105
- email_server = engine.find("EMAIL_SERVER")
106
- if email_server is not None:
107
- domain = email_server.findtext("SMTP_DOMAIN", default="")
108
- if domain:
109
- details["email_domain"] = domain
110
- user_count = email_server.findtext("NO_OF_USERS", default="")
111
- if user_count:
112
- details["email_user_count"] = user_count
113
- acs_server = engine.find("ACS_SERVER")
114
- if acs_server is not None:
115
- auth_port = acs_server.findtext("./RADIUS_SETTINGS/AUTH_PORT", default="")
116
- if auth_port:
117
- details["aaa_auth_port"] = auth_port
118
- if details:
119
- result[name] = details
120
- return result
121
-
122
-
123
- def inventory_wireless(root: ET.Element) -> dict[str, dict[str, str]]:
124
- result: dict[str, dict[str, str]] = {}
125
- for device in root.findall(".//DEVICES/DEVICE"):
126
- name = device.findtext("./ENGINE/NAME", default="")
127
- engine = device.find("./ENGINE")
128
- if engine is None:
129
- continue
130
- if engine.find("WIRELESS_SERVER") is not None:
131
- common = engine.find("./WIRELESS_SERVER/WIRELESS_COMMON")
132
- result[name] = {
133
- "mode": "ap",
134
- "ssid": common.findtext("SSID", default="") if common is not None else "",
135
- }
136
- if engine.find("./CAPWAP_AC/WLANS/WLAN_CONFIG") is not None:
137
- wlan = engine.find("./CAPWAP_AC/WLANS/WLAN_CONFIG")
138
- result[name] = {
139
- "mode": "controller",
140
- "ssid": wlan.findtext("SSID", default="") if wlan is not None else "",
141
- }
142
- if engine.find("WIRELESS_CLIENT") is not None:
143
- common = engine.find("./WIRELESS_CLIENT/WIRELESS_COMMON")
144
- result[name] = {
145
- "mode": "client",
146
- "ssid": common.findtext("SSID", default="") if common is not None else "",
147
- }
148
- return result
149
-
150
-
151
- def inventory_iot(root: ET.Element) -> dict[str, dict[str, object]]:
152
- result: dict[str, dict[str, object]] = {}
153
- for device in root.findall(".//DEVICES/DEVICE"):
154
- name = device.findtext("./ENGINE/NAME", default="")
155
- engine = device.find("./ENGINE")
156
- if engine is None:
157
- continue
158
- raw_type = engine.findtext("TYPE", default="")
159
- tags = {child.tag for child in list(engine)}
160
- iot_role = None
161
- if raw_type in {"HomeGateway", "WirelessRouterNewGeneration"}:
162
- iot_role = "gateway"
163
- elif raw_type in {"IoT", "MCUComponent", "Board", "Sensor", "Actuator"}:
164
- iot_role = "thing"
165
- elif {"IOE_USER_MANAGER", "IOX_SEVICE", "IOX_VM_MANAGER"} & tags:
166
- iot_role = "server"
167
- if iot_role is None:
168
- continue
169
- result[name] = {
170
- "role": iot_role,
171
- "type": raw_type,
172
- "wireless_ssid": engine.findtext("./WIRELESS_CLIENT/WIRELESS_COMMON/SSID", default=""),
173
- "http_enabled": engine.findtext("./HTTP_SERVER/ENABLED", default="0") in {"1", "true", "True"},
174
- "client_mode": engine.findtext("./IOE_CLIENT/CLIENT_MODE", default=""),
175
- "server_address": engine.findtext("./IOE_CLIENT/SERVER_ADDRESS", default=""),
176
- "username": engine.findtext("./IOE_CLIENT/USERNAME", default=""),
177
- }
178
- if iot_role == "server":
179
- rules: list[dict[str, object]] = []
180
- for node in engine.findall("./IOE_USER_MANAGER/USERS/USER/IOE_RULES/IOE_RULE/JSON"):
181
- try:
182
- payload = json.loads(node.text or "{}")
183
- except json.JSONDecodeError:
184
- continue
185
- if payload.get("name"):
186
- rules.append({"name": str(payload["name"]), "enabled": bool(payload.get("enabled", False))})
187
- if rules:
188
- result[name]["rules"] = rules
189
- return result
190
-
191
-
192
- def inventory_vlans(root: ET.Element) -> dict[str, list[dict[str, str]]]:
193
- result: dict[str, list[dict[str, str]]] = {}
194
- for device in root.findall(".//DEVICES/DEVICE"):
195
- name = device.findtext("./ENGINE/NAME", default="")
196
- vlans: list[dict[str, str]] = []
197
- for vlan in device.findall(".//VLANS/VLAN"):
198
- vlan_id = vlan.get("number") or vlan.findtext("ID", default="")
199
- vlan_name = vlan.get("name") or vlan.findtext("NAME", default="")
200
- if vlan_id:
201
- vlans.append({"id": vlan_id, "name": vlan_name})
202
- if vlans:
203
- result[name] = vlans
204
- continue
205
- running = "\n".join(line.text or "" for line in device.findall(".//LINE"))
206
- inferred: list[dict[str, str]] = []
207
- for match in re.finditer(r"(?mi)^vlan\s+(\d+)\s*$", running):
208
- vlan_id = match.group(1)
209
- name_match = re.search(rf"(?mi)^vlan\s+{re.escape(vlan_id)}\s*$\n^\s*name\s+(.+?)\s*$", running)
210
- inferred.append({"id": vlan_id, "name": name_match.group(1).strip() if name_match else ""})
211
- if inferred:
212
- unique: dict[str, dict[str, str]] = {entry["id"]: entry for entry in inferred}
213
- result[name] = list(unique.values())
214
- return result
215
-
216
-
217
- def inventory_dhcp_pools(root: ET.Element) -> dict[str, list[str]]:
218
- pools: dict[str, list[str]] = {}
219
- for device in root.findall(".//DEVICES/DEVICE"):
220
- name = device.findtext("./ENGINE/NAME", default="")
221
- device_pools: list[str] = []
222
- for pool in device.findall(".//DHCP_SERVER/POOLS/POOL/NAME"):
223
- if pool.text:
224
- device_pools.append(pool.text)
225
- running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
226
- for match in re.findall(r"ip dhcp pool\s+([A-Za-z0-9_-]+)", running):
227
- if match not in device_pools:
228
- device_pools.append(match)
229
- if device_pools:
230
- pools[name] = device_pools
231
- return pools
232
-
233
-
234
- def inventory_acl_names(root: ET.Element) -> dict[str, list[str]]:
235
- result: dict[str, list[str]] = {}
236
- for device in root.findall(".//DEVICES/DEVICE"):
237
- name = device.findtext("./ENGINE/NAME", default="")
238
- running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
239
- matches = re.findall(r"ip access-list\s+(?:standard|extended)\s+([A-Za-z0-9_-]+)", running)
240
- if matches:
241
- result[name] = sorted(dict.fromkeys(matches))
242
- return result
243
-
244
-
245
- def inventory_management(root: ET.Element) -> dict[str, dict[str, object]]:
246
- result: dict[str, dict[str, object]] = {}
247
- for device in root.findall(".//DEVICES/DEVICE"):
248
- name = device.findtext("./ENGINE/NAME", default="")
249
- running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
250
- if not running:
251
- continue
252
- management_vlans = sorted(
253
- {
254
- match.group(1)
255
- for match in re.finditer(r"(?mi)^interface\s+Vlan(\d+)\s*$", running)
256
- },
257
- key=int,
258
- )
259
- usernames = sorted(
260
- {
261
- match.group(1)
262
- for match in re.finditer(r"(?mi)^username\s+(\S+)\s+(?:password|secret)\s+\S+\s*$", running)
263
- }
264
- )
265
- default_gateway_match = re.search(r"(?mi)^ip default-gateway\s+(\S+)\s*$", running)
266
- telnet_enabled = bool(re.search(r"(?mi)^\s*transport input .*telnet.*$", running))
267
- enable_secret_present = bool(re.search(r"(?mi)^enable secret\s+\S+\s*$", running))
268
- if not any([management_vlans, usernames, default_gateway_match, telnet_enabled, enable_secret_present]):
269
- continue
270
- result[name] = {
271
- "management_vlans": management_vlans,
272
- "default_gateway": default_gateway_match.group(1) if default_gateway_match else "",
273
- "telnet_enabled": telnet_enabled,
274
- "usernames": usernames,
275
- "enable_secret_present": enable_secret_present,
276
- }
277
- return result
278
-
279
-
280
- def inventory_routing(root: ET.Element) -> dict[str, dict[str, object]]:
281
- result: dict[str, dict[str, object]] = {}
282
- for device in root.findall(".//DEVICES/DEVICE"):
283
- name = device.findtext("./ENGINE/NAME", default="")
284
- running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
285
- if not running:
286
- continue
287
- capabilities: set[str] = set()
288
- if re.search(r"(?mi)^\s*ipv6\s+unicast-routing\s*$", running) or re.search(r"(?mi)^\s*ipv6\s+(?:enable|address)\b", running):
289
- capabilities.add("ipv6_slaac")
290
- 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):
291
- capabilities.add("dhcpv6_stateful")
292
- if re.search(r"(?mi)^\s*ipv6\s+ospf\b", running) or re.search(r"(?mi)^\s*ipv6\s+router\s+ospf\b", running):
293
- capabilities.add("ospfv3")
294
- if re.search(r"(?mi)^\s*ipv6\s+eigrp\b", running) or re.search(r"(?mi)^\s*ipv6\s+router\s+eigrp\b", running):
295
- capabilities.add("eigrp_ipv6")
296
- if re.search(r"(?mi)^\s*ipv6\s+rip\b", running):
297
- capabilities.add("ripng")
298
- if re.search(r"(?mi)^\s*standby\s+\d+\s+ipv6\b", running):
299
- capabilities.add("hsrp")
300
- if capabilities:
301
- result[name] = {"capabilities": sorted(capabilities)}
302
- return result
303
-
304
-
305
- def inventory_l2_security_monitoring(root: ET.Element) -> dict[str, dict[str, object]]:
306
- result: dict[str, dict[str, object]] = {}
307
- for device in root.findall(".//DEVICES/DEVICE"):
308
- name = device.findtext("./ENGINE/NAME", default="")
309
- running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
310
- if not running:
311
- continue
312
- capabilities: set[str] = set()
313
- if re.search(r"(?mi)^\s*ip\s+dhcp\s+snooping\b", running):
314
- capabilities.add("dhcp_snooping")
315
- if re.search(r"(?mi)^\s*ip\s+arp\s+inspection\b", running):
316
- capabilities.add("dai")
317
- if re.search(r"(?mi)^\s*(?:aaa\s+new-model|radius-server\s+host|authentication\s+port-control|dot1x\b)", running):
318
- capabilities.add("dot1x")
319
- if re.search(r"(?mi)^\s*lldp\s+run\b", running):
320
- capabilities.add("lldp")
321
- if re.search(r"(?mi)^\s*rep\s+segment\b", running):
322
- capabilities.add("rep")
323
- if re.search(r"(?mi)^\s*snmp-server\b", running):
324
- capabilities.add("snmp")
325
- 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):
326
- capabilities.add("netflow")
327
- if re.search(r"(?mi)^\s*monitor\s+session\b", running):
328
- capabilities.add("span")
329
- if re.search(r"(?mi)^\s*(?:mls\s+qos|auto\s+qos|class-map|policy-map|service-policy)\b", running):
330
- capabilities.add("qos")
331
- if re.search(r"(?mi)^\s*switchport\s+port-security\b", running):
332
- capabilities.add("port_security")
333
- if capabilities:
334
- result[name] = {"capabilities": sorted(capabilities)}
335
- return result
336
-
337
-
338
- def inventory_topology_summary(root: ET.Element) -> dict[str, object]:
339
- devices = inventory_devices(root)
340
- counts: dict[str, int] = {}
341
- for device in devices:
342
- counts[device["type"]] = counts.get(device["type"], 0) + 1
343
- return {
344
- "device_counts": counts,
345
- "link_count": len(inventory_links(root)),
346
- "has_wireless": bool(inventory_wireless(root)),
347
- "has_services": bool(inventory_services(root)),
348
- "has_vlans": bool(inventory_vlans(root)),
349
- }
350
-
351
-
352
- def inventory_root(root: ET.Element) -> dict[str, object]:
353
- return {
354
- "devices": inventory_devices(root),
355
- "links": inventory_links(root),
356
- "services": inventory_services(root),
357
- "service_details": inventory_service_details(root),
358
- "wireless": inventory_wireless(root),
359
- "iot": inventory_iot(root),
360
- "vlans": inventory_vlans(root),
361
- "dhcp_pools": inventory_dhcp_pools(root),
362
- "acl_names": inventory_acl_names(root),
363
- "management": inventory_management(root),
364
- "routing": inventory_routing(root),
365
- "l2_security_monitoring": inventory_l2_security_monitoring(root),
366
- "topology_summary": inventory_topology_summary(root),
367
- }
368
-
369
-
370
- def _find_device(root: ET.Element, name: str) -> ET.Element | None:
371
- for device in root.findall(".//DEVICES/DEVICE"):
372
- if device.findtext("./ENGINE/NAME", default="") == name:
373
- return device
374
- return None
375
-
376
-
377
- def _ensure_text(parent: ET.Element, tag: str, value: str) -> ET.Element:
378
- node = parent.find(tag)
379
- if node is None:
380
- node = ET.SubElement(parent, tag)
381
- node.text = value
382
- return node
383
-
384
-
385
- def _replace_lines(target: ET.Element, lines: list[str]) -> None:
386
- target.clear()
387
- for line in lines:
388
- node = ET.SubElement(target, "LINE")
389
- node.text = line
390
-
391
-
392
- def _append_unique_config_lines(parent: ET.Element | None, lines: list[str]) -> None:
393
- if parent is None:
394
- return
395
- existing = [line.text or "" for line in parent.findall("./LINE")]
396
- merged = list(existing)
397
- for line in lines:
398
- if line not in merged:
399
- merged.append(line)
400
- _replace_lines(parent, merged)
401
-
402
-
403
- def _append_config_block(parent: ET.Element | None, header: str, body: list[str]) -> None:
404
- if parent is None:
405
- return
406
- existing = [line.text or "" for line in parent.findall("./LINE")]
407
- block = [header, *body]
408
- for index in range(0, max(len(existing) - len(block) + 1, 0)):
409
- if existing[index : index + len(block)] == block:
410
- return
411
- _replace_lines(parent, [*existing, *block])
412
-
413
-
414
- def _device_index_map(root: ET.Element) -> dict[str, int]:
415
- return {device.findtext("./ENGINE/NAME", default=""): index for index, device in enumerate(root.findall(".//DEVICES/DEVICE"))}
416
-
417
-
418
- def _device_refs(root: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
419
- index_map = _device_index_map(root)
420
- index_refs = {name: str(index) for name, index in index_map.items()}
421
- save_refs = {}
422
- for device in root.findall(".//DEVICES/DEVICE"):
423
- name = device.findtext("./ENGINE/NAME", default="")
424
- save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
425
- if name and save_ref:
426
- save_refs[name] = save_ref
427
- return index_refs, save_refs
428
-
429
-
430
- def _link_port_mem_map(root: ET.Element) -> dict[tuple[str, str], str]:
431
- mapping: dict[tuple[str, str], str] = {}
432
- for link in root.findall(".//LINKS/LINK"):
433
- cable = link.find("./CABLE")
434
- if cable is None:
435
- continue
436
- ports = cable.findall("PORT")
437
- if len(ports) < 2:
438
- continue
439
- from_ref = cable.findtext("FROM", default="")
440
- to_ref = cable.findtext("TO", default="")
441
- from_port = ports[0].text or ""
442
- to_port = ports[1].text or ""
443
- from_mem = cable.findtext("FROM_PORT_MEM_ADDR", default="").strip()
444
- to_mem = cable.findtext("TO_PORT_MEM_ADDR", default="").strip()
445
- if from_ref and from_port and from_mem:
446
- mapping[(from_ref, from_port)] = from_mem
447
- if to_ref and to_port and to_mem:
448
- mapping[(to_ref, to_port)] = to_mem
449
- return mapping
450
-
451
-
452
- def _find_link_by_devices(root: ET.Element, left_name: str, right_name: str) -> ET.Element | None:
453
- index_refs, save_refs = _device_refs(root)
454
- left_candidates = {index_refs.get(left_name, ""), save_refs.get(left_name, "")}
455
- right_candidates = {index_refs.get(right_name, ""), save_refs.get(right_name, "")}
456
- for link in root.findall(".//LINKS/LINK"):
457
- cable = link.find("./CABLE")
458
- if cable is None:
459
- continue
460
- from_idx = cable.findtext("FROM", default="")
461
- to_idx = cable.findtext("TO", default="")
462
- if from_idx in left_candidates and to_idx in right_candidates:
463
- return link
464
- if from_idx in right_candidates and to_idx in left_candidates:
465
- return link
466
- return None
467
-
468
-
469
- def _first_link_prototype(root: ET.Element) -> ET.Element | None:
470
- for link in root.findall(".//LINKS/LINK"):
471
- cable = link.find("./CABLE")
472
- if cable is None:
473
- continue
474
- 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:
475
- return link
476
- return root.find(".//LINKS/LINK")
477
-
478
-
479
- def _remove_links_for_device(root: ET.Element, device_name: str) -> None:
480
- index_refs, save_refs = _device_refs(root)
481
- refs = {index_refs.get(device_name, ""), save_refs.get(device_name, "")}
482
- links_parent = root.find(".//LINKS")
483
- if links_parent is None:
484
- return
485
- for link in list(links_parent.findall("./LINK")):
486
- cable = link.find("./CABLE")
487
- if cable is None:
488
- continue
489
- if cable.findtext("FROM", default="") in refs or cable.findtext("TO", default="") in refs:
490
- links_parent.remove(link)
491
-
492
-
493
- def _find_parent_of_node(root: ET.Element, target: ET.Element) -> ET.Element | None:
494
- for parent in root.iter():
495
- for child in list(parent):
496
- if child is target:
497
- return parent
498
- return None
499
-
500
-
501
- def _remove_physical_leaf(root: ET.Element, device: ET.Element) -> None:
502
- physical_path = device.findtext("./WORKSPACE/PHYSICAL", default="")
503
- if not physical_path:
504
- return
505
- tokens = [token.strip() for token in physical_path.split(",") if token.strip()]
506
- if not tokens:
507
- return
508
- leaf_token = tokens[-1]
509
- for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
510
- uuid = node.findtext("UUID_STR", default="").strip()
511
- if uuid == leaf_token:
512
- parent = _find_parent_of_node(root, node)
513
- if parent is not None:
514
- parent.remove(node)
515
- return
516
-
517
-
518
- def _node_contains_tokens(node: ET.Element, tokens: list[str]) -> bool:
519
- if any(token and token in ((node.text or "").strip()) for token in tokens):
520
- return True
521
- for value in node.attrib.values():
522
- if any(token and token in value for token in tokens):
523
- return True
524
- for child in list(node):
525
- if _node_contains_tokens(child, tokens):
526
- return True
527
- return False
528
-
529
-
530
- def _remove_runtime_references(root: ET.Element, device: ET.Element) -> None:
531
- tokens = [
532
- device.findtext("./ENGINE/SAVE_REF_ID", default="").strip(),
533
- device.findtext("./ENGINE/NAME", default="").strip(),
534
- device.findtext("./ENGINE/ORIGINAL_DEVICE_UUID", default="").strip(),
535
- ]
536
- physical_path = device.findtext("./WORKSPACE/PHYSICAL", default="").strip()
537
- if physical_path:
538
- tokens.append(physical_path.split(",")[-1].strip())
539
- tokens = [token for token in tokens if token]
540
- if not tokens:
541
- return
542
- protected_tags = {"SCENARIOSET", "SCENARIO", "COMMAND_LOGS", "CEPS"}
543
- for section_path in ["./SCENARIOSET", "./COMMAND_LOGS", "./CEPS"]:
544
- section = root.find(section_path)
545
- if section is None:
546
- continue
547
- stack = [section]
548
- while stack:
549
- parent = stack.pop()
550
- for child in list(parent):
551
- if child.tag in protected_tags:
552
- stack.append(child)
553
- continue
554
- if _node_contains_tokens(child, tokens):
555
- parent.remove(child)
556
- continue
557
- stack.append(child)
558
-
559
-
560
- def _prune_device(root: ET.Element, device_name: str) -> None:
561
- device = _find_device(root, device_name)
562
- if device is None:
563
- return
564
- _remove_links_for_device(root, device_name)
565
- _remove_physical_leaf(root, device)
566
- _remove_runtime_references(root, device)
567
- parent = _find_parent_of_node(root, device)
568
- if parent is not None:
569
- parent.remove(device)
570
-
571
-
572
- def _remove_link(root: ET.Element, left_name: str, right_name: str) -> None:
573
- links_parent = root.find(".//LINKS")
574
- if links_parent is None:
575
- return
576
- link = _find_link_by_devices(root, left_name, right_name)
577
- if link is not None:
578
- links_parent.remove(link)
579
-
580
-
581
- def _ensure_link(
582
- root: ET.Element,
583
- left_name: str,
584
- left_port: str,
585
- right_name: str,
586
- right_port: str,
587
- media: str,
588
- port_mem_map: dict[tuple[str, str], str] | None = None,
589
- ) -> None:
590
- existing = _find_link_by_devices(root, left_name, right_name)
591
- devices = {device.findtext("./ENGINE/NAME", default=""): device for device in root.findall(".//DEVICES/DEVICE")}
592
- index_refs, save_refs = _device_refs(root)
593
- left_device = devices.get(left_name)
594
- right_device = devices.get(right_name)
595
- if left_device is None or right_device is None:
596
- return
597
- link = existing
598
- if link is None:
599
- left_type = _device_type(left_device)
600
- right_type = _device_type(right_device)
601
- prototype = None
602
- for candidate in root.findall(".//LINKS/LINK"):
603
- cable = candidate.find("./CABLE")
604
- if cable is None:
605
- continue
606
- from_ref = cable.findtext("FROM", default="")
607
- to_ref = cable.findtext("TO", default="")
608
- 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), ""))
609
- 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), ""))
610
- if not from_name or not to_name:
611
- continue
612
- from_type = _device_type(devices[from_name])
613
- to_type = _device_type(devices[to_name])
614
- if {from_type, to_type} == {left_type, right_type}:
615
- prototype = candidate
616
- break
617
- if prototype is None:
618
- prototype = _first_link_prototype(root)
619
- if prototype is None:
620
- prototype_root = load_sample_root(resolve_sample_path(FTP_SAMPLE))
621
- prototype = prototype_root.find(".//LINKS/LINK")
622
- link = copy.deepcopy(prototype)
623
- if link is None:
624
- return
625
- links_parent = root.find(".//LINKS")
626
- if links_parent is None:
627
- return
628
- links_parent.append(link)
629
- cable = link.find("./CABLE")
630
- if cable is None:
631
- return
632
- from_ref = save_refs.get(left_name, index_refs[left_name])
633
- to_ref = save_refs.get(right_name, index_refs[right_name])
634
- _ensure_text(cable, "FROM", from_ref)
635
- _ensure_text(cable, "TO", to_ref)
636
- ports = cable.findall("PORT")
637
- if len(ports) < 2:
638
- while len(ports) < 2:
639
- ports.append(ET.SubElement(cable, "PORT"))
640
- ports[0].text = left_port
641
- ports[1].text = right_port
642
- resolved_port_mem_map = port_mem_map or {}
643
- for node_name, value in [
644
- ("FROM_DEVICE_MEM_ADDR", left_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
645
- ("TO_DEVICE_MEM_ADDR", right_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
646
- (
647
- "FROM_PORT_MEM_ADDR",
648
- _port_address_for_name(left_device, left_port)
649
- or resolved_port_mem_map.get((from_ref, left_port), ""),
650
- ),
651
- (
652
- "TO_PORT_MEM_ADDR",
653
- _port_address_for_name(right_device, right_port)
654
- or resolved_port_mem_map.get((to_ref, right_port), ""),
655
- ),
656
- ]:
657
- if value:
658
- _ensure_text(cable, node_name, value)
659
- _ensure_text(cable, "FUNCTIONAL", cable.findtext("FUNCTIONAL", default="true") or "true")
660
- _ensure_text(cable, "GEO_VIEW_COLOR", cable.findtext("GEO_VIEW_COLOR", default="#6ba72e") or "#6ba72e")
661
- _ensure_text(
662
- cable,
663
- "IS_MANAGED_IN_RACK_VIEW",
664
- cable.findtext("IS_MANAGED_IN_RACK_VIEW", default="false") or "false",
665
- )
666
- apply_cable_type(cable, media)
667
-
668
-
669
- def _prefix_to_mask(prefix: int) -> str:
670
- bits = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
671
- return ".".join(str((bits >> shift) & 0xFF) for shift in (24, 16, 8, 0))
672
-
673
-
674
- def _set_device_name(root: ET.Element, device: ET.Element, new_name: str) -> None:
675
- old_name = device.findtext("./ENGINE/NAME", default="")
676
- node = device.find("./ENGINE/NAME")
677
- if node is not None:
678
- node.text = new_name
679
- sys_name = device.find("./ENGINE/SYS_NAME")
680
- if sys_name is not None and (sys_name.text or "").strip() == old_name:
681
- sys_name.text = new_name
682
-
683
- for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
684
- text = line.text or ""
685
- if old_name and text == f"hostname {old_name}":
686
- line.text = f"hostname {new_name}"
687
- for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
688
- text = line.text or ""
689
- if old_name and text == f"hostname {old_name}":
690
- line.text = f"hostname {new_name}"
691
- for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
692
- text = line.text or ""
693
- if old_name and text == f"hostname {old_name}":
694
- line.text = f"hostname {new_name}"
695
-
696
- physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
697
- leaf_uuid = physical.split(",")[-1].strip() if physical else ""
698
- if leaf_uuid:
699
- for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
700
- uuid = node.findtext("UUID_STR", default="").strip()
701
- if uuid == leaf_uuid:
702
- leaf_name = node.find("./NAME")
703
- if leaf_name is not None:
704
- leaf_name.text = new_name
705
- break
706
-
707
-
708
- def _set_device_position(device: ET.Element, x: int, y: int) -> None:
709
- workspace = device.find("./WORKSPACE/LOGICAL")
710
- if workspace is None:
711
- return
712
- _ensure_text(workspace, "X", str(x))
713
- _ensure_text(workspace, "Y", str(y))
714
-
715
-
716
- def _config_targets(device: ET.Element) -> list[ET.Element]:
717
- targets: list[ET.Element] = []
718
- for path in ["./ENGINE/RUNNINGCONFIG", "./ENGINE/STARTUPCONFIG"]:
719
- node = device.find(path)
720
- if node is not None:
721
- targets.append(node)
722
- for node in device.findall(".//FILE_CONTENT/CONFIG"):
723
- targets.append(node)
724
- return targets
725
-
726
-
727
- def _ensure_vlan_state(device: ET.Element, vlan_id: int, vlan_name: str) -> None:
728
- vlans = device.find(".//VLANS")
729
- if vlans is None:
730
- engine = device.find("./ENGINE")
731
- if engine is None:
732
- return
733
- vlans = ET.SubElement(engine, "VLANS")
734
- existing = next((node for node in vlans.findall("./VLAN") if node.get("number") == str(vlan_id)), None)
735
- if existing is None:
736
- existing = ET.SubElement(vlans, "VLAN")
737
- existing.set("number", str(vlan_id))
738
- existing.set("rspan", "0")
739
- existing.set("name", vlan_name)
740
-
741
-
742
- def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
743
- if operation["op"] == "set_vlan":
744
- lines = [f"vlan {operation['vlan']}", f" name {operation['name']}"]
745
- for target in _config_targets(device):
746
- _append_unique_config_lines(target, lines)
747
- _ensure_vlan_state(device, int(operation["vlan"]), str(operation["name"]))
748
- return
749
- elif operation["op"] == "set_access_port":
750
- for target in _config_targets(device):
751
- _append_config_block(
752
- target,
753
- f"interface {operation['port']}",
754
- [" switchport mode access", f" switchport access vlan {operation['vlan']}"],
755
- )
756
- return
757
- elif operation["op"] == "set_trunk_port":
758
- allowed = ",".join(str(vlan) for vlan in operation["allowed"])
759
- body = [" switchport mode trunk", f" switchport trunk allowed vlan {allowed}"]
760
- if operation.get("native"):
761
- body.append(f" switchport trunk native vlan {operation['native']}")
762
- for target in _config_targets(device):
763
- _append_config_block(target, f"interface {operation['port']}", body)
764
- return
765
- elif operation["op"] == "set_dhcp_snooping":
766
- for target in _config_targets(device):
767
- _append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}"])
768
- if operation.get("trust_port"):
769
- _append_config_block(target, f"interface {operation['trust_port']}", [" ip dhcp snooping trust"])
770
- return
771
- elif operation["op"] == "set_dai":
772
- for target in _config_targets(device):
773
- _append_unique_config_lines(target, ["ip dhcp snooping", f"ip dhcp snooping vlan {operation['vlan']}", f"ip arp inspection vlan {operation['vlan']}"])
774
- if operation.get("trust_port"):
775
- _append_config_block(target, f"interface {operation['trust_port']}", [" ip arp inspection trust", " ip dhcp snooping trust"])
776
- return
777
- elif operation["op"] == "set_port_security":
778
- body = [" switchport mode access", " switchport port-security"]
779
- if operation.get("maximum"):
780
- body.append(f" switchport port-security maximum {operation['maximum']}")
781
- if operation.get("violation"):
782
- body.append(f" switchport port-security violation {operation['violation']}")
783
- for target in _config_targets(device):
784
- _append_config_block(target, f"interface {operation['port']}", body)
785
- return
786
- elif operation["op"] == "set_lldp":
787
- for target in _config_targets(device):
788
- _append_unique_config_lines(target, ["lldp run"])
789
- return
790
- elif operation["op"] == "set_rep":
791
- for target in _config_targets(device):
792
- _append_config_block(target, f"interface {operation['interface']}", [f" rep segment {operation['segment']}"])
793
- return
794
- elif operation["op"] == "set_span":
795
- for target in _config_targets(device):
796
- _append_unique_config_lines(
797
- target,
798
- [
799
- f"monitor session {operation['session']} source interface {operation['source']}",
800
- f"monitor session {operation['session']} destination interface {operation['destination']}",
801
- ],
802
- )
803
- return
804
- else:
805
- return
806
-
807
-
808
- def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
809
- if operation["op"] == "set_subinterface":
810
- for target in _config_targets(device):
811
- _append_config_block(
812
- target,
813
- f"interface {operation['subinterface']}",
814
- [
815
- f" encapsulation dot1Q {operation['vlan']}",
816
- f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}",
817
- " no shutdown",
818
- ],
819
- )
820
- return
821
- elif operation["op"] == "set_router_dhcp_pool":
822
- lines = [
823
- f"ip dhcp pool {operation['name']}",
824
- f" network {operation['network']} {_prefix_to_mask(int(operation['prefix']))}",
825
- f" default-router {operation['gateway']}",
826
- ]
827
- if operation.get("dns"):
828
- lines.append(f" dns-server {operation['dns']}")
829
- elif operation["op"] == "set_acl":
830
- lines = [f"ip access-list {operation['acl_kind']} {operation['acl_name']}"]
831
- elif operation["op"] == "add_acl_rule":
832
- acl_kind = str(operation.get("acl_kind") or "standard")
833
- for target in _config_targets(device):
834
- _append_config_block(
835
- target,
836
- f"ip access-list {acl_kind} {operation['acl_name']}",
837
- [f" {operation['action']} {operation['source']} {operation['destination']}"] if operation.get("destination") else [f" {operation['action']} {operation['source']}"],
838
- )
839
- return
840
- elif operation["op"] == "apply_acl":
841
- for target in _config_targets(device):
842
- _append_config_block(target, f"interface {operation['interface']}", [f" ip access-group {operation['acl_name']} {operation['direction']}"])
843
- return
844
- elif operation["op"] == "enable_ipv6_unicast_routing":
845
- for target in _config_targets(device):
846
- _append_unique_config_lines(target, ["ipv6 unicast-routing"])
847
- return
848
- elif operation["op"] == "set_ipv6_address":
849
- for target in _config_targets(device):
850
- _append_unique_config_lines(target, ["ipv6 unicast-routing"])
851
- _append_config_block(
852
- target,
853
- f"interface {operation['interface']}",
854
- [f" ipv6 address {operation['address']}/{operation['prefix']}", " no shutdown"],
855
- )
856
- return
857
- elif operation["op"] == "set_ipv6_slaac":
858
- body = [" ipv6 enable", " no shutdown"]
859
- if operation.get("prefix") and operation.get("prefix_len"):
860
- body.insert(1, f" ipv6 nd prefix {operation['prefix']}/{operation['prefix_len']}")
861
- for target in _config_targets(device):
862
- _append_unique_config_lines(target, ["ipv6 unicast-routing"])
863
- _append_config_block(target, f"interface {operation['interface']}", body)
864
- return
865
- elif operation["op"] == "set_dhcpv6_pool":
866
- pool_body = [f" address prefix {operation['prefix']}/{operation['prefix_len']}"]
867
- if operation.get("dns"):
868
- pool_body.append(f" dns-server {operation['dns']}")
869
- if operation.get("domain"):
870
- pool_body.append(f" domain-name {operation['domain']}")
871
- interface_body = [f" ipv6 dhcp server {operation['name']}", " ipv6 nd managed-config-flag", " no shutdown"]
872
- for target in _config_targets(device):
873
- _append_unique_config_lines(target, ["ipv6 unicast-routing"])
874
- _append_config_block(target, f"ipv6 dhcp pool {operation['name']}", pool_body)
875
- _append_config_block(target, f"interface {operation['interface']}", interface_body)
876
- return
877
- elif operation["op"] == "set_ospfv3_interface":
878
- process_id = operation["process_id"]
879
- for target in _config_targets(device):
880
- _append_unique_config_lines(target, ["ipv6 unicast-routing"])
881
- _append_config_block(target, f"interface {operation['interface']}", [f" ipv6 ospf {process_id} area {operation['area']}", " no shutdown"])
882
- _append_config_block(target, f"ipv6 router ospf {process_id}", [])
883
- return
884
- elif operation["op"] == "set_eigrp_ipv6_interface":
885
- asn = operation["asn"]
886
- for target in _config_targets(device):
887
- _append_unique_config_lines(target, ["ipv6 unicast-routing", "no ipv6 cef"])
888
- _append_config_block(target, f"interface {operation['interface']}", [f" ipv6 eigrp {asn}", " no shutdown"])
889
- _append_config_block(target, f"ipv6 router eigrp {asn}", [" no shutdown"])
890
- return
891
- elif operation["op"] == "set_ripng_interface":
892
- for target in _config_targets(device):
893
- _append_unique_config_lines(target, ["ipv6 unicast-routing"])
894
- _append_config_block(target, f"interface {operation['interface']}", [f" ipv6 rip {operation['process_name']} enable", " no shutdown"])
895
- return
896
- elif operation["op"] == "set_hsrp_ipv6":
897
- body = [" standby version 2", f" standby {operation['group']} ipv6 {operation['virtual_ipv6']}"]
898
- if operation.get("priority"):
899
- body.append(f" standby {operation['group']} priority {operation['priority']}")
900
- body.append(f" standby {operation['group']} preempt")
901
- for target in _config_targets(device):
902
- _append_unique_config_lines(target, ["ipv6 unicast-routing"])
903
- _append_config_block(target, f"interface {operation['interface']}", body)
904
- return
905
- elif operation["op"] == "set_snmp_community":
906
- for target in _config_targets(device):
907
- _append_unique_config_lines(target, [f"snmp-server community {operation['community']} {operation['mode']}"])
908
- return
909
- elif operation["op"] == "set_netflow":
910
- global_lines = [
911
- f"ip flow-export destination {operation['destination']} {operation['port']}",
912
- f"ip flow-export version {operation['version']}",
913
- ]
914
- for target in _config_targets(device):
915
- _append_unique_config_lines(target, global_lines)
916
- if operation.get("interface") and operation.get("direction"):
917
- _append_config_block(target, f"interface {operation['interface']}", [f" ip flow {operation['direction']}"])
918
- return
919
- else:
920
- return
921
- for target in _config_targets(device):
922
- _append_unique_config_lines(target, lines)
923
-
924
-
925
- def _apply_management_op(device: ET.Element, operation: dict[str, object]) -> None:
926
- if operation["op"] == "set_management_vlan":
927
- for target in _config_targets(device):
928
- _append_config_block(
929
- target,
930
- f"interface Vlan{operation['vlan']}",
931
- [f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}", " no shutdown"],
932
- )
933
- _append_unique_config_lines(target, [f"ip default-gateway {operation['gateway']}"])
934
- return
935
- elif operation["op"] == "enable_telnet":
936
- for target in _config_targets(device):
937
- _append_unique_config_lines(target, [f"username {operation['username']} secret {operation['password']}", f"enable secret {operation['password']}"])
938
- _append_config_block(target, "line vty 0 4", [" login local", " transport input telnet"])
939
- return
940
- else:
941
- return
942
-
943
-
944
- def _set_enabled_service(engine: ET.Element, service_name: str) -> None:
945
- mapping = {
946
- "dns": ("DNS_SERVER", "ENABLED"),
947
- "http": ("HTTP_SERVER", "ENABLED"),
948
- "https": ("HTTPS_SERVER", "HTTPSENABLED"),
949
- "ftp": ("FTP_SERVER", "ENABLED"),
950
- "tftp": ("TFTP_SERVER", "ENABLED"),
951
- "ntp": ("NTP_SERVER", "ENABLED"),
952
- "syslog": ("SYSLOG_SERVER", "ENABLED"),
953
- "aaa": ("ACS_SERVER", "ENABLED"),
954
- }
955
- if service_name == "email":
956
- email = engine.find("EMAIL_SERVER")
957
- if email is None:
958
- prototype = _server_engine_prototype_child("EMAIL_SERVER")
959
- email = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "EMAIL_SERVER")
960
- if email not in list(engine):
961
- engine.append(email)
962
- _ensure_text(email, "SMTP_ENABLED", "1")
963
- _ensure_text(email, "POP3_ENABLED", "1")
964
- return
965
- tag, enabled_tag = mapping[service_name]
966
- node = engine.find(tag)
967
- if node is None:
968
- prototype = _server_engine_prototype_child(tag)
969
- node = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, tag)
970
- if node not in list(engine):
971
- engine.append(node)
972
- _ensure_text(node, enabled_tag, "1")
973
-
974
-
975
- def _server_engine_prototype_child(tag: str) -> ET.Element | None:
976
- sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
977
- for device in sample_root.findall(".//DEVICES/DEVICE"):
978
- engine = device.find("ENGINE")
979
- if engine is None or _device_type(device) != "Server":
980
- continue
981
- child = engine.find(tag)
982
- if child is not None:
983
- return child
984
- return None
985
-
986
-
987
- def _apply_server_op(device: ET.Element, operation: dict[str, object]) -> None:
988
- engine = device.find("./ENGINE")
989
- if engine is None:
990
- return
991
- if operation["op"] == "set_server_dns_record":
992
- dns_server = engine.find("DNS_SERVER")
993
- if dns_server is None:
994
- prototype = _server_engine_prototype_child("DNS_SERVER")
995
- dns_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DNS_SERVER")
996
- engine.append(dns_server)
997
- _ensure_text(dns_server, "ENABLED", "1")
998
- database = dns_server.find("NAMESERVER-DATABASE")
999
- if database is None:
1000
- database = ET.SubElement(dns_server, "NAMESERVER-DATABASE")
1001
- record = ET.SubElement(database, "RESOURCE-RECORD")
1002
- if operation["record_type"] == "A":
1003
- _ensure_text(record, "TYPE", "A-REC")
1004
- _ensure_text(record, "NAME", str(operation["name"]))
1005
- _ensure_text(record, "TTL", "86400")
1006
- _ensure_text(record, "IPADDRESS", str(operation["value"]))
1007
- else:
1008
- _ensure_text(record, "TYPE", "CNAME")
1009
- _ensure_text(record, "NAME", str(operation["name"]))
1010
- _ensure_text(record, "TTL", "86400")
1011
- _ensure_text(record, "SERVER-NAME", str(operation["value"]))
1012
- elif operation["op"] == "set_server_dhcp_pool":
1013
- dhcp_server = engine.find("DHCP_SERVER")
1014
- if dhcp_server is None:
1015
- prototype = _server_engine_prototype_child("DHCP_SERVER")
1016
- dhcp_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DHCP_SERVER")
1017
- engine.append(dhcp_server)
1018
- _ensure_text(dhcp_server, "ENABLED", "1")
1019
- pools = dhcp_server.find("POOLS")
1020
- if pools is None:
1021
- pools = ET.SubElement(dhcp_server, "POOLS")
1022
- pool = ET.SubElement(pools, "POOL")
1023
- _ensure_text(pool, "NAME", str(operation["name"]))
1024
- _ensure_text(pool, "NETWORK", str(operation["network"]))
1025
- _ensure_text(pool, "MASK", _prefix_to_mask(int(operation["prefix"])))
1026
- _ensure_text(pool, "DEFAULT_ROUTER", str(operation["gateway"]))
1027
- _ensure_text(pool, "START_IP", str(operation.get("start") or operation["network"]))
1028
- _ensure_text(pool, "END_IP", str(operation.get("start") or operation["network"]))
1029
- _ensure_text(pool, "DNS_SERVER", str(operation.get("dns") or "0.0.0.0"))
1030
- _ensure_text(pool, "MAX_USERS", str(operation.get("max_users") or 0))
1031
- elif operation["op"] == "enable_server_service":
1032
- _set_enabled_service(engine, str(operation["service"]))
1033
- elif operation["op"] == "set_server_email_domain":
1034
- email_server = engine.find("EMAIL_SERVER")
1035
- if email_server is None:
1036
- prototype = _server_engine_prototype_child("EMAIL_SERVER")
1037
- email_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "EMAIL_SERVER")
1038
- engine.append(email_server)
1039
- _ensure_text(email_server, "SMTP_ENABLED", "1")
1040
- _ensure_text(email_server, "POP3_ENABLED", "1")
1041
- _ensure_text(email_server, "SMTP_DOMAIN", str(operation["domain"]))
1042
- elif operation["op"] == "set_server_aaa_auth_port":
1043
- acs_server = engine.find("ACS_SERVER")
1044
- if acs_server is None:
1045
- prototype = _server_engine_prototype_child("ACS_SERVER")
1046
- acs_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "ACS_SERVER")
1047
- engine.append(acs_server)
1048
- _ensure_text(acs_server, "ENABLED", "1")
1049
- radius_settings = acs_server.find("RADIUS_SETTINGS")
1050
- if radius_settings is None:
1051
- radius_settings = ET.SubElement(acs_server, "RADIUS_SETTINGS")
1052
- _ensure_text(radius_settings, "AUTH_PORT", str(operation["auth_port"]))
1053
-
1054
-
1055
- def _wireless_common_nodes(engine: ET.Element) -> list[ET.Element]:
1056
- nodes: list[ET.Element] = []
1057
- for path in [
1058
- "./WIRELESS_SERVER/WIRELESS_COMMON",
1059
- "./WIRELESS_CLIENT/WIRELESS_COMMON",
1060
- "./WLC/WLANS/WLAN_CONFIG",
1061
- "./CAPWAP_AC/WLANS/WLAN_CONFIG",
1062
- ]:
1063
- node = engine.find(path)
1064
- if node is not None:
1065
- nodes.append(node)
1066
- return nodes
1067
-
1068
-
1069
- def _profile_nodes(engine: ET.Element) -> list[ET.Element]:
1070
- return engine.findall("./WIRELESS_CLIENT/PROFILES/WIRELESS_PROFILE") + engine.findall("./WIRELESS_CLIENT/CURRENT_PROFILE/WIRELESS_PROFILE")
1071
-
1072
-
1073
- def _apply_wireless_op(device: ET.Element, operation: dict[str, object]) -> None:
1074
- engine = device.find("./ENGINE")
1075
- if engine is None:
1076
- return
1077
- if operation["op"] == "set_wireless_ssid":
1078
- for node in _wireless_common_nodes(engine):
1079
- _ensure_text(node, "SSID", str(operation["ssid"]))
1080
- _ensure_text(node, "AUTHEN_TYPE", str(operation["auth_type"]))
1081
- _ensure_text(node, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
1082
- if node.find("STANDARD_CHANNEL") is not None:
1083
- _ensure_text(node, "STANDARD_CHANNEL", str(operation["channel"]))
1084
- if node.find("CHANNEL") is not None:
1085
- _ensure_text(node, "CHANNEL", str(operation["channel"]))
1086
- if operation.get("passphrase"):
1087
- if node.find("WEP_KEY") is not None:
1088
- _ensure_text(node, "WEP_KEY", str(operation["passphrase"]))
1089
- if node.find("WPA_PASSPHRASE") is not None:
1090
- _ensure_text(node, "WPA_PASSPHRASE", str(operation["passphrase"]))
1091
- for profile in _profile_nodes(engine):
1092
- _ensure_text(profile, "NAME", str(operation["ssid"]))
1093
- _ensure_text(profile, "SSID", str(operation["ssid"]))
1094
- _ensure_text(profile, "AUTHEN_TYPE", str(operation["auth_type"]))
1095
- _ensure_text(profile, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
1096
- _ensure_text(profile, "CHANNEL", str(operation["channel"]))
1097
- if profile.find("WEP_KEY") is not None:
1098
- _ensure_text(profile, "WEP_KEY", str(operation.get("passphrase") or ""))
1099
- elif operation["op"] == "associate_wireless_client":
1100
- for node in _wireless_common_nodes(engine):
1101
- _ensure_text(node, "SSID", str(operation["ssid"]))
1102
- for profile in _profile_nodes(engine):
1103
- _ensure_text(profile, "NAME", str(operation["ssid"]))
1104
- _ensure_text(profile, "SSID", str(operation["ssid"]))
1105
- _ensure_text(profile, "DHCP_ENABLED", "1" if operation.get("ip_mode", "dhcp") == "dhcp" else "0")
1106
- for port in device.findall(".//PORT"):
1107
- if port.findtext("TYPE", default="").startswith("eHostWireless"):
1108
- _ensure_text(port, "PORT_DHCP_ENABLE", "true" if operation.get("ip_mode", "dhcp") == "dhcp" else "false")
1109
-
1110
-
1111
- def _apply_end_device_op(device: ET.Element, operation: dict[str, object]) -> None:
1112
- if operation["op"] == "set_host_ip":
1113
- apply_host_ip(device, operation)
1114
- elif operation["op"] == "set_host_dhcp":
1115
- for port in device.findall(".//PORT"):
1116
- if port.find("PORT_DHCP_ENABLE") is not None:
1117
- _ensure_text(port, "PORT_DHCP_ENABLE", "true")
1118
- for profile in _profile_nodes(device.find("./ENGINE") or ET.Element("ENGINE")):
1119
- _ensure_text(profile, "DHCP_ENABLED", "1")
1120
- elif operation["op"] == "set_host_dns":
1121
- apply_host_ip(device, operation)
1122
-
1123
-
1124
- def _apply_iot_op(device: ET.Element, operation: dict[str, object]) -> None:
1125
- engine = device.find("./ENGINE")
1126
- if engine is None:
1127
- return
1128
- if operation["op"] == "set_iot_registration":
1129
- client = engine.find("IOE_CLIENT")
1130
- if client is None:
1131
- client = ET.SubElement(engine, "IOE_CLIENT")
1132
- if operation.get("mode"):
1133
- _ensure_text(client, "CLIENT_MODE", str(operation["mode"]))
1134
- if operation.get("server_address"):
1135
- _ensure_text(client, "SERVER_ADDRESS", str(operation["server_address"]))
1136
- if operation.get("username"):
1137
- _ensure_text(client, "USERNAME", str(operation["username"]))
1138
- if operation.get("password"):
1139
- _ensure_text(client, "PASSWORD", str(operation["password"]))
1140
- return
1141
- if operation["op"] == "set_iot_rule_state":
1142
- for node in engine.findall("./IOE_USER_MANAGER/USERS/USER/IOE_RULES/IOE_RULE/JSON"):
1143
- try:
1144
- payload = json.loads(node.text or "{}")
1145
- except json.JSONDecodeError:
1146
- continue
1147
- if str(payload.get("name", "")).strip() != str(operation["rule_name"]).strip():
1148
- continue
1149
- payload["enabled"] = bool(operation["enabled"])
1150
- node.text = json.dumps(payload)
1151
- return
1152
-
1153
-
1154
- def apply_plan_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
1155
- updated = copy.deepcopy(root)
1156
- port_mem_map = _link_port_mem_map(updated)
1157
- acl_kind_map: dict[str, str] = {}
1158
- acl_device_map: dict[str, str] = {}
1159
- for operation in plan.router_ops:
1160
- if operation["op"] == "set_acl":
1161
- acl_kind_map[str(operation["acl_name"])] = str(operation["acl_kind"])
1162
- acl_device_map[str(operation["acl_name"])] = str(operation["device"])
1163
- for operation in plan.router_ops:
1164
- if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_kind_map:
1165
- operation["acl_kind"] = acl_kind_map[str(operation["acl_name"])]
1166
- if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_device_map:
1167
- operation["device"] = acl_device_map[str(operation["acl_name"])]
1168
- for operation in plan.edit_operations:
1169
- if operation["op"] == "prune_device":
1170
- _prune_device(updated, str(operation["device"]))
1171
- continue
1172
- if operation["op"] == "remove_link":
1173
- _remove_link(updated, str(operation["a"]["dev"]), str(operation["b"]["dev"]))
1174
- continue
1175
- if operation["op"] == "set_link":
1176
- _ensure_link(
1177
- updated,
1178
- str(operation["a"]["dev"]),
1179
- str(operation["a"]["port"]),
1180
- str(operation["b"]["dev"]),
1181
- str(operation["b"]["port"]),
1182
- str(operation.get("media", "copper")),
1183
- port_mem_map=port_mem_map,
1184
- )
1185
- continue
1186
- device = _find_device(updated, str(operation["device"]))
1187
- if device is None:
1188
- continue
1189
- if operation["op"] == "rename_device":
1190
- _set_device_name(updated, device, str(operation["new_name"]))
1191
- elif operation["op"] == "reflow_layout":
1192
- _set_device_position(device, int(operation["x"]), int(operation["y"]))
1193
-
1194
- for bucket, handler in [
1195
- (plan.switch_ops, _apply_switch_op),
1196
- (plan.router_ops, _apply_router_op),
1197
- (plan.server_ops, _apply_server_op),
1198
- (plan.wireless_ops, _apply_wireless_op),
1199
- (plan.end_device_ops, _apply_end_device_op),
1200
- (plan.management_ops, _apply_management_op),
1201
- (plan.iot_ops, _apply_iot_op),
1202
- ]:
1203
- for operation in bucket:
1204
- device = _find_device(updated, str(operation["device"]))
1205
- if device is None:
1206
- continue
1207
- handler(device, operation)
1208
- return updated
1209
-
1210
-
1211
- def apply_edit_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
1212
- return apply_plan_operations(root, plan)
1213
-
1214
-
1215
- def edit_pkt_file(pkt_path: str | Path, plan: IntentPlan, output_path: str | Path, xml_out_path: str | Path | None = None) -> Path:
1216
- root = decode_pkt_to_root(pkt_path)
1217
- updated = apply_plan_operations(root, plan)
1218
- xml_bytes = ET.tostring(updated, encoding="utf-8", xml_declaration=False)
1219
- output_path = Path(output_path)
1220
- output_path.parent.mkdir(parents=True, exist_ok=True)
1221
- output_path.write_bytes(encode_pkt_modern(xml_bytes))
1222
- if xml_out_path is not None:
1223
- xml_path = Path(xml_out_path)
1224
- xml_path.parent.mkdir(parents=True, exist_ok=True)
1225
- xml_path.write_bytes(xml_bytes)
1226
- return output_path
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