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,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
|
|
10
|
+
from packet_tracer_env import get_packet_tracer_saves_root
|
|
11
|
+
from pkt_codec import decode_pkt_modern
|
|
12
|
+
|
|
13
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
14
|
+
SKILL_ROOT = SCRIPT_DIR.parent
|
|
15
|
+
DEFAULT_CATALOG_JSON = SKILL_ROOT / "references" / "packettracer-sample-catalog.json"
|
|
16
|
+
DEFAULT_CATALOG_MD = SKILL_ROOT / "references" / "packettracer-sample-catalog.md"
|
|
17
|
+
|
|
18
|
+
CAPABILITY_KEYWORDS = {
|
|
19
|
+
"vlan": ["vlan", "trunk", "access"],
|
|
20
|
+
"trunk": ["trunk"],
|
|
21
|
+
"access_port": ["access"],
|
|
22
|
+
"dhcp_pool": ["dhcp", "reservation", "apipa"],
|
|
23
|
+
"router_dhcp": ["dhcp", "reservation", "apipa"],
|
|
24
|
+
"server_dhcp": ["dhcp", "reservation", "apipa"],
|
|
25
|
+
"dhcp_snooping": ["dhcp snooping", "option_82", "trusted_untrusted"],
|
|
26
|
+
"ospf": ["ospf"],
|
|
27
|
+
"eigrp": ["eigrp"],
|
|
28
|
+
"rip": ["rip"],
|
|
29
|
+
"nat": ["nat"],
|
|
30
|
+
"acl": ["acl", "access-list"],
|
|
31
|
+
"vpn": ["vpn", "ipsec", "gre"],
|
|
32
|
+
"wireless": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep", "5g", "bluetooth", "cellular"],
|
|
33
|
+
"wireless_ap": ["wireless", "wlan", "wlc", "wifi", "ssid", "wpa", "wep"],
|
|
34
|
+
"wireless_client": ["wireless", "wifi", "tablet", "smartphone", "laptop"],
|
|
35
|
+
"ntp": ["ntp"],
|
|
36
|
+
"dns": ["dns"],
|
|
37
|
+
"server_dns": ["dns"],
|
|
38
|
+
"server_http": ["http", "https", "websocket"],
|
|
39
|
+
"server_ftp": ["ftp", "tftp"],
|
|
40
|
+
"ftp_http_https": ["ftp", "http", "https", "websocket"],
|
|
41
|
+
"iot": ["iot", "sensor", "led", "arduino", "mqtt", "environment"],
|
|
42
|
+
"switching": ["switch", "switching", "lldp", "rep"],
|
|
43
|
+
"router_on_a_stick": ["router-on-a-stick", "dot1q"],
|
|
44
|
+
"host_server": ["server", "pc", "client", "host"],
|
|
45
|
+
"management_vlan": ["management", "vlan99", "telnet"],
|
|
46
|
+
"telnet": ["telnet", "terminal server"],
|
|
47
|
+
"tablet": ["tablet", "pda"],
|
|
48
|
+
"printer": ["printer"],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
TYPE_NORMALIZATION = {
|
|
52
|
+
"pc": "PC",
|
|
53
|
+
"pc-pt": "PC",
|
|
54
|
+
"server": "Server",
|
|
55
|
+
"server-pt": "Server",
|
|
56
|
+
"router": "Router",
|
|
57
|
+
"switch": "Switch",
|
|
58
|
+
"multilayerswitch": "Switch",
|
|
59
|
+
"wirelessrouter": "WirelessRouter",
|
|
60
|
+
"lightweightaccesspoint": "LightWeightAccessPoint",
|
|
61
|
+
"accesspoint": "LightWeightAccessPoint",
|
|
62
|
+
"pda": "Tablet",
|
|
63
|
+
"tabletpc": "Tablet",
|
|
64
|
+
"tablet": "Tablet",
|
|
65
|
+
"laptop": "Laptop",
|
|
66
|
+
"printer": "Printer",
|
|
67
|
+
"smartphone": "Smartphone",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class SampleDescriptor:
|
|
73
|
+
path: str
|
|
74
|
+
relative_path: str
|
|
75
|
+
version: str
|
|
76
|
+
device_count: int
|
|
77
|
+
link_count: int
|
|
78
|
+
devices: list[dict[str, Any]]
|
|
79
|
+
links: list[dict[str, Any]]
|
|
80
|
+
capability_tags: list[str]
|
|
81
|
+
topology_tags: list[str]
|
|
82
|
+
preferred_roles: list[str]
|
|
83
|
+
trust_level: str = "trusted"
|
|
84
|
+
origin: str = "cisco-local"
|
|
85
|
+
role: str = "primary"
|
|
86
|
+
prototype_eligible: bool = True
|
|
87
|
+
|
|
88
|
+
def normalized_device_counts(self) -> dict[str, int]:
|
|
89
|
+
counts: dict[str, int] = {}
|
|
90
|
+
for device in self.devices:
|
|
91
|
+
normalized = normalize_device_type(device.get("type", ""))
|
|
92
|
+
if normalized:
|
|
93
|
+
counts[normalized] = counts.get(normalized, 0) + 1
|
|
94
|
+
return counts
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class SampleCandidate:
|
|
99
|
+
sample: SampleDescriptor
|
|
100
|
+
capability_score: int
|
|
101
|
+
topology_score: int
|
|
102
|
+
total_score: int
|
|
103
|
+
reasons: list[str]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class ReferencePattern:
|
|
108
|
+
relative_path: str
|
|
109
|
+
origin: str
|
|
110
|
+
capability_tags: list[str]
|
|
111
|
+
topology_tags: list[str]
|
|
112
|
+
device_summary: dict[str, int]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def normalize_device_type(raw_type: str) -> str:
|
|
116
|
+
key = raw_type.strip().lower().replace(" ", "")
|
|
117
|
+
return TYPE_NORMALIZATION.get(key, raw_type.strip())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalized_counts_for_item(item: dict[str, Any]) -> dict[str, int]:
|
|
121
|
+
counts: dict[str, int] = {}
|
|
122
|
+
for device in item.get("devices", []):
|
|
123
|
+
normalized = normalize_device_type(device.get("type", ""))
|
|
124
|
+
if normalized:
|
|
125
|
+
counts[normalized] = counts.get(normalized, 0) + 1
|
|
126
|
+
return counts
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def infer_capability_tags(item: dict[str, Any]) -> list[str]:
|
|
130
|
+
tags: set[str] = set()
|
|
131
|
+
rel = item.get("relative_path", "").lower().replace("\\", "/")
|
|
132
|
+
rel_flat = rel.replace("/", " ")
|
|
133
|
+
devices = item.get("devices", [])
|
|
134
|
+
normalized_types = [normalize_device_type(device.get("type", "")) for device in devices]
|
|
135
|
+
router_count = normalized_types.count("Router")
|
|
136
|
+
switch_count = normalized_types.count("Switch")
|
|
137
|
+
host_count = sum(1 for dtype in normalized_types if dtype in {"PC", "Server"})
|
|
138
|
+
|
|
139
|
+
for tag, words in CAPABILITY_KEYWORDS.items():
|
|
140
|
+
if any(word in rel or word in rel_flat for word in words):
|
|
141
|
+
tags.add(tag)
|
|
142
|
+
if router_count >= 2:
|
|
143
|
+
tags.add("multi_router")
|
|
144
|
+
if switch_count >= 1:
|
|
145
|
+
tags.add("switching")
|
|
146
|
+
if host_count >= 1:
|
|
147
|
+
tags.add("host_server")
|
|
148
|
+
if any(dtype in {"WirelessRouter", "LightWeightAccessPoint"} for dtype in normalized_types):
|
|
149
|
+
tags.add("wireless")
|
|
150
|
+
tags.add("wireless_ap")
|
|
151
|
+
if any(dtype in {"Tablet", "Laptop", "Smartphone"} for dtype in normalized_types):
|
|
152
|
+
tags.add("wireless_client")
|
|
153
|
+
if any(dtype == "Tablet" for dtype in normalized_types):
|
|
154
|
+
tags.add("tablet")
|
|
155
|
+
if any(dtype == "Printer" for dtype in normalized_types):
|
|
156
|
+
tags.add("printer")
|
|
157
|
+
if "telnet" in rel or "terminal server" in rel_flat:
|
|
158
|
+
tags.add("telnet")
|
|
159
|
+
if "management" in rel_flat:
|
|
160
|
+
tags.add("management_vlan")
|
|
161
|
+
return sorted(tags or {"switching"})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def infer_topology_tags(item: dict[str, Any]) -> list[str]:
|
|
165
|
+
tags: set[str] = set()
|
|
166
|
+
rel = item.get("relative_path", "").lower().replace("\\", "/")
|
|
167
|
+
counts = _normalized_counts_for_item(item)
|
|
168
|
+
link_count = int(item.get("link_count", 0))
|
|
169
|
+
switch_count = counts.get("Switch", 0)
|
|
170
|
+
router_count = counts.get("Router", 0)
|
|
171
|
+
wireless_count = counts.get("LightWeightAccessPoint", 0) + counts.get("WirelessRouter", 0)
|
|
172
|
+
server_count = counts.get("Server", 0)
|
|
173
|
+
|
|
174
|
+
if "router_on_a_stick" in item.get("capability_tags", []):
|
|
175
|
+
tags.add("router_on_a_stick")
|
|
176
|
+
if "acl" in item.get("capability_tags", []):
|
|
177
|
+
tags.add("acl_policy")
|
|
178
|
+
if wireless_count:
|
|
179
|
+
tags.add("wireless_edge")
|
|
180
|
+
if server_count:
|
|
181
|
+
tags.add("server_services")
|
|
182
|
+
if switch_count >= 3 and router_count >= 1:
|
|
183
|
+
tags.add("department_lan")
|
|
184
|
+
if switch_count >= 2 and router_count >= 1:
|
|
185
|
+
tags.add("core_access")
|
|
186
|
+
if switch_count >= 2 and link_count >= switch_count:
|
|
187
|
+
tags.add("chain")
|
|
188
|
+
if router_count == 1 and switch_count <= 1 and wireless_count:
|
|
189
|
+
tags.add("small_office")
|
|
190
|
+
if "campus" in rel or "department" in rel or "vlan" in rel:
|
|
191
|
+
tags.add("department_lan")
|
|
192
|
+
return sorted(tags or {"general"})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def infer_preferred_roles(item: dict[str, Any]) -> list[str]:
|
|
196
|
+
roles: list[str] = []
|
|
197
|
+
tags = set(item.get("capability_tags", []))
|
|
198
|
+
if "vlan" in tags:
|
|
199
|
+
roles.append("preferred_vlan")
|
|
200
|
+
if "dhcp_pool" in tags:
|
|
201
|
+
roles.append("preferred_dhcp")
|
|
202
|
+
if {"ospf", "eigrp", "rip"} & tags:
|
|
203
|
+
roles.append("preferred_routing")
|
|
204
|
+
if {"nat", "acl", "vpn"} & tags:
|
|
205
|
+
roles.append("preferred_security")
|
|
206
|
+
if "wireless" in tags:
|
|
207
|
+
roles.append("preferred_wireless")
|
|
208
|
+
if "telnet" in tags or "management_vlan" in tags:
|
|
209
|
+
roles.append("preferred_management")
|
|
210
|
+
if "server_dns" in tags or "server_dhcp" in tags:
|
|
211
|
+
roles.append("preferred_server")
|
|
212
|
+
if "iot" in tags:
|
|
213
|
+
roles.append("preferred_iot")
|
|
214
|
+
if not roles:
|
|
215
|
+
roles.append("preferred_general")
|
|
216
|
+
return roles
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def enrich_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
220
|
+
enriched: list[dict[str, Any]] = []
|
|
221
|
+
for item in items:
|
|
222
|
+
if "error" in item:
|
|
223
|
+
enriched.append(item)
|
|
224
|
+
continue
|
|
225
|
+
new_item = dict(item)
|
|
226
|
+
new_item["capability_tags"] = infer_capability_tags(new_item)
|
|
227
|
+
new_item["topology_tags"] = infer_topology_tags(new_item)
|
|
228
|
+
new_item["preferred_roles"] = infer_preferred_roles(new_item)
|
|
229
|
+
new_item.setdefault("trust_level", "trusted")
|
|
230
|
+
new_item.setdefault("origin", "cisco-local")
|
|
231
|
+
new_item.setdefault("role", "primary")
|
|
232
|
+
new_item.setdefault("prototype_eligible", True)
|
|
233
|
+
enriched.append(new_item)
|
|
234
|
+
return enriched
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@lru_cache(maxsize=8)
|
|
238
|
+
def _load_catalog_cached(path_str: str) -> tuple[SampleDescriptor, ...]:
|
|
239
|
+
raw_items = json.loads(Path(path_str).read_text(encoding="utf-8"))
|
|
240
|
+
items = enrich_catalog_items(raw_items)
|
|
241
|
+
saves_root = get_packet_tracer_saves_root()
|
|
242
|
+
return tuple(
|
|
243
|
+
SampleDescriptor(
|
|
244
|
+
path=str((saves_root / item["relative_path"]) if saves_root is not None else item.get("path", item["relative_path"])),
|
|
245
|
+
relative_path=item["relative_path"],
|
|
246
|
+
version=item.get("version", ""),
|
|
247
|
+
device_count=item.get("device_count", 0),
|
|
248
|
+
link_count=item.get("link_count", 0),
|
|
249
|
+
devices=item.get("devices", []),
|
|
250
|
+
links=item.get("links", []),
|
|
251
|
+
capability_tags=item.get("capability_tags", []),
|
|
252
|
+
topology_tags=item.get("topology_tags", []),
|
|
253
|
+
preferred_roles=item.get("preferred_roles", []),
|
|
254
|
+
trust_level=item.get("trust_level", "trusted"),
|
|
255
|
+
origin=item.get("origin", "cisco-local"),
|
|
256
|
+
role=item.get("role", "primary"),
|
|
257
|
+
prototype_eligible=bool(item.get("prototype_eligible", True)),
|
|
258
|
+
)
|
|
259
|
+
for item in items
|
|
260
|
+
if "error" not in item
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load_catalog(path: Path | None = None) -> list[SampleDescriptor]:
|
|
265
|
+
return list(_load_catalog_cached(str(path or DEFAULT_CATALOG_JSON)))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _summarize_pkt(path: Path, relative_path: str, origin: str, prototype_eligible: bool) -> dict[str, Any]:
|
|
269
|
+
xml = decode_pkt_modern(path.read_bytes())
|
|
270
|
+
root = ET.fromstring(xml)
|
|
271
|
+
devices = []
|
|
272
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
273
|
+
type_node = device.find("./ENGINE/TYPE")
|
|
274
|
+
devices.append(
|
|
275
|
+
{
|
|
276
|
+
"name": device.findtext("./ENGINE/NAME", default=""),
|
|
277
|
+
"type": device.findtext("./ENGINE/TYPE", default=""),
|
|
278
|
+
"model": type_node.get("model", "") if type_node is not None else "",
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
links = []
|
|
282
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
283
|
+
cable = link.find("./CABLE")
|
|
284
|
+
ports = cable.findall("PORT") if cable is not None else []
|
|
285
|
+
links.append(
|
|
286
|
+
{
|
|
287
|
+
"type": link.findtext("./TYPE", default=""),
|
|
288
|
+
"cable_type": cable.findtext("./TYPE", default="") if cable is not None else "",
|
|
289
|
+
"ports": [port.text or "" for port in ports[:2]],
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
return {
|
|
293
|
+
"path": str(path),
|
|
294
|
+
"relative_path": relative_path,
|
|
295
|
+
"version": root.findtext("./VERSION", default=""),
|
|
296
|
+
"device_count": len(devices),
|
|
297
|
+
"link_count": len(links),
|
|
298
|
+
"devices": devices,
|
|
299
|
+
"links": links,
|
|
300
|
+
"origin": origin,
|
|
301
|
+
"trust_level": "reference-only" if origin != "cisco-local" else "trusted",
|
|
302
|
+
"role": "reference" if origin != "cisco-local" else "primary",
|
|
303
|
+
"prototype_eligible": prototype_eligible,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@lru_cache(maxsize=4)
|
|
308
|
+
def _load_reference_catalog_cached(roots_key: tuple[str, ...]) -> tuple[SampleDescriptor, ...]:
|
|
309
|
+
items: list[dict[str, Any]] = []
|
|
310
|
+
for root_str in roots_key:
|
|
311
|
+
root = Path(root_str)
|
|
312
|
+
if not root.exists():
|
|
313
|
+
continue
|
|
314
|
+
for pkt_path in sorted(root.rglob("*.pkt")):
|
|
315
|
+
try:
|
|
316
|
+
rel = str(pkt_path.relative_to(root))
|
|
317
|
+
items.append(_summarize_pkt(pkt_path, rel, "external-reference", False))
|
|
318
|
+
except Exception:
|
|
319
|
+
continue
|
|
320
|
+
enriched = enrich_catalog_items(items)
|
|
321
|
+
return tuple(
|
|
322
|
+
SampleDescriptor(
|
|
323
|
+
path=item["path"],
|
|
324
|
+
relative_path=item["relative_path"],
|
|
325
|
+
version=item.get("version", ""),
|
|
326
|
+
device_count=item.get("device_count", 0),
|
|
327
|
+
link_count=item.get("link_count", 0),
|
|
328
|
+
devices=item.get("devices", []),
|
|
329
|
+
links=item.get("links", []),
|
|
330
|
+
capability_tags=item.get("capability_tags", []),
|
|
331
|
+
topology_tags=item.get("topology_tags", []),
|
|
332
|
+
preferred_roles=item.get("preferred_roles", []),
|
|
333
|
+
trust_level=item.get("trust_level", "reference-only"),
|
|
334
|
+
origin=item.get("origin", "external-reference"),
|
|
335
|
+
role=item.get("role", "reference"),
|
|
336
|
+
prototype_eligible=bool(item.get("prototype_eligible", False)),
|
|
337
|
+
)
|
|
338
|
+
for item in enriched
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def load_reference_catalog(reference_roots: list[Path] | None = None) -> list[SampleDescriptor]:
|
|
343
|
+
if not reference_roots:
|
|
344
|
+
return []
|
|
345
|
+
roots_key = tuple(sorted(str(path) for path in reference_roots))
|
|
346
|
+
return list(_load_reference_catalog_cached(roots_key))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def extract_reference_patterns(samples: list[SampleDescriptor]) -> list[ReferencePattern]:
|
|
350
|
+
patterns: list[ReferencePattern] = []
|
|
351
|
+
for sample in samples:
|
|
352
|
+
patterns.append(
|
|
353
|
+
ReferencePattern(
|
|
354
|
+
relative_path=sample.relative_path,
|
|
355
|
+
origin=sample.origin,
|
|
356
|
+
capability_tags=sample.capability_tags,
|
|
357
|
+
topology_tags=sample.topology_tags,
|
|
358
|
+
device_summary=sample.normalized_device_counts(),
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
return patterns
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def write_catalog_outputs(items: list[dict[str, Any]], json_path: Path | None = None, md_path: Path | None = None) -> None:
|
|
365
|
+
enriched = enrich_catalog_items(items)
|
|
366
|
+
compact_items: list[dict[str, Any]] = []
|
|
367
|
+
for item in enriched:
|
|
368
|
+
saved = dict(item)
|
|
369
|
+
saved.pop("path", None)
|
|
370
|
+
compact_items.append(saved)
|
|
371
|
+
(json_path or DEFAULT_CATALOG_JSON).write_text(json.dumps(compact_items, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
372
|
+
root_label = "<PACKET_TRACER_SAVES_ROOT>"
|
|
373
|
+
lines = ["# Packet Tracer Installed Sample Catalog", "", f"Source root: `{root_label}`", ""]
|
|
374
|
+
for item in enriched:
|
|
375
|
+
if "error" in item:
|
|
376
|
+
lines.append(f"- `{item['relative_path']}`")
|
|
377
|
+
lines.append(f" decode error: `{item['error']}`")
|
|
378
|
+
continue
|
|
379
|
+
labels = ", ".join(f"{device['name']} ({device['type']}/{device['model']})" for device in item["devices"])
|
|
380
|
+
lines.append(f"- `{item['relative_path']}`")
|
|
381
|
+
lines.append(f" version: `{item['version']}`, devices: `{item['device_count']}`, links: `{item['link_count']}`")
|
|
382
|
+
lines.append(f" tags: {', '.join(item.get('capability_tags', []))}")
|
|
383
|
+
lines.append(f" topology: {', '.join(item.get('topology_tags', []))}")
|
|
384
|
+
lines.append(f" devices: {labels}")
|
|
385
|
+
(md_path or DEFAULT_CATALOG_MD).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sample_catalog import SampleCandidate, SampleDescriptor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _device_fit_score(sample: SampleDescriptor, device_requirements: dict[str, int]) -> tuple[int, list[str]]:
|
|
7
|
+
score = 0
|
|
8
|
+
reasons: list[str] = []
|
|
9
|
+
counts = sample.normalized_device_counts()
|
|
10
|
+
for device_type, needed in device_requirements.items():
|
|
11
|
+
available = counts.get(device_type, 0)
|
|
12
|
+
if available >= needed:
|
|
13
|
+
score += 5 + needed
|
|
14
|
+
reasons.append(f"device:{device_type}")
|
|
15
|
+
elif available > 0:
|
|
16
|
+
score += available
|
|
17
|
+
reasons.append(f"partial-device:{device_type}")
|
|
18
|
+
return score, reasons
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _score_sample(
|
|
22
|
+
sample: SampleDescriptor,
|
|
23
|
+
capabilities: list[str],
|
|
24
|
+
device_requirements: dict[str, int],
|
|
25
|
+
topology_tags: list[str] | None = None,
|
|
26
|
+
) -> SampleCandidate:
|
|
27
|
+
score = 0
|
|
28
|
+
topology_score = 0
|
|
29
|
+
reasons: list[str] = []
|
|
30
|
+
tags = set(sample.capability_tags)
|
|
31
|
+
for capability in capabilities:
|
|
32
|
+
if capability in tags:
|
|
33
|
+
score += 10
|
|
34
|
+
reasons.append(f"capability:{capability}")
|
|
35
|
+
if any(role in sample.preferred_roles for role in ["preferred_wireless", "preferred_management", "preferred_server"]):
|
|
36
|
+
score += 4
|
|
37
|
+
reasons.append("preferred-role")
|
|
38
|
+
device_score, device_reasons = _device_fit_score(sample, device_requirements)
|
|
39
|
+
score += device_score
|
|
40
|
+
reasons.extend(device_reasons)
|
|
41
|
+
counts = sample.normalized_device_counts()
|
|
42
|
+
if sample.version.startswith("9."):
|
|
43
|
+
score += 2
|
|
44
|
+
reasons.append("version:9.x")
|
|
45
|
+
score += min(sample.link_count, 10)
|
|
46
|
+
if sample.origin == "cisco-local":
|
|
47
|
+
score += 20
|
|
48
|
+
reasons.append("origin:cisco-local")
|
|
49
|
+
if sample.prototype_eligible:
|
|
50
|
+
score += 10
|
|
51
|
+
reasons.append("prototype-eligible")
|
|
52
|
+
else:
|
|
53
|
+
score -= 50
|
|
54
|
+
wanted_topology = set(topology_tags or [])
|
|
55
|
+
available_topology = set(sample.topology_tags)
|
|
56
|
+
for tag in wanted_topology:
|
|
57
|
+
if tag in available_topology:
|
|
58
|
+
topology_score += 12
|
|
59
|
+
reasons.append(f"topology:{tag}")
|
|
60
|
+
total_score = score + topology_score
|
|
61
|
+
return SampleCandidate(
|
|
62
|
+
sample=sample,
|
|
63
|
+
capability_score=score,
|
|
64
|
+
topology_score=topology_score,
|
|
65
|
+
total_score=total_score,
|
|
66
|
+
reasons=reasons,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _score_reference_sample(
|
|
71
|
+
sample: SampleDescriptor,
|
|
72
|
+
capabilities: list[str],
|
|
73
|
+
device_requirements: dict[str, int],
|
|
74
|
+
topology_tags: list[str] | None = None,
|
|
75
|
+
) -> SampleCandidate:
|
|
76
|
+
score = 0
|
|
77
|
+
topology_score = 0
|
|
78
|
+
reasons: list[str] = []
|
|
79
|
+
tags = set(sample.capability_tags)
|
|
80
|
+
for capability in capabilities:
|
|
81
|
+
if capability in tags:
|
|
82
|
+
score += 8
|
|
83
|
+
reasons.append(f"capability:{capability}")
|
|
84
|
+
device_score, device_reasons = _device_fit_score(sample, device_requirements)
|
|
85
|
+
score += device_score
|
|
86
|
+
reasons.extend(device_reasons)
|
|
87
|
+
wanted_topology = set(topology_tags or [])
|
|
88
|
+
available_topology = set(sample.topology_tags)
|
|
89
|
+
for tag in wanted_topology:
|
|
90
|
+
if tag in available_topology:
|
|
91
|
+
topology_score += 14
|
|
92
|
+
reasons.append(f"topology:{tag}")
|
|
93
|
+
if sample.origin == "external-reference":
|
|
94
|
+
score += 6
|
|
95
|
+
reasons.append("origin:external-reference")
|
|
96
|
+
if not sample.prototype_eligible:
|
|
97
|
+
reasons.append("reference-only")
|
|
98
|
+
total_score = score + topology_score
|
|
99
|
+
return SampleCandidate(
|
|
100
|
+
sample=sample,
|
|
101
|
+
capability_score=score,
|
|
102
|
+
topology_score=topology_score,
|
|
103
|
+
total_score=total_score,
|
|
104
|
+
reasons=reasons,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def rank_samples(
|
|
109
|
+
samples: list[SampleDescriptor],
|
|
110
|
+
capabilities: list[str],
|
|
111
|
+
device_requirements: dict[str, int],
|
|
112
|
+
topology_tags: list[str] | None = None,
|
|
113
|
+
prototype_only: bool = True,
|
|
114
|
+
) -> list[SampleCandidate]:
|
|
115
|
+
filtered: list[SampleDescriptor] = []
|
|
116
|
+
for sample in samples:
|
|
117
|
+
if prototype_only and not sample.prototype_eligible:
|
|
118
|
+
continue
|
|
119
|
+
counts = sample.normalized_device_counts()
|
|
120
|
+
if all(counts.get(device_type, 0) >= needed for device_type, needed in device_requirements.items()):
|
|
121
|
+
filtered.append(sample)
|
|
122
|
+
candidates = filtered or samples
|
|
123
|
+
ranked = sorted(
|
|
124
|
+
(_score_sample(sample, capabilities, device_requirements, topology_tags) for sample in candidates if (sample.prototype_eligible or not prototype_only)),
|
|
125
|
+
key=lambda candidate: (candidate.total_score, candidate.sample.device_count, candidate.sample.link_count),
|
|
126
|
+
reverse=True,
|
|
127
|
+
)
|
|
128
|
+
return ranked
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def rank_reference_samples(
|
|
132
|
+
samples: list[SampleDescriptor],
|
|
133
|
+
capabilities: list[str],
|
|
134
|
+
device_requirements: dict[str, int],
|
|
135
|
+
topology_tags: list[str] | None = None,
|
|
136
|
+
) -> list[SampleCandidate]:
|
|
137
|
+
external_only = [sample for sample in samples if sample.origin == "external-reference"]
|
|
138
|
+
candidates = external_only or samples
|
|
139
|
+
ranked = sorted(
|
|
140
|
+
(_score_reference_sample(sample, capabilities, device_requirements, topology_tags) for sample in candidates),
|
|
141
|
+
key=lambda candidate: (candidate.total_score, candidate.sample.device_count, candidate.sample.link_count),
|
|
142
|
+
reverse=True,
|
|
143
|
+
)
|
|
144
|
+
return ranked
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def select_best_sample(
|
|
148
|
+
samples: list[SampleDescriptor],
|
|
149
|
+
capabilities: list[str],
|
|
150
|
+
device_requirements: dict[str, int],
|
|
151
|
+
topology_tags: list[str] | None = None,
|
|
152
|
+
) -> SampleDescriptor:
|
|
153
|
+
ranked = rank_samples(samples, capabilities, device_requirements, topology_tags=topology_tags, prototype_only=True)
|
|
154
|
+
if not ranked:
|
|
155
|
+
raise ValueError("No Packet Tracer sample is available for selection")
|
|
156
|
+
return ranked[0].sample
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[string]$VenvPath = ".venv",
|
|
3
|
+
[switch]$Dev
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = "Stop"
|
|
7
|
+
|
|
8
|
+
if (-not (Test-Path $VenvPath)) {
|
|
9
|
+
python -m venv $VenvPath
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
$python = Join-Path $VenvPath "Scripts\\python.exe"
|
|
13
|
+
if (-not (Test-Path $python)) {
|
|
14
|
+
throw "Virtual environment python not found at $python"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
& $python -m pip install --upgrade pip
|
|
18
|
+
& $python -m pip install -r "requirements.txt"
|
|
19
|
+
|
|
20
|
+
if ($Dev) {
|
|
21
|
+
& $python -m pip install -r "requirements-dev.txt"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
Write-Output "Setup complete."
|
|
25
|
+
Write-Output "Venv python: $python"
|
|
26
|
+
Write-Output "If you use Packet Tracer 9.x encoding/decoding, also set PKT_TWOFISH_LIBRARY."
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Runtime diagnostics for the local Twofish bridge."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from ctypes import CDLL
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SUPPORTED_PYTHON = (3, 14)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _vendor_dir() -> Path:
|
|
18
|
+
return Path(__file__).resolve().parent / "vendor"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _candidate_paths() -> list[tuple[str, Path]]:
|
|
22
|
+
vendor_dir = _vendor_dir()
|
|
23
|
+
env_path = os.getenv("PKT_TWOFISH_LIBRARY")
|
|
24
|
+
candidates: list[tuple[str, Path]] = []
|
|
25
|
+
if env_path:
|
|
26
|
+
candidates.append(("env", Path(env_path).expanduser()))
|
|
27
|
+
for pattern in ("_twofish*.pyd", "_twofish*.so", "_twofish*.dll"):
|
|
28
|
+
for candidate in sorted(vendor_dir.glob(pattern)):
|
|
29
|
+
candidates.append(("sibling", candidate))
|
|
30
|
+
return candidates
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> int:
|
|
34
|
+
python_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
35
|
+
python_supported = sys.version_info[:2] == SUPPORTED_PYTHON
|
|
36
|
+
result = {
|
|
37
|
+
"python_version": python_version,
|
|
38
|
+
"python_support_status": "ok" if python_supported else "unsupported",
|
|
39
|
+
"python_support_message": (
|
|
40
|
+
"supported"
|
|
41
|
+
if python_supported
|
|
42
|
+
else f"requires Python {SUPPORTED_PYTHON[0]}.{SUPPORTED_PYTHON[1]}.x"
|
|
43
|
+
),
|
|
44
|
+
"resolved_twofish_path": "",
|
|
45
|
+
"twofish_source": "",
|
|
46
|
+
"twofish_load_status": "missing",
|
|
47
|
+
"twofish_message": "no local Twofish bridge was found",
|
|
48
|
+
"twofish_sha256": "",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for source, candidate in _candidate_paths():
|
|
52
|
+
if not candidate.exists():
|
|
53
|
+
continue
|
|
54
|
+
result["resolved_twofish_path"] = str(candidate)
|
|
55
|
+
result["twofish_source"] = source
|
|
56
|
+
result["twofish_sha256"] = hashlib.sha256(candidate.read_bytes()).hexdigest()
|
|
57
|
+
if not python_supported:
|
|
58
|
+
result["twofish_load_status"] = "python_unsupported"
|
|
59
|
+
result["twofish_message"] = (
|
|
60
|
+
f"found {candidate}, but this bridge is only supported with Python "
|
|
61
|
+
f"{SUPPORTED_PYTHON[0]}.{SUPPORTED_PYTHON[1]}.x"
|
|
62
|
+
)
|
|
63
|
+
print(json.dumps(result))
|
|
64
|
+
return 0
|
|
65
|
+
try:
|
|
66
|
+
library = CDLL(str(candidate))
|
|
67
|
+
getattr(library, "exp_Twofish_encrypt")
|
|
68
|
+
getattr(library, "exp_Twofish_decrypt")
|
|
69
|
+
result["twofish_load_status"] = "ok"
|
|
70
|
+
result["twofish_message"] = f"loaded {candidate}"
|
|
71
|
+
print(json.dumps(result))
|
|
72
|
+
return 0
|
|
73
|
+
except Exception as exc: # pragma: no cover - runtime diagnostics
|
|
74
|
+
result["twofish_load_status"] = "load_error"
|
|
75
|
+
result["twofish_message"] = f"{candidate}: {exc}"
|
|
76
|
+
print(json.dumps(result))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
env_path = os.getenv("PKT_TWOFISH_LIBRARY")
|
|
80
|
+
if env_path:
|
|
81
|
+
result["resolved_twofish_path"] = str(Path(env_path).expanduser())
|
|
82
|
+
result["twofish_source"] = "env"
|
|
83
|
+
result["twofish_load_status"] = "missing"
|
|
84
|
+
result["twofish_message"] = f"set but missing: {env_path}"
|
|
85
|
+
|
|
86
|
+
print(json.dumps(result))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|