packet-tracer-skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/LICENSE.Twofish-BSD-3-Clause.txt +29 -0
  3. package/README.md +687 -0
  4. package/SKILL.md +221 -0
  5. package/bin/packet-tracer-skill.js +635 -0
  6. package/examples/blueprint_minimal.json +46 -0
  7. package/package.json +42 -0
  8. package/references/packettracer-sample-catalog.json +21410 -0
  9. package/references/packettracer-sample-catalog.md +1124 -0
  10. package/references/pkt-format.md +57 -0
  11. package/references/xml-skeleton-notes.md +44 -0
  12. package/requirements-dev.txt +1 -0
  13. package/requirements.txt +6 -0
  14. package/scripts/build_sample_catalog.py +65 -0
  15. package/scripts/donor_diagnostics.py +35 -0
  16. package/scripts/generate_pkt.py +1264 -0
  17. package/scripts/install_skill.py +71 -0
  18. package/scripts/intent_parser.py +712 -0
  19. package/scripts/packet_tracer_env.py +278 -0
  20. package/scripts/pkt_builder.py +15 -0
  21. package/scripts/pkt_codec.py +181 -0
  22. package/scripts/pkt_editor.py +752 -0
  23. package/scripts/pkt_transformer.py +541 -0
  24. package/scripts/sample_catalog.py +385 -0
  25. package/scripts/sample_selector.py +156 -0
  26. package/scripts/setup.ps1 +26 -0
  27. package/scripts/twofish_diagnostics.py +91 -0
  28. package/scripts/vendor/README.md +46 -0
  29. package/scripts/vendor/twofish.py +81 -0
  30. package/scripts/workspace_repair.py +441 -0
  31. package/templates/pt900/base_empty.xml +21 -0
  32. package/templates/pt900/device_library/pc.xml +20 -0
  33. package/templates/pt900/device_library/printer.xml +432 -0
  34. package/templates/pt900/device_library/router.xml +16 -0
  35. package/templates/pt900/device_library/switch.xml +38 -0
