packet-tracer-skill 0.2.3 → 0.3.1

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