tracerlab 0.1.5__py3-none-any.whl
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.
- tracerlab/__init__.py +3 -0
- tracerlab/bridge.py +359 -0
- tracerlab/catalog.py +230 -0
- tracerlab/cli.py +151 -0
- tracerlab/configgen.py +566 -0
- tracerlab/data/pt_api.json.gz +0 -0
- tracerlab/js/bridge.html +3 -0
- tracerlab/js/bridge.js +94 -0
- tracerlab/js/runtime.js +592 -0
- tracerlab/labs.py +371 -0
- tracerlab/netcalc.py +165 -0
- tracerlab/pkt.py +199 -0
- tracerlab/planner.py +629 -0
- tracerlab/pt_extension/bridge.html +25 -0
- tracerlab/pt_extension/ptmcp.js +58 -0
- tracerlab/pt_extension/ptmcp.pts +0 -0
- tracerlab/server.py +1056 -0
- tracerlab/twofish.py +203 -0
- tracerlab-0.1.5.dist-info/METADATA +82 -0
- tracerlab-0.1.5.dist-info/RECORD +23 -0
- tracerlab-0.1.5.dist-info/WHEEL +4 -0
- tracerlab-0.1.5.dist-info/entry_points.txt +3 -0
- tracerlab-0.1.5.dist-info/licenses/LICENSE +21 -0
tracerlab/__init__.py
ADDED
tracerlab/bridge.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""HTTP long-poll bridge to the PT script module.
|
|
2
|
+
|
|
3
|
+
If another tracerlab already owns the port, jobs are relayed through it (POST /exec).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import itertools
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from importlib import resources
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("tracerlab.bridge")
|
|
21
|
+
|
|
22
|
+
HOST = "127.0.0.1"
|
|
23
|
+
PORT = int(os.environ.get("TRACERLAB_PORT") or os.environ.get("PTMCP_PORT") or "39150")
|
|
24
|
+
POLL_HOLD_S = 20.0 # how long a /poll request is held open when idle
|
|
25
|
+
STALE_AFTER_S = POLL_HOLD_S + 10.0
|
|
26
|
+
PICKUP_S = 8.0 # a queued job must be picked up by a poll within this
|
|
27
|
+
MAX_BODY = 64 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PTError(RuntimeError):
|
|
31
|
+
"""Raised for errors reported by Packet Tracer or the bridge."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NotConnected(PTError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Job:
|
|
40
|
+
id: int
|
|
41
|
+
code: str
|
|
42
|
+
future: asyncio.Future
|
|
43
|
+
picked: asyncio.Event = field(default_factory=asyncio.Event)
|
|
44
|
+
created: float = field(default_factory=time.monotonic)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bridge_js() -> str:
|
|
48
|
+
return resources.files("tracerlab").joinpath("js/bridge.js").read_text()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bridge_build() -> str:
|
|
52
|
+
import hashlib
|
|
53
|
+
return hashlib.sha1(_bridge_js().encode()).hexdigest()[:10]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _runtime_js() -> str:
|
|
57
|
+
return resources.files("tracerlab").joinpath("js/runtime.js").read_text()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _runtime_version(js: str) -> str:
|
|
61
|
+
# include the hash so edits to runtime.js reload without a version bump
|
|
62
|
+
import hashlib
|
|
63
|
+
from . import __version__
|
|
64
|
+
return f"{__version__}+{hashlib.sha1(js.encode()).hexdigest()[:8]}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Bridge:
|
|
68
|
+
def __init__(self, host: str = HOST, port: int = PORT) -> None:
|
|
69
|
+
self.host, self.port = host, port
|
|
70
|
+
self._queue: asyncio.Queue[_Job] = asyncio.Queue()
|
|
71
|
+
self._inflight: dict[int, _Job] = {}
|
|
72
|
+
self._ids = itertools.count(1)
|
|
73
|
+
self._chunks: dict[int, dict[int, str]] = {}
|
|
74
|
+
self._server: asyncio.base_events.Server | None = None
|
|
75
|
+
self._last_seen = 0.0
|
|
76
|
+
self._pt_info: dict[str, Any] = {}
|
|
77
|
+
self._runtime_loaded_for: str | None = None # session token of the PT side
|
|
78
|
+
self._runtime_lock = asyncio.Lock()
|
|
79
|
+
self.mode = "stopped" # "owner" | "relay" | "stopped"
|
|
80
|
+
|
|
81
|
+
async def start(self) -> None:
|
|
82
|
+
# created here so they bind to the running loop
|
|
83
|
+
self._queue = asyncio.Queue()
|
|
84
|
+
self._inflight = {}
|
|
85
|
+
self._chunks: dict[int, dict[int, str]] = {}
|
|
86
|
+
self._runtime_lock = asyncio.Lock()
|
|
87
|
+
try:
|
|
88
|
+
self._server = await asyncio.start_server(self._handle, self.host, self.port)
|
|
89
|
+
self.mode = "owner"
|
|
90
|
+
log.info("bridge listening on http://%s:%d", self.host, self.port)
|
|
91
|
+
except OSError:
|
|
92
|
+
if await asyncio.to_thread(self._probe_owner):
|
|
93
|
+
self.mode = "relay"
|
|
94
|
+
log.info("port %d owned by another ptmcp; relaying", self.port)
|
|
95
|
+
else:
|
|
96
|
+
raise PTError(f"port {self.port} is in use by something that is not ptmcp "
|
|
97
|
+
"(set PTMCP_PORT to another port and update the PT module)")
|
|
98
|
+
|
|
99
|
+
async def stop(self) -> None:
|
|
100
|
+
self._last_seen = 0.0
|
|
101
|
+
if self._server:
|
|
102
|
+
self._server.close()
|
|
103
|
+
await self._server.wait_closed()
|
|
104
|
+
for job in list(self._inflight.values()):
|
|
105
|
+
if not job.future.done():
|
|
106
|
+
job.future.set_exception(PTError("bridge stopped"))
|
|
107
|
+
|
|
108
|
+
def _probe_owner(self) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
with urllib.request.urlopen(f"http://{self.host}:{self.port}/ping", timeout=2) as r:
|
|
111
|
+
return json.loads(r.read()).get("service") == "ptmcp"
|
|
112
|
+
except Exception:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def connected(self) -> bool:
|
|
117
|
+
return time.monotonic() - self._last_seen < STALE_AFTER_S
|
|
118
|
+
|
|
119
|
+
def status(self) -> dict[str, Any]:
|
|
120
|
+
if self.mode == "relay":
|
|
121
|
+
try:
|
|
122
|
+
with urllib.request.urlopen(f"http://{self.host}:{self.port}/ping", timeout=2) as r:
|
|
123
|
+
d = json.loads(r.read())
|
|
124
|
+
d["mode"] = "relay"
|
|
125
|
+
return d
|
|
126
|
+
except Exception as e: # pragma: no cover - network edge
|
|
127
|
+
return {"mode": "relay", "connected": False, "error": str(e)}
|
|
128
|
+
return {
|
|
129
|
+
"service": "ptmcp",
|
|
130
|
+
"mode": self.mode,
|
|
131
|
+
"connected": self.connected,
|
|
132
|
+
"seconds_since_poll": round(time.monotonic() - self._last_seen, 1) if self._last_seen else None,
|
|
133
|
+
"pt": self._pt_info,
|
|
134
|
+
"queued": self._queue.qsize(),
|
|
135
|
+
"inflight": len(self._inflight),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async def run(self, code: str, timeout: float = 60.0, *, raw: bool = False) -> Any:
|
|
139
|
+
"""Run a JS function body in PT (PTM in scope) and return its JSON value."""
|
|
140
|
+
if not raw:
|
|
141
|
+
await self._ensure_runtime(timeout)
|
|
142
|
+
return await self._submit(code, timeout)
|
|
143
|
+
|
|
144
|
+
async def wait_connected(self, timeout: float) -> bool:
|
|
145
|
+
end = time.monotonic() + timeout
|
|
146
|
+
while time.monotonic() < end:
|
|
147
|
+
if self.mode == "relay" or self.connected:
|
|
148
|
+
return True
|
|
149
|
+
await asyncio.sleep(0.25)
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
async def _ensure_runtime(self, timeout: float) -> None:
|
|
153
|
+
async with self._runtime_lock:
|
|
154
|
+
probe = "return (typeof PTM !== 'undefined' && PTM.VERSION) ? PTM.VERSION : null;"
|
|
155
|
+
v = await self._submit(probe, timeout)
|
|
156
|
+
js = _runtime_js()
|
|
157
|
+
want = _runtime_version(js)
|
|
158
|
+
if v != want:
|
|
159
|
+
await self._submit(js + f"\nPTM.VERSION = {json.dumps(want)};\nreturn PTM.VERSION;", timeout)
|
|
160
|
+
|
|
161
|
+
async def _submit(self, code: str, timeout: float) -> Any:
|
|
162
|
+
if self.mode == "relay":
|
|
163
|
+
return await asyncio.to_thread(self._relay, code, timeout)
|
|
164
|
+
if not self.connected:
|
|
165
|
+
raise NotConnected(
|
|
166
|
+
"Packet Tracer is not connected. Open Packet Tracer 9 with the PT-MCP script "
|
|
167
|
+
"module installed (Extensions > PT-MCP Bridge shows a green 'connected' pill). "
|
|
168
|
+
"See pt_setup_instructions.")
|
|
169
|
+
loop = asyncio.get_running_loop()
|
|
170
|
+
job = _Job(next(self._ids), code, loop.create_future())
|
|
171
|
+
self._inflight[job.id] = job
|
|
172
|
+
await self._queue.put(job)
|
|
173
|
+
try:
|
|
174
|
+
await asyncio.wait_for(job.picked.wait(), PICKUP_S)
|
|
175
|
+
except asyncio.TimeoutError:
|
|
176
|
+
self._inflight.pop(job.id, None)
|
|
177
|
+
job.future.cancel()
|
|
178
|
+
self._last_seen = 0.0
|
|
179
|
+
raise NotConnected("Packet Tracer stopped polling (PT closed, bridge window closed, or a modal "
|
|
180
|
+
"dialog is blocking PT). Check the 'PT-MCP Bridge' window.") from None
|
|
181
|
+
try:
|
|
182
|
+
env = await asyncio.wait_for(job.future, timeout)
|
|
183
|
+
except asyncio.TimeoutError:
|
|
184
|
+
raise PTError(f"Packet Tracer did not answer within {timeout:.0f}s "
|
|
185
|
+
"(a modal dialog open in PT blocks scripting)") from None
|
|
186
|
+
finally:
|
|
187
|
+
self._inflight.pop(job.id, None)
|
|
188
|
+
return _unwrap(env)
|
|
189
|
+
|
|
190
|
+
def _relay(self, code: str, timeout: float) -> Any:
|
|
191
|
+
req = urllib.request.Request(
|
|
192
|
+
f"http://{self.host}:{self.port}/exec",
|
|
193
|
+
data=json.dumps({"code": code, "timeout": timeout}).encode(),
|
|
194
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
195
|
+
try:
|
|
196
|
+
with urllib.request.urlopen(req, timeout=timeout + 5) as r:
|
|
197
|
+
env = json.loads(r.read())
|
|
198
|
+
except urllib.error.HTTPError as e:
|
|
199
|
+
env = json.loads(e.read() or b"{}")
|
|
200
|
+
if env.get("bridge_error"):
|
|
201
|
+
raise (NotConnected if env.get("not_connected") else PTError)(env["bridge_error"])
|
|
202
|
+
return _unwrap(env)
|
|
203
|
+
|
|
204
|
+
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
205
|
+
try:
|
|
206
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
207
|
+
lines = head.decode("latin-1").split("\r\n")
|
|
208
|
+
method, target, _ = lines[0].split(" ", 2)
|
|
209
|
+
headers = {k.strip().lower(): v.strip() for k, v in
|
|
210
|
+
(l.split(":", 1) for l in lines[1:] if ":" in l)}
|
|
211
|
+
n = int(headers.get("content-length", "0") or 0)
|
|
212
|
+
if n > MAX_BODY:
|
|
213
|
+
return await self._send(writer, 413, {"error": "too large"})
|
|
214
|
+
if "chunked" in headers.get("transfer-encoding", "").lower():
|
|
215
|
+
body = await _read_chunked(reader)
|
|
216
|
+
else:
|
|
217
|
+
body = await reader.readexactly(n) if n else b""
|
|
218
|
+
path = target.split("?", 1)[0]
|
|
219
|
+
if method == "OPTIONS":
|
|
220
|
+
return await self._send(writer, 204, None)
|
|
221
|
+
if path == "/ping":
|
|
222
|
+
return await self._send(writer, 200, self.status())
|
|
223
|
+
if path == "/hello":
|
|
224
|
+
from urllib.parse import parse_qs
|
|
225
|
+
q = parse_qs(target.split("?", 1)[1]) if "?" in target else {}
|
|
226
|
+
self._pt_info["webview"] = {k: v[0] for k, v in q.items()}
|
|
227
|
+
log.info("PT web view hello: %s", self._pt_info["webview"])
|
|
228
|
+
return await self._send(writer, 200, {"ok": True})
|
|
229
|
+
if path == "/poll":
|
|
230
|
+
return await self._poll(writer, target)
|
|
231
|
+
if path == "/result" and method == "POST":
|
|
232
|
+
self._last_seen = time.monotonic()
|
|
233
|
+
self._on_result(json.loads(body))
|
|
234
|
+
return await self._send(writer, 200, {"ok": True})
|
|
235
|
+
if path == "/exec" and method == "POST":
|
|
236
|
+
return await self._exec_for_relay(writer, json.loads(body))
|
|
237
|
+
if path == "/r": # chunked GET result from the PT web view
|
|
238
|
+
self._last_seen = time.monotonic()
|
|
239
|
+
self._on_chunk(target)
|
|
240
|
+
return await self._send_raw(writer, 200, "application/javascript", b"0;")
|
|
241
|
+
if path == "/bridge.js":
|
|
242
|
+
js = _bridge_js().replace("__PORT__", str(self.port)).replace("__BUILD__", _bridge_build())
|
|
243
|
+
return await self._send_raw(writer, 200, "application/javascript", js.encode())
|
|
244
|
+
if path == "/log":
|
|
245
|
+
log.info("PT web view: %s", target)
|
|
246
|
+
return await self._send(writer, 200, {"ok": True})
|
|
247
|
+
if path in ("/", "/bridge"):
|
|
248
|
+
return await self._send_raw(writer, 200, "text/html; charset=utf-8",
|
|
249
|
+
_bridge_page(self.port).encode())
|
|
250
|
+
await self._send(writer, 404, {"error": "not found"})
|
|
251
|
+
except (asyncio.IncompleteReadError, ConnectionError):
|
|
252
|
+
pass
|
|
253
|
+
except Exception as e: # pragma: no cover - defensive
|
|
254
|
+
log.exception("http handler failed")
|
|
255
|
+
try:
|
|
256
|
+
await self._send(writer, 500, {"error": str(e)})
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
finally:
|
|
260
|
+
try:
|
|
261
|
+
writer.close()
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
async def _poll(self, writer: asyncio.StreamWriter, target: str) -> None:
|
|
266
|
+
self._last_seen = time.monotonic()
|
|
267
|
+
q: dict[str, str] = {}
|
|
268
|
+
if "?" in target:
|
|
269
|
+
from urllib.parse import parse_qs
|
|
270
|
+
q = {k: v[0] for k, v in parse_qs(target.split("?", 1)[1]).items()}
|
|
271
|
+
if q.get("pt"):
|
|
272
|
+
self._pt_info.update({"version": q.get("pt"), "session": q.get("s")})
|
|
273
|
+
try:
|
|
274
|
+
job = await asyncio.wait_for(self._queue.get(), POLL_HOLD_S)
|
|
275
|
+
except asyncio.TimeoutError:
|
|
276
|
+
self._last_seen = time.monotonic()
|
|
277
|
+
return await self._reply_poll(writer, q, {"id": 0})
|
|
278
|
+
if job.future.done(): # caller gave up while queued
|
|
279
|
+
return await self._reply_poll(writer, q, {"id": 0})
|
|
280
|
+
job.picked.set()
|
|
281
|
+
self._last_seen = time.monotonic()
|
|
282
|
+
try:
|
|
283
|
+
await self._reply_poll(writer, q, {"id": job.id, "code": job.code})
|
|
284
|
+
except ConnectionError:
|
|
285
|
+
await self._queue.put(job) # web view vanished mid-poll; retry on next poll
|
|
286
|
+
|
|
287
|
+
async def _reply_poll(self, writer, q: dict, obj: dict) -> None:
|
|
288
|
+
cb = q.get("cb", "")
|
|
289
|
+
if cb and cb.replace("_", "").isalnum(): # JSONP for the PT web view
|
|
290
|
+
body = f"{cb}({json.dumps({**obj, 'build': _bridge_build()})});".encode()
|
|
291
|
+
return await self._send_raw(writer, 200, "application/javascript", body)
|
|
292
|
+
await self._send(writer, 200, obj)
|
|
293
|
+
|
|
294
|
+
def _on_chunk(self, target: str) -> None:
|
|
295
|
+
from urllib.parse import parse_qs
|
|
296
|
+
q = {k: v[0] for k, v in parse_qs(target.split("?", 1)[1], keep_blank_values=True).items()}
|
|
297
|
+
jid, i, n = int(q["id"]), int(q["i"]), int(q["n"])
|
|
298
|
+
parts = self._chunks.setdefault(jid, {})
|
|
299
|
+
parts[i] = q.get("d", "")
|
|
300
|
+
if len(parts) == n:
|
|
301
|
+
del self._chunks[jid]
|
|
302
|
+
self._on_result(json.loads("".join(parts[k] for k in range(n))))
|
|
303
|
+
|
|
304
|
+
def _on_result(self, msg: dict) -> None:
|
|
305
|
+
job = self._inflight.get(int(msg.get("id", 0)))
|
|
306
|
+
if job and not job.future.done():
|
|
307
|
+
job.future.set_result(msg)
|
|
308
|
+
|
|
309
|
+
async def _exec_for_relay(self, writer, req: dict) -> None:
|
|
310
|
+
try:
|
|
311
|
+
value = await self._submit(req["code"], float(req.get("timeout", 60)))
|
|
312
|
+
await self._send(writer, 200, {"ok": True, "value": value})
|
|
313
|
+
except NotConnected as e:
|
|
314
|
+
await self._send(writer, 200, {"bridge_error": str(e), "not_connected": True})
|
|
315
|
+
except PTError as e:
|
|
316
|
+
await self._send(writer, 200, {"bridge_error": str(e)})
|
|
317
|
+
|
|
318
|
+
async def _send(self, writer, status: int, obj: Any) -> None:
|
|
319
|
+
body = b"" if obj is None else json.dumps(obj).encode()
|
|
320
|
+
await self._send_raw(writer, status, "application/json", body)
|
|
321
|
+
|
|
322
|
+
async def _send_raw(self, writer, status: int, ctype: str, body: bytes) -> None:
|
|
323
|
+
reason = {200: "OK", 204: "No Content", 404: "Not Found", 413: "Too Large",
|
|
324
|
+
500: "Error"}.get(status, "OK")
|
|
325
|
+
hdr = (f"HTTP/1.1 {status} {reason}\r\nContent-Type: {ctype}\r\n"
|
|
326
|
+
f"Content-Length: {len(body)}\r\nAccess-Control-Allow-Origin: *\r\n"
|
|
327
|
+
"Access-Control-Allow-Headers: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
|
|
328
|
+
"Cache-Control: no-store\r\nConnection: close\r\n\r\n")
|
|
329
|
+
writer.write(hdr.encode() + body)
|
|
330
|
+
await writer.drain()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _read_chunked(reader: asyncio.StreamReader) -> bytes:
|
|
334
|
+
body = bytearray()
|
|
335
|
+
while True:
|
|
336
|
+
size = int((await reader.readline()).split(b";")[0].strip() or b"0", 16)
|
|
337
|
+
if size == 0:
|
|
338
|
+
await reader.readline()
|
|
339
|
+
return bytes(body)
|
|
340
|
+
if len(body) + size > MAX_BODY:
|
|
341
|
+
raise ConnectionError("body too large")
|
|
342
|
+
body += await reader.readexactly(size)
|
|
343
|
+
await reader.readline()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _unwrap(env: dict) -> Any:
|
|
347
|
+
"""PT side replies {ok, value} or {ok:false, error, line}."""
|
|
348
|
+
if isinstance(env, str):
|
|
349
|
+
env = json.loads(env)
|
|
350
|
+
if env.get("ok"):
|
|
351
|
+
return env.get("value")
|
|
352
|
+
msg = env.get("error") or "unknown Packet Tracer error"
|
|
353
|
+
if env.get("line"):
|
|
354
|
+
msg += f" (line {env['line']})"
|
|
355
|
+
raise PTError(msg)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _bridge_page(port: int) -> str:
|
|
359
|
+
return resources.files("tracerlab").joinpath("js/bridge.html").read_text().replace("__PORT__", str(port))
|
tracerlab/catalog.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Device, module and cable catalog for PT 9. Type codes are the DeviceType enum."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
# DeviceType enum (PT 8.2+/9.0)
|
|
8
|
+
ROUTER, SWITCH, CLOUD, BRIDGE, HUB, REPEATER, SPLITTER, ACCESS_POINT, PC, SERVER, PRINTER = range(11)
|
|
9
|
+
WIRELESS_ROUTER, IP_PHONE, DSL_MODEM, CABLE_MODEM, REMOTE_NET, L3_SWITCH = 11, 12, 13, 14, 15, 16
|
|
10
|
+
SWITCH_3650, LAPTOP, TABLET, PDA, WIRELESS_END, WIRED_END, TV, HOME_VOIP, ANALOG_PHONE = 17, 18, 19, 20, 21, 22, 23, 24, 25
|
|
11
|
+
ASA, IOE, HOME_GATEWAY, CELL_TOWER, CO_SERVER, SNIFFER, MCU, SBC, THING = 27, 28, 29, 31, 32, 35, 36, 37, 38
|
|
12
|
+
EMBEDDED_SERVER, WLC, LAP, MERAKI_MX, MERAKI_SERVER, NET_CONTROLLER = 40, 41, 44, 48, 49, 50
|
|
13
|
+
|
|
14
|
+
TYPE_NAMES = {
|
|
15
|
+
0: "router", 1: "switch", 2: "cloud", 3: "bridge", 4: "hub", 5: "repeater", 6: "coax-splitter",
|
|
16
|
+
7: "access-point", 8: "pc", 9: "server", 10: "printer", 11: "wireless-router", 12: "ip-phone",
|
|
17
|
+
13: "dsl-modem", 14: "cable-modem", 15: "remote-network", 16: "l3-switch", 17: "l3-switch",
|
|
18
|
+
18: "laptop", 19: "tablet", 20: "smartphone", 21: "wireless-end-device", 22: "wired-end-device",
|
|
19
|
+
23: "tv", 24: "home-voip", 25: "analog-phone", 26: "multiuser", 27: "asa", 28: "iot",
|
|
20
|
+
29: "home-gateway", 30: "home-router", 31: "cell-tower", 32: "central-office-server",
|
|
21
|
+
33: "cisco-ap", 34: "embedded-ap", 35: "sniffer", 36: "mcu", 37: "sbc", 38: "thing",
|
|
22
|
+
40: "embedded-server", 41: "wlc", 44: "lightweight-ap", 45: "power-distribution",
|
|
23
|
+
46: "patch-panel", 47: "wall-mount", 48: "meraki-mx", 49: "meraki-server", 50: "network-controller",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
CLI_TYPES = {0, 1, 16, 17, 27} # devices with an IOS/ASA CLI
|
|
27
|
+
HOST_TYPES = {8, 9, 10, 18, 19, 20, 21, 22, 12} # devices configured through IP settings
|
|
28
|
+
L2_TYPES = {1, 4, 3, 5, 7, 33, 44} # forward frames without routing (switch-like)
|
|
29
|
+
L3_SWITCH_TYPES = {16, 17}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _r(n):
|
|
33
|
+
return [f"GigabitEthernet0/{i}" for i in range(n)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Model:
|
|
38
|
+
model: str
|
|
39
|
+
types: tuple[int, ...]
|
|
40
|
+
role: str
|
|
41
|
+
ports: tuple[str, ...] = ()
|
|
42
|
+
slots: str = ""
|
|
43
|
+
notes: str = ""
|
|
44
|
+
aliases: tuple[str, ...] = field(default=())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _fa(prefix, a, b):
|
|
48
|
+
return tuple(f"{prefix}{i}" for i in range(a, b + 1))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
MODELS: dict[str, Model] = {m.model.lower(): m for m in [
|
|
52
|
+
# routers
|
|
53
|
+
Model("1941", (ROUTER,), "router", ("GigabitEthernet0/0", "GigabitEthernet0/1"),
|
|
54
|
+
"2x EHWIC: slot 0/0 and 0/1 (HWIC-2T serial, HWIC-4ESW, EHWIC-...)", "Classic CCNA lab router; IOS 15.1.", ("r1941",)),
|
|
55
|
+
Model("2901", (ROUTER,), "router", ("GigabitEthernet0/0", "GigabitEthernet0/1"),
|
|
56
|
+
"4x EHWIC (0/0..0/3)", "Like 1941 with more slots."),
|
|
57
|
+
Model("2911", (ROUTER,), "router", ("GigabitEthernet0/0", "GigabitEthernet0/1", "GigabitEthernet0/2"),
|
|
58
|
+
"4x EHWIC (0/0..0/3), 1x SM", "Best default router: 3 gig ports, security + voice licenses.", ("router", "r2911")),
|
|
59
|
+
Model("ISR4321", (ROUTER,), "router", ("GigabitEthernet0/0/0", "GigabitEthernet0/0/1"),
|
|
60
|
+
"2x NIM (0/1, 0/2) e.g. NIM-2T", "IOS-XE; port names have three parts.", ("4321",)),
|
|
61
|
+
Model("ISR4331", (ROUTER,), "router", ("GigabitEthernet0/0/0", "GigabitEthernet0/0/1", "GigabitEthernet0/0/2"),
|
|
62
|
+
"3x NIM (0/1..0/3)", "IOS-XE; current CCNA curriculum router.", ("4331",)),
|
|
63
|
+
Model("1841", (ROUTER,), "router", ("FastEthernet0/0", "FastEthernet0/1"), "2x HWIC/WIC (0/0, 0/1)",
|
|
64
|
+
"Old 12.4 router; FastEthernet only."),
|
|
65
|
+
Model("2811", (ROUTER,), "router", ("FastEthernet0/0", "FastEthernet0/1"), "4x HWIC, 1x NM",
|
|
66
|
+
"Old 12.4 router."),
|
|
67
|
+
Model("2620XM", (ROUTER,), "router", ("FastEthernet0/0",), "1x NM, 2x WIC", "Legacy."),
|
|
68
|
+
Model("2621XM", (ROUTER,), "router", ("FastEthernet0/0", "FastEthernet0/1"), "1x NM, 2x WIC", "Legacy."),
|
|
69
|
+
Model("819HGW", (ROUTER,), "router", (), "", "Integrated 4G/wifi router."),
|
|
70
|
+
Model("829", (ROUTER,), "router", (), "", "Industrial ISR."),
|
|
71
|
+
Model("CGR1240", (ROUTER,), "router", (), "", "Connected grid router."),
|
|
72
|
+
Model("IR8340", (ROUTER,), "router", (), "", "Industrial router (PT 9)."),
|
|
73
|
+
Model("PT8200", (ROUTER,), "router", (), "", "PT 9 generic modern router."),
|
|
74
|
+
Model("Router-PT", (ROUTER,), "router",
|
|
75
|
+
("FastEthernet0/0", "FastEthernet1/0", "Serial2/0", "Serial3/0", "FastEthernet4/0", "FastEthernet5/0"),
|
|
76
|
+
"10 PT-ROUTER-NM slots", "Generic router with built-in serial ports: quickest for serial WAN labs."),
|
|
77
|
+
Model("Router-PT-Empty", (ROUTER,), "router", (), "10 empty PT-ROUTER-NM slots", "Generic, no interfaces."),
|
|
78
|
+
# switches
|
|
79
|
+
Model("2960-24TT", (SWITCH,), "switch", _fa("FastEthernet0/", 1, 24) + ("GigabitEthernet0/1", "GigabitEthernet0/2"),
|
|
80
|
+
"", "Default access switch: 24 Fa + 2 Gi uplinks.", ("switch", "2960")),
|
|
81
|
+
Model("2950-24", (SWITCH,), "switch", _fa("FastEthernet0/", 1, 24), "", "Legacy L2."),
|
|
82
|
+
Model("2950T-24", (SWITCH,), "switch", _fa("FastEthernet0/", 1, 24) + ("GigabitEthernet0/1", "GigabitEthernet0/2"), "", "Legacy L2."),
|
|
83
|
+
Model("Switch-PT", (SWITCH,), "switch", ("FastEthernet0/1", "FastEthernet1/1", "FastEthernet2/1", "FastEthernet3/1",
|
|
84
|
+
"FastEthernet4/1", "FastEthernet5/1"), "10 PT-SWITCH-NM slots", "Generic."),
|
|
85
|
+
Model("Switch-PT-Empty", (SWITCH,), "switch", (), "", "Generic, no interfaces."),
|
|
86
|
+
Model("IE-2000", (L3_SWITCH, SWITCH), "switch", (), "", "Industrial Ethernet switch."),
|
|
87
|
+
Model("3560-24PS", (L3_SWITCH,), "l3-switch", _fa("FastEthernet0/", 1, 24) + ("GigabitEthernet0/1", "GigabitEthernet0/2"),
|
|
88
|
+
"", "Layer-3 switch: SVIs, ip routing, EtherChannel, HSRP. Use for inter-VLAN routing.", ("multilayer", "l3switch", "3560")),
|
|
89
|
+
Model("3650-24PS", (SWITCH_3650, L3_SWITCH), "l3-switch",
|
|
90
|
+
tuple(f"GigabitEthernet1/0/{i}" for i in range(1, 25)) + tuple(f"GigabitEthernet1/1/{i}" for i in range(1, 5)),
|
|
91
|
+
"1 uplink module slot", "IOS-XE L3 switch. Ships WITHOUT a power supply in PT: add AC-POWER-SUPPLY.", ("3650",)),
|
|
92
|
+
# end devices
|
|
93
|
+
Model("PC-PT", (PC,), "host", ("FastEthernet0",), "1 host NM slot", "Desktop PC.", ("pc",)),
|
|
94
|
+
Model("Laptop-PT", (LAPTOP,), "host", ("FastEthernet0",), "1 laptop NM slot (swap for WPC300N for wifi)", "", ("laptop",)),
|
|
95
|
+
Model("Server-PT", (SERVER,), "host", ("FastEthernet0",), "", "DHCP/DNS/HTTP/FTP/Email/AAA/NTP/Syslog services.", ("server",)),
|
|
96
|
+
Model("Printer-PT", (PRINTER,), "host", ("FastEthernet0",), "", "", ("printer",)),
|
|
97
|
+
Model("TabletPC-PT", (TABLET,), "host", ("Wireless0",), "", "Wireless by default.", ("tablet",)),
|
|
98
|
+
Model("SMARTPHONE-PT", (PDA,), "host", ("Wireless0",), "", "Wireless + 3G/4G.", ("smartphone", "phone-pt")),
|
|
99
|
+
Model("7960", (IP_PHONE,), "host", ("Switch", "PC"), "", "Cisco IP phone; needs power adapter or PoE + voice VLAN.", ("ipphone",)),
|
|
100
|
+
Model("TV-PT", (TV,), "host", (), "", ""),
|
|
101
|
+
Model("WirelessEndDevice-PT", (WIRELESS_END,), "host", (), "", ""),
|
|
102
|
+
Model("WiredEndDevice-PT", (WIRED_END,), "host", (), "", ""),
|
|
103
|
+
Model("Sniffer", (SNIFFER,), "host", (), "", "Captures traffic on a link."),
|
|
104
|
+
# other
|
|
105
|
+
Model("Hub-PT", (HUB,), "hub", (), "", "", ("hub",)),
|
|
106
|
+
Model("Repeater-PT", (REPEATER,), "hub", (), "", ""),
|
|
107
|
+
Model("Bridge-PT", (BRIDGE,), "switch", (), "", ""),
|
|
108
|
+
Model("AccessPoint-PT", (ACCESS_POINT,), "ap", (), "", "Autonomous AP (802.11g)."),
|
|
109
|
+
Model("AccessPoint-PT-N", (ACCESS_POINT,), "ap", (), "", "802.11n AP."),
|
|
110
|
+
Model("AccessPoint-PT-AC", (ACCESS_POINT,), "ap", (), "", "802.11ac AP."),
|
|
111
|
+
Model("Linksys-WRT300N", (WIRELESS_ROUTER,), "wireless-router", (), "", "Home wireless router with GUI config."),
|
|
112
|
+
Model("HomeRouter-PT-AC", (30,), "wireless-router", (), "", ""),
|
|
113
|
+
Model("WLC-2504", (WLC,), "wlc", (), "", "Wireless LAN controller."),
|
|
114
|
+
Model("WLC-3504", (WLC,), "wlc", (), "", ""),
|
|
115
|
+
Model("LAP-PT", (LAP,), "ap", (), "", "Lightweight AP (needs WLC)."),
|
|
116
|
+
Model("3702i", (LAP,), "ap", (), "", "Lightweight AP."),
|
|
117
|
+
Model("5506-X", (ASA,), "firewall", tuple(f"GigabitEthernet1/{i}" for i in range(1, 9)), "",
|
|
118
|
+
"ASA firewall (ASA OS CLI, not IOS).", ("asa",)),
|
|
119
|
+
Model("5505", (ASA,), "firewall", tuple(f"Ethernet0/{i}" for i in range(0, 8)), "", "Legacy ASA with switch ports."),
|
|
120
|
+
Model("Cloud-PT", (CLOUD,), "cloud", (), "", "WAN cloud (frame relay / DSL / cable)."),
|
|
121
|
+
Model("DSL-Modem-PT", (DSL_MODEM,), "modem", (), "", ""),
|
|
122
|
+
Model("Cable-Modem-PT", (CABLE_MODEM,), "modem", (), "", ""),
|
|
123
|
+
Model("Cell-Tower", (CELL_TOWER,), "cellular", (), "", ""),
|
|
124
|
+
Model("Central-Office-Server", (CO_SERVER,), "cellular", (), "", ""),
|
|
125
|
+
Model("Meraki-MX65W", (MERAKI_MX,), "firewall", (), "", ""),
|
|
126
|
+
Model("NetworkController", (NET_CONTROLLER,), "controller", (), "", "SDN controller with REST API."),
|
|
127
|
+
Model("MCU-PT", (MCU,), "iot", (), "", "Microcontroller (programmable)."),
|
|
128
|
+
Model("SBC-PT", (SBC,), "iot", (), "", "Single-board computer (programmable)."),
|
|
129
|
+
]}
|
|
130
|
+
|
|
131
|
+
ALIASES = {a.lower(): m for m in MODELS.values() for a in m.aliases}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve_model(name: str) -> Model:
|
|
135
|
+
key = name.strip().lower()
|
|
136
|
+
if key in MODELS:
|
|
137
|
+
return MODELS[key]
|
|
138
|
+
if key in ALIASES:
|
|
139
|
+
return ALIASES[key]
|
|
140
|
+
# anything else: try it as an IoE thing
|
|
141
|
+
raise KeyError(name)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def model_or_passthrough(name: str) -> tuple[str, list[int]]:
|
|
145
|
+
"""Return (exact PT model name, type codes to try)."""
|
|
146
|
+
try:
|
|
147
|
+
m = resolve_model(name)
|
|
148
|
+
return m.model, list(m.types)
|
|
149
|
+
except KeyError:
|
|
150
|
+
return name, [THING, IOE, 39]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# module type codes (PT ModuleType enum)
|
|
154
|
+
MODULE_TYPES: dict[str, int] = {}
|
|
155
|
+
for _names, _code in [
|
|
156
|
+
("NM-1E NM-1E2W NM-1FE-FX NM-1FE-TX NM-1FE2W NM-2E2W NM-2FE2W NM-2W NM-4A/S NM-4E NM-8A/S NM-8AM NM-Cover NM-ESW-161", 1),
|
|
157
|
+
("1240-Cellular 1240-Cover HWIC-1GE-SFP HWIC-2T HWIC-4ESW HWIC-8A HWIC-AP-AG-B NIM-2T NIM-Cover NIM-ES2-4 "
|
|
158
|
+
"WIC-1AM WIC-1ENET WIC-1T WIC-2AM WIC-2T WIC-Cover", 2),
|
|
159
|
+
("PT-ROUTER-NM-1AM PT-ROUTER-NM-1CE PT-ROUTER-NM-1CFE PT-ROUTER-NM-1CGE PT-ROUTER-NM-1FFE PT-ROUTER-NM-1FGE "
|
|
160
|
+
"PT-ROUTER-NM-1S PT-ROUTER-NM-1SS PT-ROUTER-NM-COVER", 3),
|
|
161
|
+
("AC-POWER-SUPPLY POWER-COVER-PLATE PT-SWITCH-NM-1CE PT-SWITCH-NM-1CFE PT-SWITCH-NM-1CGE PT-SWITCH-NM-1FFE "
|
|
162
|
+
"PT-SWITCH-NM-1FGE PT-SWITCH-NM-COVER", 4),
|
|
163
|
+
("PT-CLOUD-NM-1AM PT-CLOUD-NM-1CE PT-CLOUD-NM-1CFE PT-CLOUD-NM-1CGE PT-CLOUD-NM-1FFE PT-CLOUD-NM-1FGE PT-CLOUD-NM-1S", 5),
|
|
164
|
+
("PT-REPEATER-NM-1CE PT-REPEATER-NM-1CFE PT-REPEATER-NM-1CGE PT-REPEATER-NM-1FFE PT-REPEATER-NM-1FGE PT-REPEATER-NM-COVER", 6),
|
|
165
|
+
("Linksys-WMP300N PT-HOST-NM-1AM PT-HOST-NM-1CE PT-HOST-NM-1CFE PT-HOST-NM-1CGE PT-HOST-NM-1FFE PT-HOST-NM-1FGE "
|
|
166
|
+
"PT-HOST-NM-1W PT-HOST-NM-1W-A PT-HOST-NM-1W-AC PT-HOST-NM-3G/4G PT-HOST-NM-COVER", 7),
|
|
167
|
+
("PT-MODEM-NM-1CE PT-MODEM-NM-1CFE PT-MODEM-NM-1CGE", 8),
|
|
168
|
+
("Linksys-WPC300N PT-LAPTOP-NM-1AM PT-LAPTOP-NM-1CE PT-LAPTOP-NM-1CFE PT-LAPTOP-NM-1CGE PT-LAPTOP-NM-1FFE "
|
|
169
|
+
"PT-LAPTOP-NM-1FGE PT-LAPTOP-NM-1W PT-LAPTOP-NM-1W-A PT-LAPTOP-NM-1W-AC PT-LAPTOP-NM-3G/4G", 9),
|
|
170
|
+
("IP_PHONE_POWER_ADAPTER", 11),
|
|
171
|
+
("PT-HEADPHONE PT-MICROPHONE", 16),
|
|
172
|
+
("ASA-Cover", 19),
|
|
173
|
+
("PT-CELL-NM-1CX PT-CELL-NM-3G/4G", 21),
|
|
174
|
+
("PT-IOT-CUSTOM-IO", 26),
|
|
175
|
+
("PT-IOT-POWER-ADAPTER", 27),
|
|
176
|
+
("PT-UNV-PWR-ADAPTER", 28),
|
|
177
|
+
("ROUTER-ADAPTER", 29),
|
|
178
|
+
("GLC-FE-100FX-RGD GLC-GE-100FX GLC-LH-SMD GLC-T GLC-TE", 30),
|
|
179
|
+
("ACCESS_POINT_POWER_ADAPTER", 31),
|
|
180
|
+
("C3650-BUILTIN C3650-SFP-BUILTIN ISR4321-BUILTIN ISR4331-BUILTIN PT-CONTROLLER-BUILTIN", 32),
|
|
181
|
+
("MERAKI-POWER-ADAPTER", 34),
|
|
182
|
+
]:
|
|
183
|
+
for _n in _names.split():
|
|
184
|
+
MODULE_TYPES.setdefault(_n, _code)
|
|
185
|
+
|
|
186
|
+
MODULE_NOTES = {
|
|
187
|
+
"HWIC-2T": "2 smart-serial WAN ports for 1941/2901/2911 (slot 0/0..0/3) -> Serial0/0/0, Serial0/0/1",
|
|
188
|
+
"WIC-2T": "2 serial ports for 1841/2811 -> Serial0/0/0, Serial0/0/1",
|
|
189
|
+
"NIM-2T": "2 serial ports for ISR4321/4331 (slot 0/1, 0/2) -> Serial0/1/0, Serial0/1/1",
|
|
190
|
+
"HWIC-4ESW": "4-port switch module (VLAN-capable) for ISR G2",
|
|
191
|
+
"NIM-ES2-4": "4-port switch module for ISR4K",
|
|
192
|
+
"HWIC-1GE-SFP": "1 SFP gigabit port",
|
|
193
|
+
"NM-1FE-TX": "1 FastEthernet for 2811/2620",
|
|
194
|
+
"PT-ROUTER-NM-1S": "1 serial port for Router-PT",
|
|
195
|
+
"PT-ROUTER-NM-1CGE": "1 copper gig for Router-PT",
|
|
196
|
+
"AC-POWER-SUPPLY": "power supply for 3650-24PS (slot is the PSU bay)",
|
|
197
|
+
"Linksys-WPC300N": "wireless NIC for Laptop-PT/PC-PT (replace the wired NM)",
|
|
198
|
+
"PT-HOST-NM-1W-AC": "802.11ac NIC for PC/Laptop",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
CABLES = {
|
|
202
|
+
"auto": "PT picks the right copper/serial cable (default)",
|
|
203
|
+
"straight": "copper straight-through: unlike devices (PC-switch, router-switch)",
|
|
204
|
+
"cross": "copper crossover: like devices (switch-switch, router-router, PC-PC, router-PC)",
|
|
205
|
+
"serial": "serial DCE/DTE (first device is DCE -> set clock rate there)",
|
|
206
|
+
"fiber": "fiber between fiber/SFP ports",
|
|
207
|
+
"console": "rollover console cable PC RS232 -> device Console",
|
|
208
|
+
"roll": "rollover", "phone": "RJ11 phone", "coaxial": "coax", "usb": "usb", "wireless": "wireless",
|
|
209
|
+
"octal": "octal", "cellular": "cellular", "cable": "cable modem",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def catalog_text(category: str | None = None) -> str:
|
|
214
|
+
rows = []
|
|
215
|
+
for m in MODELS.values():
|
|
216
|
+
if category and category.lower() not in (m.role, TYPE_NAMES.get(m.types[0], "")):
|
|
217
|
+
continue
|
|
218
|
+
ports = ""
|
|
219
|
+
if m.ports:
|
|
220
|
+
ports = (", ".join(m.ports) if len(m.ports) <= 6
|
|
221
|
+
else f"{m.ports[0]}..{m.ports[-1]} ({len(m.ports)} ports)")
|
|
222
|
+
line = f"{m.model} [{m.role}]"
|
|
223
|
+
if ports:
|
|
224
|
+
line += f" ports: {ports}"
|
|
225
|
+
if m.slots:
|
|
226
|
+
line += f" | slots: {m.slots}"
|
|
227
|
+
if m.notes:
|
|
228
|
+
line += f" | {m.notes}"
|
|
229
|
+
rows.append(line)
|
|
230
|
+
return "\n".join(rows)
|