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.
- package/LICENSE +21 -0
- package/LICENSES/LICENSE.Twofish-BSD-3-Clause.txt +29 -0
- package/README.md +687 -0
- package/SKILL.md +221 -0
- package/bin/packet-tracer-skill.js +635 -0
- package/examples/blueprint_minimal.json +46 -0
- package/package.json +42 -0
- package/references/packettracer-sample-catalog.json +21410 -0
- package/references/packettracer-sample-catalog.md +1124 -0
- package/references/pkt-format.md +57 -0
- package/references/xml-skeleton-notes.md +44 -0
- package/requirements-dev.txt +1 -0
- package/requirements.txt +6 -0
- package/scripts/build_sample_catalog.py +65 -0
- package/scripts/donor_diagnostics.py +35 -0
- package/scripts/generate_pkt.py +1264 -0
- package/scripts/install_skill.py +71 -0
- package/scripts/intent_parser.py +712 -0
- package/scripts/packet_tracer_env.py +278 -0
- package/scripts/pkt_builder.py +15 -0
- package/scripts/pkt_codec.py +181 -0
- package/scripts/pkt_editor.py +752 -0
- package/scripts/pkt_transformer.py +541 -0
- package/scripts/sample_catalog.py +385 -0
- package/scripts/sample_selector.py +156 -0
- package/scripts/setup.ps1 +26 -0
- package/scripts/twofish_diagnostics.py +91 -0
- package/scripts/vendor/README.md +46 -0
- package/scripts/vendor/twofish.py +81 -0
- package/scripts/workspace_repair.py +441 -0
- package/templates/pt900/base_empty.xml +21 -0
- package/templates/pt900/device_library/pc.xml +20 -0
- package/templates/pt900/device_library/printer.xml +432 -0
- package/templates/pt900/device_library/router.xml +16 -0
- package/templates/pt900/device_library/switch.xml +38 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Twofish Bridge Setup
|
|
2
|
+
|
|
3
|
+
This repository does not ship a prebuilt Twofish bridge binary by default.
|
|
4
|
+
|
|
5
|
+
The Packet Tracer `.pkt` codec needs a local Twofish bridge at runtime for
|
|
6
|
+
modern Packet Tracer 9.x encode/decode operations.
|
|
7
|
+
|
|
8
|
+
## Supported loading paths
|
|
9
|
+
|
|
10
|
+
The wrapper in `twofish.py` loads the bridge from one of these locations:
|
|
11
|
+
|
|
12
|
+
1. `PKT_TWOFISH_LIBRARY`
|
|
13
|
+
2. a sibling file in this folder named like:
|
|
14
|
+
- `_twofish*.pyd`
|
|
15
|
+
- `_twofish*.so`
|
|
16
|
+
- `_twofish*.dll`
|
|
17
|
+
|
|
18
|
+
## Recommended public-repo workflow
|
|
19
|
+
|
|
20
|
+
- keep this repository free of prebuilt machine-specific binaries
|
|
21
|
+
- keep the bridge local to your machine
|
|
22
|
+
- preferred: place the bridge next to `scripts/vendor/twofish.py` inside the installed skill folder
|
|
23
|
+
- optional: store the bridge elsewhere and point `PKT_TWOFISH_LIBRARY` at that local file
|
|
24
|
+
|
|
25
|
+
Example on Windows:
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
$env:PKT_TWOFISH_LIBRARY="$env:USERPROFILE\.codex\skills\pkt\scripts\vendor\_twofish.cp314-win_amd64.pyd"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Supported runtime
|
|
32
|
+
|
|
33
|
+
- supported Python runtime: `3.14.x`
|
|
34
|
+
- current bridge filename: `_twofish.cp314-win_amd64.pyd`
|
|
35
|
+
- other Python ABIs are not considered supported by this public setup
|
|
36
|
+
|
|
37
|
+
## Security and privacy
|
|
38
|
+
|
|
39
|
+
- do not commit machine-specific binaries unless you have reviewed them
|
|
40
|
+
- do not commit binaries that embed private paths, usernames, or internal build metadata
|
|
41
|
+
- prefer rebuilding or sourcing the bridge in a reproducible way for your own machine
|
|
42
|
+
|
|
43
|
+
## Failure mode
|
|
44
|
+
|
|
45
|
+
If the bridge is missing, the wrapper raises an `ImportError` with setup guidance
|
|
46
|
+
instead of silently loading a repo-shipped binary.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Vendored Twofish ctypes wrapper for the pkt skill.
|
|
3
|
+
|
|
4
|
+
This wrapper loads a local Twofish bridge from either:
|
|
5
|
+
|
|
6
|
+
- `PKT_TWOFISH_LIBRARY`
|
|
7
|
+
- a sibling binary named like `_twofish*.pyd` / `.so` / `.dll`
|
|
8
|
+
|
|
9
|
+
See `scripts/vendor/README.md` for setup guidance.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from ctypes import POINTER, CDLL, Structure, c_char_p, c_int, c_uint32, create_string_buffer, pointer
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_library() -> CDLL:
|
|
20
|
+
here = Path(__file__).resolve().parent
|
|
21
|
+
env_path = os.getenv("PKT_TWOFISH_LIBRARY")
|
|
22
|
+
candidates: list[Path] = []
|
|
23
|
+
if env_path:
|
|
24
|
+
candidate = Path(env_path).expanduser()
|
|
25
|
+
if candidate.exists():
|
|
26
|
+
candidates.append(candidate)
|
|
27
|
+
candidates.extend(sorted(here.glob("_twofish*.pyd")))
|
|
28
|
+
candidates.extend(sorted(here.glob("_twofish*.so")))
|
|
29
|
+
candidates.extend(sorted(here.glob("_twofish*.dll")))
|
|
30
|
+
if not candidates:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"Twofish bridge not found. Set PKT_TWOFISH_LIBRARY or place a local _twofish binary "
|
|
33
|
+
"next to scripts/vendor/twofish.py. See scripts/vendor/README.md."
|
|
34
|
+
)
|
|
35
|
+
return CDLL(str(candidates[0]))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_twofish = _load_library()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _TwofishKey(Structure):
|
|
42
|
+
_fields_ = [("s", (c_uint32 * 4) * 256), ("K", c_uint32 * 40)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_twofish.exp_Twofish_initialise.argtypes = []
|
|
46
|
+
_twofish.exp_Twofish_initialise.restype = None
|
|
47
|
+
_twofish.exp_Twofish_prepare_key.argtypes = [c_char_p, c_int, POINTER(_TwofishKey)]
|
|
48
|
+
_twofish.exp_Twofish_prepare_key.restype = None
|
|
49
|
+
_twofish.exp_Twofish_encrypt.argtypes = [POINTER(_TwofishKey), c_char_p, c_char_p]
|
|
50
|
+
_twofish.exp_Twofish_encrypt.restype = None
|
|
51
|
+
_twofish.exp_Twofish_decrypt.argtypes = [POINTER(_TwofishKey), c_char_p, c_char_p]
|
|
52
|
+
_twofish.exp_Twofish_decrypt.restype = None
|
|
53
|
+
_twofish.exp_Twofish_initialise()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Twofish:
|
|
57
|
+
def __init__(self, key: bytes) -> None:
|
|
58
|
+
if not isinstance(key, bytes):
|
|
59
|
+
raise TypeError("Twofish key must be bytes")
|
|
60
|
+
if not 0 < len(key) <= 32:
|
|
61
|
+
raise ValueError("invalid Twofish key length")
|
|
62
|
+
self._key = _TwofishKey()
|
|
63
|
+
_twofish.exp_Twofish_prepare_key(key, len(key), pointer(self._key))
|
|
64
|
+
|
|
65
|
+
def encrypt(self, block: bytes) -> bytes:
|
|
66
|
+
if not isinstance(block, bytes):
|
|
67
|
+
raise TypeError("block must be bytes")
|
|
68
|
+
if len(block) != 16:
|
|
69
|
+
raise ValueError("Twofish encrypt expects a 16-byte block")
|
|
70
|
+
outbuf = create_string_buffer(16)
|
|
71
|
+
_twofish.exp_Twofish_encrypt(pointer(self._key), block, outbuf)
|
|
72
|
+
return outbuf.raw
|
|
73
|
+
|
|
74
|
+
def decrypt(self, block: bytes) -> bytes:
|
|
75
|
+
if not isinstance(block, bytes):
|
|
76
|
+
raise TypeError("block must be bytes")
|
|
77
|
+
if len(block) != 16:
|
|
78
|
+
raise ValueError("Twofish decrypt expects a 16-byte block")
|
|
79
|
+
outbuf = create_string_buffer(16)
|
|
80
|
+
_twofish.exp_Twofish_decrypt(pointer(self._key), block, outbuf)
|
|
81
|
+
return outbuf.raw
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
|
|
7
|
+
PHYSICAL_HOME_PATH = ["Intercity", "Home City", "Corporate Office", "Main Wiring Closet"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class WorkspaceValidationResult:
|
|
12
|
+
workspace_mode: str
|
|
13
|
+
logical_status: str
|
|
14
|
+
physical_status: str
|
|
15
|
+
blocking_issues: list[str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DonorCoherenceResult:
|
|
20
|
+
device_metadata_status: str
|
|
21
|
+
scenario_status: str
|
|
22
|
+
physical_runtime_status: str
|
|
23
|
+
blocking_issues: list[str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _make_workspace_node(name: str, node_type: int, x_pos: int, y_pos: int, width: int, height: int, path: str = "") -> ET.Element:
|
|
27
|
+
node = ET.Element("NODE")
|
|
28
|
+
for tag, value in [
|
|
29
|
+
("X", str(x_pos)),
|
|
30
|
+
("Y", str(y_pos)),
|
|
31
|
+
("TYPE", str(node_type)),
|
|
32
|
+
]:
|
|
33
|
+
child = ET.SubElement(node, tag)
|
|
34
|
+
child.text = value
|
|
35
|
+
name_node = ET.SubElement(node, "NAME")
|
|
36
|
+
name_node.set("translate", "true")
|
|
37
|
+
name_node.text = name
|
|
38
|
+
for tag, value in [
|
|
39
|
+
("SX", "1"),
|
|
40
|
+
("SY", "1"),
|
|
41
|
+
("W", str(width)),
|
|
42
|
+
("H", str(height)),
|
|
43
|
+
("PATH", path),
|
|
44
|
+
]:
|
|
45
|
+
child = ET.SubElement(node, tag)
|
|
46
|
+
child.text = value
|
|
47
|
+
ET.SubElement(node, "CHILDREN")
|
|
48
|
+
for tag, value in [
|
|
49
|
+
("MANUAL_SCALING", "false"),
|
|
50
|
+
("SCALED_PIXMAP_WIDTH", "-842150451"),
|
|
51
|
+
("SCALED_PIXMAP_HEIGHT", "-842150451"),
|
|
52
|
+
("INIT_WIDTH", str(width)),
|
|
53
|
+
("INIT_HEIGHT", str(height)),
|
|
54
|
+
("INIT_SX", "1"),
|
|
55
|
+
("INIT_SY", "1"),
|
|
56
|
+
]:
|
|
57
|
+
child = ET.SubElement(node, tag)
|
|
58
|
+
child.text = value
|
|
59
|
+
return node
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_minimal_physical_workspace() -> tuple[ET.Element, ET.Element, ET.Element]:
|
|
63
|
+
physical = ET.Element("PHYSICALWORKSPACE")
|
|
64
|
+
homerack = ET.SubElement(physical, "HOMERACK")
|
|
65
|
+
homerack.set("translate", "true")
|
|
66
|
+
homerack.text = ",".join(PHYSICAL_HOME_PATH)
|
|
67
|
+
|
|
68
|
+
intercity = _make_workspace_node("Intercity", 0, 0, 0, 20000, 12416, "../art/Background/gGeoViewInterCity.png")
|
|
69
|
+
city = _make_workspace_node("Home City", 1, 200, 200, 2000, 1238, "../art/Background/gGeoViewCity.png")
|
|
70
|
+
building = _make_workspace_node("Corporate Office", 2, 100, 100, 200, 125, "../art/Background/gGeoViewBuilding.png")
|
|
71
|
+
closet = _make_workspace_node("Main Wiring Closet", 3, 50, 50, 20, 20, "")
|
|
72
|
+
table = _make_workspace_node("Table", 5, 0, 0, 0, 0, "")
|
|
73
|
+
rack = _make_workspace_node("Rack", 4, 0, 0, 0, 0, "")
|
|
74
|
+
|
|
75
|
+
intercity.find("./CHILDREN").append(city)
|
|
76
|
+
city.find("./CHILDREN").append(building)
|
|
77
|
+
building.find("./CHILDREN").append(closet)
|
|
78
|
+
closet.find("./CHILDREN").extend([table, rack])
|
|
79
|
+
physical.append(intercity)
|
|
80
|
+
return physical, table, rack
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _closet_node_from_physical_root(physical_root: ET.Element) -> ET.Element | None:
|
|
84
|
+
path = "./NODE/CHILDREN/NODE/CHILDREN/NODE/CHILDREN/NODE"
|
|
85
|
+
closet = physical_root.find(path)
|
|
86
|
+
if closet is not None and closet.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
|
|
87
|
+
return closet
|
|
88
|
+
for node in physical_root.findall(".//NODE"):
|
|
89
|
+
if node.findtext("./NAME", default="") == PHYSICAL_HOME_PATH[-1]:
|
|
90
|
+
return node
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _container_node(closet: ET.Element, container_name: str, node_type: int) -> ET.Element:
|
|
95
|
+
children = closet.find("./CHILDREN")
|
|
96
|
+
if children is None:
|
|
97
|
+
children = ET.SubElement(closet, "CHILDREN")
|
|
98
|
+
for node in children.findall("./NODE"):
|
|
99
|
+
if node.findtext("./NAME", default="") == container_name:
|
|
100
|
+
target = node.find("./TYPE")
|
|
101
|
+
if target is not None:
|
|
102
|
+
target.text = str(node_type)
|
|
103
|
+
return node
|
|
104
|
+
node = _make_workspace_node(container_name, node_type, 0, 0, 0, 0, "")
|
|
105
|
+
children.append(node)
|
|
106
|
+
return node
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _leaf_template(container: ET.Element, default_name: str) -> ET.Element:
|
|
110
|
+
for node in container.findall("./CHILDREN/NODE"):
|
|
111
|
+
if node.findtext("./TYPE", default="") == "6":
|
|
112
|
+
return copy.deepcopy(node)
|
|
113
|
+
return _make_workspace_node(default_name, 6, 0, 0, 0, 0, "")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _clone_leaf(template: ET.Element, name: str, index: int) -> ET.Element:
|
|
117
|
+
leaf = copy.deepcopy(template)
|
|
118
|
+
for child in list(leaf):
|
|
119
|
+
if child.tag == "CHILDREN":
|
|
120
|
+
child.clear()
|
|
121
|
+
name_node = leaf.find("./NAME")
|
|
122
|
+
if name_node is None:
|
|
123
|
+
name_node = ET.SubElement(leaf, "NAME")
|
|
124
|
+
name_node.set("translate", "true")
|
|
125
|
+
name_node.text = name
|
|
126
|
+
x_node = leaf.find("./X")
|
|
127
|
+
y_node = leaf.find("./Y")
|
|
128
|
+
if x_node is None:
|
|
129
|
+
x_node = ET.SubElement(leaf, "X")
|
|
130
|
+
if y_node is None:
|
|
131
|
+
y_node = ET.SubElement(leaf, "Y")
|
|
132
|
+
x_node.text = str((index % 8) * 8)
|
|
133
|
+
y_node.text = str((index // 8) * 8)
|
|
134
|
+
return leaf
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _physical_leaf_path(device_name: str, container_name: str) -> str:
|
|
138
|
+
return ",".join([*PHYSICAL_HOME_PATH, container_name, device_name])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _device_physical_container(device: ET.Element) -> str:
|
|
142
|
+
device_type = device.findtext("./ENGINE/TYPE", default="")
|
|
143
|
+
if device_type in {"Router", "Switch", "MultiLayerSwitch", "Server", "WirelessRouter", "WirelessLanController", "LightWeightAccessPoint"}:
|
|
144
|
+
return "Rack"
|
|
145
|
+
return "Table"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def sanitize_generated_physical_workspace(root: ET.Element) -> None:
|
|
149
|
+
devices_parent = root.find(".//DEVICES")
|
|
150
|
+
if devices_parent is None:
|
|
151
|
+
return
|
|
152
|
+
version = root.findtext("./VERSION", default="")
|
|
153
|
+
if version.startswith("9.") and any(device.find("./WORKSPACE/PHYSICAL_CPUR") is not None for device in devices_parent.findall("./DEVICE")):
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
physical_root = root.find("./PHYSICALWORKSPACE")
|
|
157
|
+
if physical_root is None:
|
|
158
|
+
physical_root, _, _ = _build_minimal_physical_workspace()
|
|
159
|
+
insert_at = 1 if root.find("./VERSION") is not None else 0
|
|
160
|
+
root.insert(insert_at, physical_root)
|
|
161
|
+
|
|
162
|
+
closet = _closet_node_from_physical_root(physical_root)
|
|
163
|
+
if closet is None:
|
|
164
|
+
replacement_root, _, _ = _build_minimal_physical_workspace()
|
|
165
|
+
existing = root.find("./PHYSICALWORKSPACE")
|
|
166
|
+
if existing is not None:
|
|
167
|
+
root.remove(existing)
|
|
168
|
+
insert_at = 1 if root.find("./VERSION") is not None else 0
|
|
169
|
+
root.insert(insert_at, replacement_root)
|
|
170
|
+
physical_root = replacement_root
|
|
171
|
+
closet = _closet_node_from_physical_root(physical_root)
|
|
172
|
+
if closet is None:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
table = _container_node(closet, "Table", 5)
|
|
176
|
+
rack = _container_node(closet, "Rack", 4)
|
|
177
|
+
table_template = _leaf_template(table, "PC0")
|
|
178
|
+
rack_template = _leaf_template(rack, "Server0")
|
|
179
|
+
table_children = table.find("./CHILDREN")
|
|
180
|
+
rack_children = rack.find("./CHILDREN")
|
|
181
|
+
assert table_children is not None and rack_children is not None
|
|
182
|
+
table_children.clear()
|
|
183
|
+
rack_children.clear()
|
|
184
|
+
|
|
185
|
+
for index, device in enumerate(devices_parent.findall("./DEVICE"), start=1):
|
|
186
|
+
name = device.findtext("./ENGINE/NAME", default=f"Device{index}")
|
|
187
|
+
container_name = _device_physical_container(device)
|
|
188
|
+
template = rack_template if container_name == "Rack" else table_template
|
|
189
|
+
leaf = _clone_leaf(template, name, index - 1)
|
|
190
|
+
if container_name == "Rack":
|
|
191
|
+
rack_children.append(leaf)
|
|
192
|
+
else:
|
|
193
|
+
table_children.append(leaf)
|
|
194
|
+
|
|
195
|
+
workspace = device.find("./WORKSPACE")
|
|
196
|
+
if workspace is None:
|
|
197
|
+
workspace = ET.SubElement(device, "WORKSPACE")
|
|
198
|
+
logical = workspace.find("./LOGICAL")
|
|
199
|
+
logical_copy = ET.fromstring(ET.tostring(logical, encoding="unicode")) if logical is not None else ET.Element("LOGICAL")
|
|
200
|
+
for child in list(workspace):
|
|
201
|
+
workspace.remove(child)
|
|
202
|
+
workspace.append(logical_copy)
|
|
203
|
+
physical = ET.SubElement(workspace, "PHYSICAL")
|
|
204
|
+
physical.set("translate", "true")
|
|
205
|
+
physical.text = _physical_leaf_path(name, container_name)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _collect_physical_paths(root: ET.Element) -> set[str]:
|
|
209
|
+
physical_root = root.find("./PHYSICALWORKSPACE")
|
|
210
|
+
if physical_root is None:
|
|
211
|
+
return set()
|
|
212
|
+
paths: set[str] = set()
|
|
213
|
+
for top in physical_root.findall("./NODE"):
|
|
214
|
+
_walk_physical_node(top, [], paths)
|
|
215
|
+
return paths
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _walk_physical_node(node: ET.Element, ancestors: list[str], sink: set[str]) -> None:
|
|
219
|
+
name = node.findtext("./NAME", default="")
|
|
220
|
+
if not name:
|
|
221
|
+
return
|
|
222
|
+
current = [*ancestors, name]
|
|
223
|
+
sink.add(",".join(current))
|
|
224
|
+
for child in node.findall("./CHILDREN/NODE"):
|
|
225
|
+
_walk_physical_node(child, current, sink)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def inspect_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
|
|
229
|
+
issues: list[str] = []
|
|
230
|
+
if root.tag != "PACKETTRACER5":
|
|
231
|
+
issues.append("Root element must be PACKETTRACER5")
|
|
232
|
+
if root.find("VERSION") is None:
|
|
233
|
+
issues.append("Packet Tracer XML is missing VERSION")
|
|
234
|
+
|
|
235
|
+
devices_parent = root.find(".//DEVICES")
|
|
236
|
+
links_parent = root.find(".//LINKS")
|
|
237
|
+
if devices_parent is None or links_parent is None:
|
|
238
|
+
issues.append("Packet Tracer XML is missing DEVICES or LINKS container")
|
|
239
|
+
return WorkspaceValidationResult("unknown", "invalid", "invalid", issues)
|
|
240
|
+
|
|
241
|
+
devices = devices_parent.findall("./DEVICE")
|
|
242
|
+
links = links_parent.findall("./LINK")
|
|
243
|
+
if not devices:
|
|
244
|
+
issues.append("No devices remain in generated Packet Tracer XML")
|
|
245
|
+
|
|
246
|
+
logical_mem_addrs: list[str] = []
|
|
247
|
+
save_ref_ids: list[str] = []
|
|
248
|
+
for device in devices:
|
|
249
|
+
if device.find("./WORKSPACE/LOGICAL/X") is None or device.find("./WORKSPACE/LOGICAL/Y") is None:
|
|
250
|
+
issues.append("Device is missing logical workspace coordinates")
|
|
251
|
+
mem_addr = device.findtext("./WORKSPACE/LOGICAL/MEM_ADDR", default="")
|
|
252
|
+
if not mem_addr:
|
|
253
|
+
issues.append("Device is missing logical workspace MEM_ADDR")
|
|
254
|
+
else:
|
|
255
|
+
logical_mem_addrs.append(mem_addr)
|
|
256
|
+
save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="")
|
|
257
|
+
if save_ref:
|
|
258
|
+
save_ref_ids.append(save_ref)
|
|
259
|
+
|
|
260
|
+
device_count = len(devices)
|
|
261
|
+
for link in links:
|
|
262
|
+
cable = link.find("./CABLE")
|
|
263
|
+
if cable is None:
|
|
264
|
+
issues.append("Link is missing CABLE node")
|
|
265
|
+
continue
|
|
266
|
+
from_idx = cable.findtext("FROM", default="")
|
|
267
|
+
to_idx = cable.findtext("TO", default="")
|
|
268
|
+
if not from_idx or not to_idx:
|
|
269
|
+
issues.append("Link is missing FROM or TO index")
|
|
270
|
+
continue
|
|
271
|
+
if from_idx.isdigit() and to_idx.isdigit():
|
|
272
|
+
if int(from_idx) >= device_count or int(to_idx) >= device_count:
|
|
273
|
+
issues.append("Link references a device index outside the DEVICES list")
|
|
274
|
+
elif save_ref_ids:
|
|
275
|
+
if from_idx not in save_ref_ids or to_idx not in save_ref_ids:
|
|
276
|
+
issues.append("Link references device SAVE_REF_ID values not present in DEVICES")
|
|
277
|
+
else:
|
|
278
|
+
issues.append("Link FROM or TO index is not numeric")
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
ports = [port.text or "" for port in cable.findall("PORT")]
|
|
282
|
+
if len(ports) < 2 or not ports[0] or not ports[1]:
|
|
283
|
+
issues.append("Link is missing endpoint port names")
|
|
284
|
+
|
|
285
|
+
from_device_mem = cable.findtext("FROM_DEVICE_MEM_ADDR", default="")
|
|
286
|
+
to_device_mem = cable.findtext("TO_DEVICE_MEM_ADDR", default="")
|
|
287
|
+
if not from_device_mem or not to_device_mem:
|
|
288
|
+
issues.append("Link is missing device MEM_ADDR references")
|
|
289
|
+
elif from_device_mem not in logical_mem_addrs or to_device_mem not in logical_mem_addrs:
|
|
290
|
+
issues.append("Link device MEM_ADDR references do not match device workspace records")
|
|
291
|
+
|
|
292
|
+
physical_paths = _collect_physical_paths(root)
|
|
293
|
+
if not physical_paths:
|
|
294
|
+
issues.append("Packet Tracer XML is missing PHYSICALWORKSPACE tree")
|
|
295
|
+
all_physical_values = [device.findtext("./WORKSPACE/PHYSICAL", default="") for device in devices]
|
|
296
|
+
legacy_uuid_physical = any("{" in value and "}" in value for value in all_physical_values if value)
|
|
297
|
+
for device in devices:
|
|
298
|
+
physical = device.findtext("./WORKSPACE/PHYSICAL", default="")
|
|
299
|
+
if not physical:
|
|
300
|
+
issues.append("Device is missing physical workspace path")
|
|
301
|
+
continue
|
|
302
|
+
if not legacy_uuid_physical and physical not in physical_paths:
|
|
303
|
+
issues.append(f"Device physical path does not exist in PHYSICALWORKSPACE: {physical}")
|
|
304
|
+
|
|
305
|
+
if all(device.find("./WORKSPACE/PHYSICAL_CPUR") is None for device in devices):
|
|
306
|
+
workspace_mode = "logical_only_safe"
|
|
307
|
+
elif legacy_uuid_physical:
|
|
308
|
+
workspace_mode = "legacy_uuid_physical"
|
|
309
|
+
else:
|
|
310
|
+
workspace_mode = "mixed_physical"
|
|
311
|
+
logical_status = "ok" if not any("logical" in issue.lower() or "mem_addr" in issue.lower() for issue in issues) else "invalid"
|
|
312
|
+
physical_status = "ok" if not any("physical" in issue.lower() for issue in issues) else "invalid"
|
|
313
|
+
return WorkspaceValidationResult(workspace_mode, logical_status, physical_status, issues)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def validate_workspace_integrity(root: ET.Element) -> WorkspaceValidationResult:
|
|
317
|
+
result = inspect_workspace_integrity(root)
|
|
318
|
+
if result.blocking_issues:
|
|
319
|
+
raise ValueError("; ".join(result.blocking_issues))
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _section_xml(root: ET.Element, path: str) -> str:
|
|
324
|
+
node = root.find(path)
|
|
325
|
+
if node is None:
|
|
326
|
+
return ""
|
|
327
|
+
return ET.tostring(node, encoding="unicode")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _device_by_save_ref(root: ET.Element) -> dict[str, ET.Element]:
|
|
331
|
+
devices: dict[str, ET.Element] = {}
|
|
332
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
333
|
+
save_ref = device.findtext("./ENGINE/SAVE_REF_ID", default="").strip()
|
|
334
|
+
if save_ref:
|
|
335
|
+
devices[save_ref] = device
|
|
336
|
+
return devices
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _device_leaf_uuid(device: ET.Element) -> str:
|
|
340
|
+
physical = device.findtext("./WORKSPACE/PHYSICAL", default="").strip()
|
|
341
|
+
if not physical:
|
|
342
|
+
return ""
|
|
343
|
+
token = physical.split(",")[-1].strip()
|
|
344
|
+
if token.startswith("{") and token.endswith("}"):
|
|
345
|
+
return token
|
|
346
|
+
return ""
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _physical_leaf_index(root: ET.Element) -> dict[str, ET.Element]:
|
|
350
|
+
nodes: dict[str, ET.Element] = {}
|
|
351
|
+
for node in root.findall(".//PHYSICALWORKSPACE//NODE"):
|
|
352
|
+
uuid = node.findtext("./UUID_STR", default="").strip()
|
|
353
|
+
if uuid:
|
|
354
|
+
nodes[uuid] = node
|
|
355
|
+
return nodes
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _config_hostname_hits(device: ET.Element, old_name: str) -> bool:
|
|
359
|
+
target = f"hostname {old_name}"
|
|
360
|
+
for line in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
361
|
+
if (line.text or "").strip() == target:
|
|
362
|
+
return True
|
|
363
|
+
for line in device.findall("./ENGINE/STARTUPCONFIG/LINE"):
|
|
364
|
+
if (line.text or "").strip() == target:
|
|
365
|
+
return True
|
|
366
|
+
for line in device.findall(".//FILE_CONTENT/CONFIG/LINE"):
|
|
367
|
+
if (line.text or "").strip() == target:
|
|
368
|
+
return True
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def inspect_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
|
|
373
|
+
issues: list[str] = []
|
|
374
|
+
donor_devices = _device_by_save_ref(donor_root)
|
|
375
|
+
generated_devices = _device_by_save_ref(generated_root)
|
|
376
|
+
|
|
377
|
+
scenario_sections = {
|
|
378
|
+
"SCENARIOSET": _section_xml(generated_root, "./SCENARIOSET"),
|
|
379
|
+
"COMMAND_LOGS": _section_xml(generated_root, "./COMMAND_LOGS"),
|
|
380
|
+
"CEPS": _section_xml(generated_root, "./CEPS"),
|
|
381
|
+
"FILTERS": _section_xml(generated_root, "./FILTERS"),
|
|
382
|
+
}
|
|
383
|
+
physical_sections = {
|
|
384
|
+
"PHYSICALWORKSPACE": _section_xml(generated_root, "./PHYSICALWORKSPACE"),
|
|
385
|
+
"GEOVIEW_GRAPHICSITEMS": _section_xml(generated_root, "./GEOVIEW_GRAPHICSITEMS"),
|
|
386
|
+
"CLUSTERS": _section_xml(generated_root, "./CLUSTERS"),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
pruned_ids = sorted(set(donor_devices) - set(generated_devices))
|
|
390
|
+
for save_ref in pruned_ids:
|
|
391
|
+
donor_device = donor_devices[save_ref]
|
|
392
|
+
donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
|
|
393
|
+
original_uuid = donor_device.findtext(".//ORIGINAL_DEVICE_UUID", default="").strip()
|
|
394
|
+
physical_uuid = _device_leaf_uuid(donor_device)
|
|
395
|
+
for section_name, text in scenario_sections.items():
|
|
396
|
+
for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid)]:
|
|
397
|
+
if value and value in text:
|
|
398
|
+
issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
|
|
399
|
+
for section_name, text in physical_sections.items():
|
|
400
|
+
for label, value in [("save-ref-id", save_ref), ("device-name", donor_name), ("original-uuid", original_uuid), ("physical-leaf", physical_uuid)]:
|
|
401
|
+
if value and value in text:
|
|
402
|
+
issues.append(f"Pruned device {donor_name} still appears in {section_name} via {label}")
|
|
403
|
+
|
|
404
|
+
physical_index = _physical_leaf_index(generated_root)
|
|
405
|
+
for save_ref in sorted(set(donor_devices) & set(generated_devices)):
|
|
406
|
+
donor_device = donor_devices[save_ref]
|
|
407
|
+
generated_device = generated_devices[save_ref]
|
|
408
|
+
donor_name = donor_device.findtext("./ENGINE/NAME", default="").strip()
|
|
409
|
+
generated_name = generated_device.findtext("./ENGINE/NAME", default="").strip()
|
|
410
|
+
sys_name = generated_device.findtext("./ENGINE/SYS_NAME", default="").strip()
|
|
411
|
+
if donor_name != generated_name and sys_name == donor_name:
|
|
412
|
+
issues.append(f"Renamed device {generated_name} still keeps donor SYS_NAME {donor_name}")
|
|
413
|
+
if donor_name != generated_name and _config_hostname_hits(generated_device, donor_name):
|
|
414
|
+
issues.append(f"Renamed device {generated_name} still keeps donor hostname {donor_name} in config")
|
|
415
|
+
leaf_uuid = _device_leaf_uuid(generated_device)
|
|
416
|
+
if leaf_uuid:
|
|
417
|
+
leaf = physical_index.get(leaf_uuid)
|
|
418
|
+
if leaf is None:
|
|
419
|
+
issues.append(f"Generated device {generated_name} references missing physical leaf {leaf_uuid}")
|
|
420
|
+
else:
|
|
421
|
+
leaf_name = leaf.findtext("./NAME", default="").strip()
|
|
422
|
+
if leaf_name != generated_name:
|
|
423
|
+
issues.append(f"Generated device {generated_name} physical leaf name is {leaf_name}")
|
|
424
|
+
|
|
425
|
+
device_issues = [issue for issue in issues if "SYS_NAME" in issue or "hostname" in issue]
|
|
426
|
+
scenario_issues = [issue for issue in issues if any(section in issue for section in ["SCENARIOSET", "COMMAND_LOGS", "CEPS", "FILTERS"])]
|
|
427
|
+
physical_issues = [issue for issue in issues if any(section in issue for section in ["PHYSICALWORKSPACE", "GEOVIEW_GRAPHICSITEMS", "CLUSTERS", "physical leaf"])]
|
|
428
|
+
|
|
429
|
+
return DonorCoherenceResult(
|
|
430
|
+
device_metadata_status="ok" if not device_issues else "invalid",
|
|
431
|
+
scenario_status="ok" if not scenario_issues else "invalid",
|
|
432
|
+
physical_runtime_status="ok" if not physical_issues else "invalid",
|
|
433
|
+
blocking_issues=issues,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def validate_donor_coherence(donor_root: ET.Element, generated_root: ET.Element) -> DonorCoherenceResult:
|
|
438
|
+
result = inspect_donor_coherence(donor_root, generated_root)
|
|
439
|
+
if result.blocking_issues:
|
|
440
|
+
raise ValueError("; ".join(result.blocking_issues))
|
|
441
|
+
return result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<PACKETTRACER5>
|
|
3
|
+
<VERSION>9.0.0.0810</VERSION>
|
|
4
|
+
<PIXMAPBANK />
|
|
5
|
+
<MOVIEBANK />
|
|
6
|
+
<NETWORK>
|
|
7
|
+
<DEVICES />
|
|
8
|
+
<LINKS />
|
|
9
|
+
<SHAPETESTS />
|
|
10
|
+
</NETWORK>
|
|
11
|
+
<OPTIONS>
|
|
12
|
+
<LOGICALVIEW>true</LOGICALVIEW>
|
|
13
|
+
</OPTIONS>
|
|
14
|
+
<PHYSICALWORKSPACE>
|
|
15
|
+
<ROOT>
|
|
16
|
+
<NAME>Physical Workspace</NAME>
|
|
17
|
+
<X>0</X>
|
|
18
|
+
<Y>0</Y>
|
|
19
|
+
</ROOT>
|
|
20
|
+
</PHYSICALWORKSPACE>
|
|
21
|
+
</PACKETTRACER5>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<DEVICE class="end-device">
|
|
2
|
+
<ID>__DEVICE_ID__</ID>
|
|
3
|
+
<ENGINE>
|
|
4
|
+
<TYPE model="PC-PT" customModel="">PC</TYPE>
|
|
5
|
+
<NAME translate="true">__NAME__</NAME>
|
|
6
|
+
</ENGINE>
|
|
7
|
+
<POSITION>
|
|
8
|
+
<X>__X__</X>
|
|
9
|
+
<Y>__Y__</Y>
|
|
10
|
+
</POSITION>
|
|
11
|
+
<PORTS>
|
|
12
|
+
<PORT name="FastEthernet0" kind="eCopperFastEthernet" />
|
|
13
|
+
</PORTS>
|
|
14
|
+
<CONFIG>
|
|
15
|
+
<IP>__IP__</IP>
|
|
16
|
+
<MASK>__MASK__</MASK>
|
|
17
|
+
<GATEWAY>__GATEWAY__</GATEWAY>
|
|
18
|
+
<DNS>__DNS__</DNS>
|
|
19
|
+
</CONFIG>
|
|
20
|
+
</DEVICE>
|