@@ -0,0 +1,752 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import re
5
+ from pathlib import Path
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from intent_parser import IntentPlan
9
+ from packet_tracer_env import resolve_sample_path
10
+ from pkt_codec import decode_pkt_modern, encode_pkt_modern
11
+ from pkt_transformer import _device_type, _port_address_for_name, apply_cable_type, apply_host_ip, load_sample_root
12
+
13
+
14
+ FTP_SAMPLE = r"01 Networking\FTP\FTP.pkt"
15
+ SERVER_SAMPLE = r"01 Networking\DNS\Multilevel_DNS.pkt"
16
+ WIRELESS_SAMPLE = r"01 Networking\DHCP\dhcp_reservation.pkt"
17
+
18
+
19
+ def decode_pkt_to_root(pkt_path: str | Path) -> ET.Element:
20
+ return ET.fromstring(decode_pkt_modern(Path(pkt_path).read_bytes()))
21
+
22
+
23
+ def inventory_devices(root: ET.Element) -> list[dict[str, str]]:
24
+ devices: list[dict[str, str]] = []
25
+ for device in root.findall(".//DEVICES/DEVICE"):
26
+ devices.append(
27
+ {
28
+ "name": device.findtext("./ENGINE/NAME", default=""),
29
+ "type": _device_type(device),
30
+ "model": device.find("./ENGINE/TYPE").get("model", "") if device.find("./ENGINE/TYPE") is not None else "",
31
+ }
32
+ )
33
+ return devices
34
+
35
+
36
+ def inventory_links(root: ET.Element) -> list[dict[str, object]]:
37
+ devices = root.findall(".//DEVICES/DEVICE")
38
+ index_to_name = {str(index): device.findtext("./ENGINE/NAME", default="") for index, device in enumerate(devices)}
39
+ save_ref_to_name = {
40
+ device.findtext("./ENGINE/SAVE_REF_ID", default=""): device.findtext("./ENGINE/NAME", default="")
41
+ for device in devices
42
+ if device.findtext("./ENGINE/SAVE_REF_ID", default="")
43
+ }
44
+ result: list[dict[str, object]] = []
45
+ for link in root.findall(".//LINKS/LINK"):
46
+ cable = link.find("./CABLE")
47
+ if cable is None:
48
+ continue
49
+ ports = cable.findall("PORT")
50
+ from_ref = cable.findtext("FROM", default="")
51
+ to_ref = cable.findtext("TO", default="")
52
+ result.append(
53
+ {
54
+ "from": save_ref_to_name.get(from_ref, index_to_name.get(from_ref, "")),
55
+ "to": save_ref_to_name.get(to_ref, index_to_name.get(to_ref, "")),
56
+ "ports": [port.text or "" for port in ports],
57
+ "media": cable.findtext("TYPE", default=""),
58
+ }
59
+ )
60
+ return result
61
+
62
+
63
+ def inventory_services(root: ET.Element) -> dict[str, list[str]]:
64
+ result: dict[str, list[str]] = {}
65
+ for device in root.findall(".//DEVICES/DEVICE"):
66
+ name = device.findtext("./ENGINE/NAME", default="")
67
+ engine = device.find("./ENGINE")
68
+ if engine is None:
69
+ continue
70
+ enabled: list[str] = []
71
+ for tag, enabled_tag in [
72
+ ("HTTP_SERVER", "ENABLED"),
73
+ ("HTTPS_SERVER", "HTTPSENABLED"),
74
+ ("DNS_SERVER", "ENABLED"),
75
+ ("DHCP_SERVER", "ENABLED"),
76
+ ("TFTP_SERVER", "ENABLED"),
77
+ ("NTP_SERVER", "ENABLED"),
78
+ ]:
79
+ node = engine.find(tag)
80
+ if node is not None and node.findtext(enabled_tag, default="0") in {"1", "true", "True"}:
81
+ enabled.append(tag.lower())
82
+ if enabled:
83
+ result[name] = enabled
84
+ return result
85
+
86
+
87
+ def inventory_wireless(root: ET.Element) -> dict[str, dict[str, str]]:
88
+ result: dict[str, dict[str, str]] = {}
89
+ for device in root.findall(".//DEVICES/DEVICE"):
90
+ name = device.findtext("./ENGINE/NAME", default="")
91
+ engine = device.find("./ENGINE")
92
+ if engine is None:
93
+ continue
94
+ if engine.find("WIRELESS_SERVER") is not None:
95
+ common = engine.find("./WIRELESS_SERVER/WIRELESS_COMMON")
96
+ result[name] = {
97
+ "mode": "ap",
98
+ "ssid": common.findtext("SSID", default="") if common is not None else "",
99
+ }
100
+ if engine.find("WIRELESS_CLIENT") is not None:
101
+ common = engine.find("./WIRELESS_CLIENT/WIRELESS_COMMON")
102
+ result[name] = {
103
+ "mode": "client",
104
+ "ssid": common.findtext("SSID", default="") if common is not None else "",
105
+ }
106
+ return result
107
+
108
+
109
+ def inventory_vlans(root: ET.Element) -> dict[str, list[dict[str, str]]]:
110
+ result: dict[str, list[dict[str, str]]] = {}
111
+ for device in root.findall(".//DEVICES/DEVICE"):
112
+ name = device.findtext("./ENGINE/NAME", default="")
113
+ vlans: list[dict[str, str]] = []
114
+ for vlan in device.findall(".//VLANS/VLAN"):
115
+ vlan_id = vlan.get("number") or vlan.findtext("ID", default="")
116
+ vlan_name = vlan.get("name") or vlan.findtext("NAME", default="")
117
+ if vlan_id:
118
+ vlans.append({"id": vlan_id, "name": vlan_name})
119
+ if vlans:
120
+ result[name] = vlans
121
+ continue
122
+ running = "\n".join(line.text or "" for line in device.findall(".//LINE"))
123
+ inferred: list[dict[str, str]] = []
124
+ for match in re.finditer(r"(?mi)^vlan\s+(\d+)\s*$", running):
125
+ vlan_id = match.group(1)
126
+ name_match = re.search(rf"(?mi)^vlan\s+{re.escape(vlan_id)}\s*$\n^\s*name\s+(.+?)\s*$", running)
127
+ inferred.append({"id": vlan_id, "name": name_match.group(1).strip() if name_match else ""})
128
+ if inferred:
129
+ unique: dict[str, dict[str, str]] = {entry["id"]: entry for entry in inferred}
130
+ result[name] = list(unique.values())
131
+ return result
132
+
133
+
134
+ def inventory_dhcp_pools(root: ET.Element) -> dict[str, list[str]]:
135
+ pools: dict[str, list[str]] = {}
136
+ for device in root.findall(".//DEVICES/DEVICE"):
137
+ name = device.findtext("./ENGINE/NAME", default="")
138
+ device_pools: list[str] = []
139
+ for pool in device.findall(".//DHCP_SERVER/POOLS/POOL/NAME"):
140
+ if pool.text:
141
+ device_pools.append(pool.text)
142
+ running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
143
+ for match in re.findall(r"ip dhcp pool\s+([A-Za-z0-9_-]+)", running):
144
+ if match not in device_pools:
145
+ device_pools.append(match)
146
+ if device_pools:
147
+ pools[name] = device_pools
148
+ return pools
149
+
150
+
151
+ def inventory_acl_names(root: ET.Element) -> dict[str, list[str]]:
152
+ result: dict[str, list[str]] = {}
153
+ for device in root.findall(".//DEVICES/DEVICE"):
154
+ name = device.findtext("./ENGINE/NAME", default="")
155
+ running = "\n".join(line.text or "" for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"))
156
+ matches = re.findall(r"ip access-list\s+(?:standard|extended)\s+([A-Za-z0-9_-]+)", running)
157
+ if matches:
158
+ result[name] = sorted(dict.fromkeys(matches))
159
+ return result
160
+
161
+
162
+ def inventory_topology_summary(root: ET.Element) -> dict[str, object]:
163
+ devices = inventory_devices(root)
164
+ counts: dict[str, int] = {}
165
+ for device in devices:
166
+ counts[device["type"]] = counts.get(device["type"], 0) + 1
167
+ return {
168
+ "device_counts": counts,
169
+ "link_count": len(inventory_links(root)),
170
+ "has_wireless": bool(inventory_wireless(root)),
171
+ "has_services": bool(inventory_services(root)),
172
+ "has_vlans": bool(inventory_vlans(root)),
173
+ }
174
+
175
+
176
+ def inventory_root(root: ET.Element) -> dict[str, object]:
177
+ return {
178
+ "devices": inventory_devices(root),
179
+ "links": inventory_links(root),
180
+ "services": inventory_services(root),
181
+ "wireless": inventory_wireless(root),
182
+ "vlans": inventory_vlans(root),
183
+ "dhcp_pools": inventory_dhcp_pools(root),
184
+ "acl_names": inventory_acl_names(root),
185
+ "topology_summary": inventory_topology_summary(root),
186
+ }
187
+
188
+
189
+ def _find_device(root: ET.Element, name: str) -> ET.Element | None:
190
+ for device in root.findall(".//DEVICES/DEVICE"):
191
+ if device.findtext("./ENGINE/NAME", default="") == name:
192
+ return device
193
+ return None
194
+
195
+
196
+ def _ensure_text(parent: ET.Element, tag: str, value: str) -> ET.Element:
197
+ node = parent.find(tag)
198
+ if node is None:
199
+ node = ET.SubElement(parent, tag)
200
+ node.text = value
201
+ return node
202
+
203
+
204
+ def _replace_lines(target: ET.Element, lines: list[str]) -> None:
205
+ target.clear()
206
+ for line in lines:
207
+ node = ET.SubElement(target, "LINE")
208
+ node.text = line
209
+
210
+
211
+ def _append_unique_config_lines(parent: ET.Element | None, lines: list[str]) -> None:
212
+ if parent is None:
213
+ return
214
+ existing = [line.text or "" for line in parent.findall("./LINE")]
215
+ merged = list(existing)
216
+ for line in lines:
217
+ if line not in merged:
218
+ merged.append(line)
219
+ _replace_lines(parent, merged)
220
+
221
+
222
+ def _append_config_block(parent: ET.Element | None, header: str, body: list[str]) -> None:
223
+ if parent is None:
224
+ return
225
+ existing = [line.text or "" for line in parent.findall("./LINE")]
226
+ block = [header, *body]
227
+ for index in range(0, max(len(existing) - len(block) + 1, 0)):
228
+ if existing[index : index + len(block)] == block:
229
+ return
230
+ _replace_lines(parent, [*existing, *block])
231
+
232
+
233
+ def _device_index_map(root: ET.Element) -> dict[str, int]:
234
+ return {device.findtext("./ENGINE/NAME", default=""): index for index, device in enumerate(root.findall(".//DEVICES/DEVICE"))}
235
+
236
+
237
+ def _device_refs(root: ET.Element) -> tuple[dict[str, str], dict[str, str]]:
238
+ index_map = _device_index_map(root)
239
+ index_refs = {name: str(index) for name, index in index_map.items()}
240
+ save_refs = {}
241
+ for device in root.findall(".//DEVICES/DEVICE"):
242
+ name = device.findtext("./ENGINE/NAME", default="")
243
+ save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
244
+ if name and save_ref:
245
+ save_refs[name] = save_ref
246
+ return index_refs, save_refs
247
+
248
+
249
+ def _find_link_by_devices(root: ET.Element, left_name: str, right_name: str) -> ET.Element | None:
250
+ index_refs, save_refs = _device_refs(root)
251
+ left_candidates = {index_refs.get(left_name, ""), save_refs.get(left_name, "")}
252
+ right_candidates = {index_refs.get(right_name, ""), save_refs.get(right_name, "")}
253
+ for link in root.findall(".//LINKS/LINK"):
254
+ cable = link.find("./CABLE")
255
+ if cable is None:
256
+ continue
257
+ from_idx = cable.findtext("FROM", default="")
258
+ to_idx = cable.findtext("TO", default="")
259
+ if from_idx in left_candidates and to_idx in right_candidates:
260
+ return link
261
+ if from_idx in right_candidates and to_idx in left_candidates:
262
+ return link
263
+ return None
264
+
265
+
266
+ def _remove_links_for_device(root: ET.Element, device_name: str) -> None:
267
+ index_refs, save_refs = _device_refs(root)
268
+ refs = {index_refs.get(device_name, ""), save_refs.get(device_name, "")}
269
+ links_parent = root.find(".//LINKS")
270
+ if links_parent is None:
271
+ return
272
+ for link in list(links_parent.findall("./LINK")):
273
+ cable = link.find("./CABLE")
274
+ if cable is None:
275
+ continue
276
+ if cable.findtext("FROM", default="") in refs or cable.findtext("TO", default="") in refs:
277
+ links_parent.remove(link)
278
+
279
+
280
+ def _find_parent_of_node(root: ET.Element, target: ET.Element) -> ET.Element | None:
281
+ for parent in root.iter():
282
+ for child in list(parent):
283
+ if child is target:
284
+ return parent
285
+ return None
286
+
287
+
288
+ def _remove_physical_leaf(root: ET.Element, device: ET.Element) -> None:
289
+ physical_path = device.findtext("./WORKSPACE/PHYSICAL", default="")
290
+ if not physical_path:
291
+ return
292
+ tokens = [token.strip() for token in physical_path.split(",") if token.strip()]
293
+ if not tokens:
294
+ return
295
+ leaf_token = tokens[-1]
296
+ for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
297
+ uuid = node.findtext("UUID_STR", default="").strip()
298
+ if uuid == leaf_token:
299
+ parent = _find_parent_of_node(root, node)
300
+ if parent is not None:
301
+ parent.remove(node)
302
+ return
303
+
304
+
305
+ def _prune_device(root: ET.Element, device_name: str) -> None:
306
+ device = _find_device(root, device_name)
307
+ if device is None:
308
+ return
309
+ _remove_links_for_device(root, device_name)
310
+ _remove_physical_leaf(root, device)
311
+ parent = _find_parent_of_node(root, device)
312
+ if parent is not None:
313
+ parent.remove(device)
314
+
315
+
316
+ def _remove_link(root: ET.Element, left_name: str, right_name: str) -> None:
317
+ links_parent = root.find(".//LINKS")
318
+ if links_parent is None:
319
+ return
320
+ link = _find_link_by_devices(root, left_name, right_name)
321
+ if link is not None:
322
+ links_parent.remove(link)
323
+
324
+
325
+ def _ensure_link(root: ET.Element, left_name: str, left_port: str, right_name: str, right_port: str, media: str) -> None:
326
+ existing = _find_link_by_devices(root, left_name, right_name)
327
+ devices = {device.findtext("./ENGINE/NAME", default=""): device for device in root.findall(".//DEVICES/DEVICE")}
328
+ index_refs, save_refs = _device_refs(root)
329
+ left_device = devices.get(left_name)
330
+ right_device = devices.get(right_name)
331
+ if left_device is None or right_device is None:
332
+ return
333
+ link = existing
334
+ if link is None:
335
+ left_type = _device_type(left_device)
336
+ right_type = _device_type(right_device)
337
+ prototype = None
338
+ for candidate in root.findall(".//LINKS/LINK"):
339
+ cable = candidate.find("./CABLE")
340
+ if cable is None:
341
+ continue
342
+ from_ref = cable.findtext("FROM", default="")
343
+ to_ref = cable.findtext("TO", default="")
344
+ 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), ""))
345
+ 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), ""))
346
+ if not from_name or not to_name:
347
+ continue
348
+ from_type = _device_type(devices[from_name])
349
+ to_type = _device_type(devices[to_name])
350
+ if {from_type, to_type} == {left_type, right_type}:
351
+ prototype = candidate
352
+ break
353
+ if prototype is None:
354
+ prototype_root = load_sample_root(resolve_sample_path(FTP_SAMPLE))
355
+ prototype = prototype_root.find(".//LINKS/LINK")
356
+ link = copy.deepcopy(prototype)
357
+ if link is None:
358
+ return
359
+ links_parent = root.find(".//LINKS")
360
+ if links_parent is None:
361
+ return
362
+ links_parent.append(link)
363
+ cable = link.find("./CABLE")
364
+ if cable is None:
365
+ return
366
+ _ensure_text(cable, "FROM", save_refs.get(left_name, index_refs[left_name]))
367
+ _ensure_text(cable, "TO", save_refs.get(right_name, index_refs[right_name]))
368
+ ports = cable.findall("PORT")
369
+ if len(ports) < 2:
370
+ while len(ports) < 2:
371
+ ports.append(ET.SubElement(cable, "PORT"))
372
+ ports[0].text = left_port
373
+ ports[1].text = right_port
374
+ for node_name, value in [
375
+ ("FROM_DEVICE_MEM_ADDR", left_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
376
+ ("TO_DEVICE_MEM_ADDR", right_device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")),
377
+ ("FROM_PORT_MEM_ADDR", _port_address_for_name(left_device, left_port) or ""),
378
+ ("TO_PORT_MEM_ADDR", _port_address_for_name(right_device, right_port) or ""),
379
+ ]:
380
+ _ensure_text(cable, node_name, value)
381
+ apply_cable_type(cable, media)
382
+
383
+
384
+ def _prefix_to_mask(prefix: int) -> str:
385
+ bits = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
386
+ return ".".join(str((bits >> shift) & 0xFF) for shift in (24, 16, 8, 0))
387
+
388
+
389
+ def _set_device_name(root: ET.Element, device: ET.Element, new_name: str) -> None:
390
+ old_name = device.findtext("./ENGINE/NAME", default="")
391
+ node = device.find("./ENGINE/NAME")
392
+ if node is not None:
393
+ node.text = new_name
394
+ sys_name = device.find("./ENGINE/SYS_NAME")
395
+ if sys_name is not None and (sys_name.text or "").strip() == old_name:
396
+ sys_name.text = new_name
397
+
398
+ for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
399
+ text = line.text or ""
400
+ if old_name and text == f"hostname {old_name}":
401
+ line.text = f"hostname {new_name}"
402
+ for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
403
+ text = line.text or ""
404
+ if old_name and text == f"hostname {old_name}":
405
+ line.text = f"hostname {new_name}"
406
+ for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
407
+ text = line.text or ""
408
+ if old_name and text == f"hostname {old_name}":
409
+ line.text = f"hostname {new_name}"
410
+
411
+ physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
412
+ leaf_uuid = physical.split(",")[-1].strip() if physical else ""
413
+ if leaf_uuid:
414
+ for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
415
+ uuid = node.findtext("UUID_STR", default="").strip()
416
+ if uuid == leaf_uuid:
417
+ leaf_name = node.find("./NAME")
418
+ if leaf_name is not None:
419
+ leaf_name.text = new_name
420
+ break
421
+
422
+
423
+ def _set_device_position(device: ET.Element, x: int, y: int) -> None:
424
+ workspace = device.find("./WORKSPACE/LOGICAL")
425
+ if workspace is None:
426
+ return
427
+ _ensure_text(workspace, "X", str(x))
428
+ _ensure_text(workspace, "Y", str(y))
429
+
430
+
431
+ def _config_targets(device: ET.Element) -> list[ET.Element]:
432
+ targets: list[ET.Element] = []
433
+ for path in ["./ENGINE/RUNNINGCONFIG", "./ENGINE/STARTUPCONFIG"]:
434
+ node = device.find(path)
435
+ if node is not None:
436
+ targets.append(node)
437
+ for node in device.findall(".//FILE_CONTENT/CONFIG"):
438
+ targets.append(node)
439
+ return targets
440
+
441
+
442
+ def _ensure_vlan_state(device: ET.Element, vlan_id: int, vlan_name: str) -> None:
443
+ vlans = device.find(".//VLANS")
444
+ if vlans is None:
445
+ engine = device.find("./ENGINE")
446
+ if engine is None:
447
+ return
448
+ vlans = ET.SubElement(engine, "VLANS")
449
+ existing = next((node for node in vlans.findall("./VLAN") if node.get("number") == str(vlan_id)), None)
450
+ if existing is None:
451
+ existing = ET.SubElement(vlans, "VLAN")
452
+ existing.set("number", str(vlan_id))
453
+ existing.set("rspan", "0")
454
+ existing.set("name", vlan_name)
455
+
456
+
457
+ def _apply_switch_op(device: ET.Element, operation: dict[str, object]) -> None:
458
+ if operation["op"] == "set_vlan":
459
+ lines = [f"vlan {operation['vlan']}", f" name {operation['name']}"]
460
+ for target in _config_targets(device):
461
+ _append_unique_config_lines(target, lines)
462
+ _ensure_vlan_state(device, int(operation["vlan"]), str(operation["name"]))
463
+ return
464
+ elif operation["op"] == "set_access_port":
465
+ for target in _config_targets(device):
466
+ _append_config_block(
467
+ target,
468
+ f"interface {operation['port']}",
469
+ [" switchport mode access", f" switchport access vlan {operation['vlan']}"],
470
+ )
471
+ return
472
+ elif operation["op"] == "set_trunk_port":
473
+ allowed = ",".join(str(vlan) for vlan in operation["allowed"])
474
+ body = [" switchport mode trunk", f" switchport trunk allowed vlan {allowed}"]
475
+ if operation.get("native"):
476
+ body.append(f" switchport trunk native vlan {operation['native']}")
477
+ for target in _config_targets(device):
478
+ _append_config_block(target, f"interface {operation['port']}", body)
479
+ return
480
+ else:
481
+ return
482
+
483
+
484
+ def _apply_router_op(device: ET.Element, operation: dict[str, object]) -> None:
485
+ if operation["op"] == "set_subinterface":
486
+ for target in _config_targets(device):
487
+ _append_config_block(
488
+ target,
489
+ f"interface {operation['subinterface']}",
490
+ [
491
+ f" encapsulation dot1Q {operation['vlan']}",
492
+ f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}",
493
+ " no shutdown",
494
+ ],
495
+ )
496
+ return
497
+ elif operation["op"] == "set_router_dhcp_pool":
498
+ lines = [
499
+ f"ip dhcp pool {operation['name']}",
500
+ f" network {operation['network']} {_prefix_to_mask(int(operation['prefix']))}",
501
+ f" default-router {operation['gateway']}",
502
+ ]
503
+ if operation.get("dns"):
504
+ lines.append(f" dns-server {operation['dns']}")
505
+ elif operation["op"] == "set_acl":
506
+ lines = [f"ip access-list {operation['acl_kind']} {operation['acl_name']}"]
507
+ elif operation["op"] == "add_acl_rule":
508
+ acl_kind = str(operation.get("acl_kind") or "standard")
509
+ for target in _config_targets(device):
510
+ _append_config_block(
511
+ target,
512
+ f"ip access-list {acl_kind} {operation['acl_name']}",
513
+ [f" {operation['action']} {operation['source']} {operation['destination']}"] if operation.get("destination") else [f" {operation['action']} {operation['source']}"],
514
+ )
515
+ return
516
+ elif operation["op"] == "apply_acl":
517
+ for target in _config_targets(device):
518
+ _append_config_block(target, f"interface {operation['interface']}", [f" ip access-group {operation['acl_name']} {operation['direction']}"])
519
+ return
520
+ else:
521
+ return
522
+ for target in _config_targets(device):
523
+ _append_unique_config_lines(target, lines)
524
+
525
+
526
+ def _apply_management_op(device: ET.Element, operation: dict[str, object]) -> None:
527
+ if operation["op"] == "set_management_vlan":
528
+ for target in _config_targets(device):
529
+ _append_config_block(
530
+ target,
531
+ f"interface Vlan{operation['vlan']}",
532
+ [f" ip address {operation['ip']} {_prefix_to_mask(int(operation['prefix']))}", " no shutdown"],
533
+ )
534
+ _append_unique_config_lines(target, [f"ip default-gateway {operation['gateway']}"])
535
+ return
536
+ elif operation["op"] == "enable_telnet":
537
+ for target in _config_targets(device):
538
+ _append_unique_config_lines(target, [f"username {operation['username']} secret {operation['password']}", f"enable secret {operation['password']}"])
539
+ _append_config_block(target, "line vty 0 4", [" login local", " transport input telnet"])
540
+ return
541
+ else:
542
+ return
543
+
544
+
545
+ def _set_enabled_service(engine: ET.Element, service_name: str) -> None:
546
+ mapping = {
547
+ "dns": ("DNS_SERVER", "ENABLED"),
548
+ "http": ("HTTP_SERVER", "ENABLED"),
549
+ "https": ("HTTPS_SERVER", "HTTPSENABLED"),
550
+ "ftp": ("FTP_SERVER", "ENABLED"),
551
+ "tftp": ("TFTP_SERVER", "ENABLED"),
552
+ "ntp": ("NTP_SERVER", "ENABLED"),
553
+ }
554
+ tag, enabled_tag = mapping[service_name]
555
+ node = engine.find(tag)
556
+ if node is None:
557
+ sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
558
+ prototype = sample_root.find(f".//DEVICES/DEVICE[ENGINE/TYPE='Server']/ENGINE/{tag}")
559
+ node = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, tag)
560
+ if node not in list(engine):
561
+ engine.append(node)
562
+ _ensure_text(node, enabled_tag, "1")
563
+
564
+
565
+ def _apply_server_op(device: ET.Element, operation: dict[str, object]) -> None:
566
+ engine = device.find("./ENGINE")
567
+ if engine is None:
568
+ return
569
+ if operation["op"] == "set_server_dns_record":
570
+ dns_server = engine.find("DNS_SERVER")
571
+ if dns_server is None:
572
+ sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
573
+ prototype = sample_root.find(".//DEVICES/DEVICE[ENGINE/TYPE='Server']/ENGINE/DNS_SERVER")
574
+ dns_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DNS_SERVER")
575
+ engine.append(dns_server)
576
+ _ensure_text(dns_server, "ENABLED", "1")
577
+ database = dns_server.find("NAMESERVER-DATABASE")
578
+ if database is None:
579
+ database = ET.SubElement(dns_server, "NAMESERVER-DATABASE")
580
+ record = ET.SubElement(database, "RESOURCE-RECORD")
581
+ if operation["record_type"] == "A":
582
+ _ensure_text(record, "TYPE", "A-REC")
583
+ _ensure_text(record, "NAME", str(operation["name"]))
584
+ _ensure_text(record, "TTL", "86400")
585
+ _ensure_text(record, "IPADDRESS", str(operation["value"]))
586
+ else:
587
+ _ensure_text(record, "TYPE", "CNAME")
588
+ _ensure_text(record, "NAME", str(operation["name"]))
589
+ _ensure_text(record, "TTL", "86400")
590
+ _ensure_text(record, "SERVER-NAME", str(operation["value"]))
591
+ elif operation["op"] == "set_server_dhcp_pool":
592
+ dhcp_server = engine.find("DHCP_SERVER")
593
+ if dhcp_server is None:
594
+ sample_root = load_sample_root(resolve_sample_path(SERVER_SAMPLE))
595
+ prototype = sample_root.find(".//DEVICES/DEVICE[ENGINE/TYPE='Server']/ENGINE/DHCP_SERVER")
596
+ dhcp_server = copy.deepcopy(prototype) if prototype is not None else ET.SubElement(engine, "DHCP_SERVER")
597
+ engine.append(dhcp_server)
598
+ _ensure_text(dhcp_server, "ENABLED", "1")
599
+ pools = dhcp_server.find("POOLS")
600
+ if pools is None:
601
+ pools = ET.SubElement(dhcp_server, "POOLS")
602
+ pool = ET.SubElement(pools, "POOL")
603
+ _ensure_text(pool, "NAME", str(operation["name"]))
604
+ _ensure_text(pool, "NETWORK", str(operation["network"]))
605
+ _ensure_text(pool, "MASK", _prefix_to_mask(int(operation["prefix"])))
606
+ _ensure_text(pool, "DEFAULT_ROUTER", str(operation["gateway"]))
607
+ _ensure_text(pool, "START_IP", str(operation.get("start") or operation["network"]))
608
+ _ensure_text(pool, "END_IP", str(operation.get("start") or operation["network"]))
609
+ _ensure_text(pool, "DNS_SERVER", str(operation.get("dns") or "0.0.0.0"))
610
+ _ensure_text(pool, "MAX_USERS", str(operation.get("max_users") or 0))
611
+ elif operation["op"] == "enable_server_service":
612
+ _set_enabled_service(engine, str(operation["service"]))
613
+
614
+
615
+ def _wireless_common_nodes(engine: ET.Element) -> list[ET.Element]:
616
+ nodes: list[ET.Element] = []
617
+ for path in [
618
+ "./WIRELESS_SERVER/WIRELESS_COMMON",
619
+ "./WIRELESS_CLIENT/WIRELESS_COMMON",
620
+ "./WLC/WLANS/WLAN_CONFIG",
621
+ ]:
622
+ node = engine.find(path)
623
+ if node is not None:
624
+ nodes.append(node)
625
+ return nodes
626
+
627
+
628
+ def _profile_nodes(engine: ET.Element) -> list[ET.Element]:
629
+ return engine.findall("./WIRELESS_CLIENT/PROFILES/WIRELESS_PROFILE") + engine.findall("./WIRELESS_CLIENT/CURRENT_PROFILE/WIRELESS_PROFILE")
630
+
631
+
632
+ def _apply_wireless_op(device: ET.Element, operation: dict[str, object]) -> None:
633
+ engine = device.find("./ENGINE")
634
+ if engine is None:
635
+ return
636
+ if operation["op"] == "set_wireless_ssid":
637
+ for node in _wireless_common_nodes(engine):
638
+ _ensure_text(node, "SSID", str(operation["ssid"]))
639
+ _ensure_text(node, "AUTHEN_TYPE", str(operation["auth_type"]))
640
+ _ensure_text(node, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
641
+ if node.find("STANDARD_CHANNEL") is not None:
642
+ _ensure_text(node, "STANDARD_CHANNEL", str(operation["channel"]))
643
+ if node.find("CHANNEL") is not None:
644
+ _ensure_text(node, "CHANNEL", str(operation["channel"]))
645
+ if operation.get("passphrase"):
646
+ if node.find("WEP_KEY") is not None:
647
+ _ensure_text(node, "WEP_KEY", str(operation["passphrase"]))
648
+ if node.find("WPA_PASSPHRASE") is not None:
649
+ _ensure_text(node, "WPA_PASSPHRASE", str(operation["passphrase"]))
650
+ for profile in _profile_nodes(engine):
651
+ _ensure_text(profile, "NAME", str(operation["ssid"]))
652
+ _ensure_text(profile, "SSID", str(operation["ssid"]))
653
+ _ensure_text(profile, "AUTHEN_TYPE", str(operation["auth_type"]))
654
+ _ensure_text(profile, "ENCRYPT_TYPE", str(operation["encrypt_type"]))
655
+ _ensure_text(profile, "CHANNEL", str(operation["channel"]))
656
+ if profile.find("WEP_KEY") is not None:
657
+ _ensure_text(profile, "WEP_KEY", str(operation.get("passphrase") or ""))
658
+ elif operation["op"] == "associate_wireless_client":
659
+ for node in _wireless_common_nodes(engine):
660
+ _ensure_text(node, "SSID", str(operation["ssid"]))
661
+ for profile in _profile_nodes(engine):
662
+ _ensure_text(profile, "NAME", str(operation["ssid"]))
663
+ _ensure_text(profile, "SSID", str(operation["ssid"]))
664
+ _ensure_text(profile, "DHCP_ENABLED", "1" if operation.get("ip_mode", "dhcp") == "dhcp" else "0")
665
+ for port in device.findall(".//PORT"):
666
+ if port.findtext("TYPE", default="").startswith("eHostWireless"):
667
+ _ensure_text(port, "PORT_DHCP_ENABLE", "true" if operation.get("ip_mode", "dhcp") == "dhcp" else "false")
668
+
669
+
670
+ def _apply_end_device_op(device: ET.Element, operation: dict[str, object]) -> None:
671
+ if operation["op"] == "set_host_ip":
672
+ apply_host_ip(device, operation)
673
+ elif operation["op"] == "set_host_dhcp":
674
+ for port in device.findall(".//PORT"):
675
+ if port.find("PORT_DHCP_ENABLE") is not None:
676
+ _ensure_text(port, "PORT_DHCP_ENABLE", "true")
677
+ for profile in _profile_nodes(device.find("./ENGINE") or ET.Element("ENGINE")):
678
+ _ensure_text(profile, "DHCP_ENABLED", "1")
679
+ elif operation["op"] == "set_host_dns":
680
+ apply_host_ip(device, operation)
681
+
682
+
683
+ def apply_plan_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
684
+ updated = copy.deepcopy(root)
685
+ acl_kind_map: dict[str, str] = {}
686
+ acl_device_map: dict[str, str] = {}
687
+ for operation in plan.router_ops:
688
+ if operation["op"] == "set_acl":
689
+ acl_kind_map[str(operation["acl_name"])] = str(operation["acl_kind"])
690
+ acl_device_map[str(operation["acl_name"])] = str(operation["device"])
691
+ for operation in plan.router_ops:
692
+ if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_kind_map:
693
+ operation["acl_kind"] = acl_kind_map[str(operation["acl_name"])]
694
+ if operation["op"] == "add_acl_rule" and operation.get("acl_name") in acl_device_map:
695
+ operation["device"] = acl_device_map[str(operation["acl_name"])]
696
+ for operation in plan.edit_operations:
697
+ if operation["op"] == "prune_device":
698
+ _prune_device(updated, str(operation["device"]))
699
+ continue
700
+ if operation["op"] == "remove_link":
701
+ _remove_link(updated, str(operation["a"]["dev"]), str(operation["b"]["dev"]))
702
+ continue
703
+ if operation["op"] == "set_link":
704
+ _ensure_link(
705
+ updated,
706
+ str(operation["a"]["dev"]),
707
+ str(operation["a"]["port"]),
708
+ str(operation["b"]["dev"]),
709
+ str(operation["b"]["port"]),
710
+ str(operation.get("media", "copper")),
711
+ )
712
+ continue
713
+ device = _find_device(updated, str(operation["device"]))
714
+ if device is None:
715
+ continue
716
+ if operation["op"] == "rename_device":
717
+ _set_device_name(updated, device, str(operation["new_name"]))
718
+ elif operation["op"] == "reflow_layout":
719
+ _set_device_position(device, int(operation["x"]), int(operation["y"]))
720
+
721
+ for bucket, handler in [
722
+ (plan.switch_ops, _apply_switch_op),
723
+ (plan.router_ops, _apply_router_op),
724
+ (plan.server_ops, _apply_server_op),
725
+ (plan.wireless_ops, _apply_wireless_op),
726
+ (plan.end_device_ops, _apply_end_device_op),
727
+ (plan.management_ops, _apply_management_op),
728
+ ]:
729
+ for operation in bucket:
730
+ device = _find_device(updated, str(operation["device"]))
731
+ if device is None:
732
+ continue
733
+ handler(device, operation)
734
+ return updated
735
+
736
+
737
+ def apply_edit_operations(root: ET.Element, plan: IntentPlan) -> ET.Element:
738
+ return apply_plan_operations(root, plan)
739
+
740
+
741
+ def edit_pkt_file(pkt_path: str | Path, plan: IntentPlan, output_path: str | Path, xml_out_path: str | Path | None = None) -> Path:
742
+ root = decode_pkt_to_root(pkt_path)
743
+ updated = apply_plan_operations(root, plan)
744
+ xml_bytes = ET.tostring(updated, encoding="utf-8", xml_declaration=False)
745
+ output_path = Path(output_path)
746
+ output_path.parent.mkdir(parents=True, exist_ok=True)
747
+ output_path.write_bytes(encode_pkt_modern(xml_bytes))
748
+ if xml_out_path is not None:
749
+ xml_path = Path(xml_out_path)
750
+ xml_path.parent.mkdir(parents=True, exist_ok=True)
751
+ xml_path.write_bytes(xml_bytes)
752
+ return output_path