pktlib 0.2.0__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.
Potentially problematic release.
This version of pktlib might be problematic. Click here for more details.
- pktlib/__init__.py +104 -0
- pktlib/__main__.py +5 -0
- pktlib/_audit.py +178 -0
- pktlib/_cli.py +125 -0
- pktlib/_codec.py +64 -0
- pktlib/_editions.py +62 -0
- pktlib/_errors.py +38 -0
- pktlib/_export.py +202 -0
- pktlib/_iosconfig.py +367 -0
- pktlib/_model.py +2178 -0
- pktlib/_modules.py +134 -0
- pktlib/_passwords.py +66 -0
- pktlib/_ports.py +120 -0
- pktlib/_shapes.py +174 -0
- pktlib/_twofish.py +223 -0
- pktlib/_xml.py +84 -0
- pktlib/activity.py +610 -0
- pktlib/data/activity.xml.gz +0 -0
- pktlib/data/base.xml.gz +0 -0
- pktlib/data/model-1841.xml.gz +0 -0
- pktlib/data/model-1941.xml.gz +0 -0
- pktlib/data/model-2620XM.xml.gz +0 -0
- pktlib/data/model-2621XM.xml.gz +0 -0
- pktlib/data/model-2811.xml.gz +0 -0
- pktlib/data/model-2901.xml.gz +0 -0
- pktlib/data/model-2911.xml.gz +0 -0
- pktlib/data/model-2950-24.xml.gz +0 -0
- pktlib/data/model-2950T-24.xml.gz +0 -0
- pktlib/data/model-2960-24TT.xml.gz +0 -0
- pktlib/data/model-3560-24PS.xml.gz +0 -0
- pktlib/data/model-3650-24PS.xml.gz +0 -0
- pktlib/data/model-3702i.xml.gz +0 -0
- pktlib/data/model-5505.xml.gz +0 -0
- pktlib/data/model-5506-X.xml.gz +0 -0
- pktlib/data/model-7960.xml.gz +0 -0
- pktlib/data/model-819HG-4G-IOX.xml.gz +0 -0
- pktlib/data/model-819HGW.xml.gz +0 -0
- pktlib/data/model-829.xml.gz +0 -0
- pktlib/data/model-AccessPoint-PT-A.xml.gz +0 -0
- pktlib/data/model-AccessPoint-PT-AC.xml.gz +0 -0
- pktlib/data/model-AccessPoint-PT-N.xml.gz +0 -0
- pktlib/data/model-AccessPoint-PT.xml.gz +0 -0
- pktlib/data/model-Analog-Phone-PT.xml.gz +0 -0
- pktlib/data/model-Bridge-PT.xml.gz +0 -0
- pktlib/data/model-CGR1240.xml.gz +0 -0
- pktlib/data/model-Cable-Modem-PT.xml.gz +0 -0
- pktlib/data/model-Cell-Tower.xml.gz +0 -0
- pktlib/data/model-Central-Office-Server.xml.gz +0 -0
- pktlib/data/model-Cloud-PT-Empty.xml.gz +0 -0
- pktlib/data/model-Cloud-PT.xml.gz +0 -0
- pktlib/data/model-CoAxialSplitter-PT.xml.gz +0 -0
- pktlib/data/model-DLC100.xml.gz +0 -0
- pktlib/data/model-DSL-Modem-PT.xml.gz +0 -0
- pktlib/data/model-Home-VoIP-PT.xml.gz +0 -0
- pktlib/data/model-HomeRouter-PT-AC.xml.gz +0 -0
- pktlib/data/model-Hub-PT.xml.gz +0 -0
- pktlib/data/model-IE-2000.xml.gz +0 -0
- pktlib/data/model-ISR4321.xml.gz +0 -0
- pktlib/data/model-ISR4331.xml.gz +0 -0
- pktlib/data/model-LAP-PT.xml.gz +0 -0
- pktlib/data/model-Laptop-PT.xml.gz +0 -0
- pktlib/data/model-Linksys-WRT300N.xml.gz +0 -0
- pktlib/data/model-Meraki-MX65W.xml.gz +0 -0
- pktlib/data/model-Meraki-Server.xml.gz +0 -0
- pktlib/data/model-NetworkController.xml.gz +0 -0
- pktlib/data/model-PC-PT.xml.gz +0 -0
- pktlib/data/model-Printer-PT.xml.gz +0 -0
- pktlib/data/model-Repeater-PT.xml.gz +0 -0
- pktlib/data/model-Router-PT-Empty.xml.gz +0 -0
- pktlib/data/model-Router-PT.xml.gz +0 -0
- pktlib/data/model-SMARTPHONE-PT.xml.gz +0 -0
- pktlib/data/model-Server-PT.xml.gz +0 -0
- pktlib/data/model-Sniffer.xml.gz +0 -0
- pktlib/data/model-Switch-PT-Empty.xml.gz +0 -0
- pktlib/data/model-Switch-PT.xml.gz +0 -0
- pktlib/data/model-TV-PT.xml.gz +0 -0
- pktlib/data/model-TabletPC-PT.xml.gz +0 -0
- pktlib/data/model-WLC-2504.xml.gz +0 -0
- pktlib/data/model-WLC-3504.xml.gz +0 -0
- pktlib/data/model-WLC-PT.xml.gz +0 -0
- pktlib/data/model-WiredEndDevice-PT.xml.gz +0 -0
- pktlib/data/model-WirelessEndDevice-PT.xml.gz +0 -0
- pktlib/data/modules.xml.gz +0 -0
- pktlib/data/router-1841-wic2t.xml.gz +0 -0
- pktlib/lab.py +207 -0
- pktlib/py.typed +0 -0
- pktlib/spec/devices.json +2068 -0
- pktlib-0.2.0.dist-info/METADATA +124 -0
- pktlib-0.2.0.dist-info/RECORD +92 -0
- pktlib-0.2.0.dist-info/WHEEL +4 -0
- pktlib-0.2.0.dist-info/entry_points.txt +3 -0
- pktlib-0.2.0.dist-info/licenses/LICENSE +21 -0
pktlib/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""pktlib: read, edit and write Cisco Packet Tracer .pkt files, in plain Python.
|
|
2
|
+
|
|
3
|
+
import pktlib
|
|
4
|
+
|
|
5
|
+
net = pktlib.new()
|
|
6
|
+
r1 = net.router("R1", "1841+WIC-2T", at=(200, 380))
|
|
7
|
+
sw = net.switch("SW0", at=(200, 260))
|
|
8
|
+
pc = net.pc("PC0", at=(200, 150), ip="192.168.1.10/24", gateway="192.168.1.1")
|
|
9
|
+
pc["fa0"].connect(sw["fa0/1"])
|
|
10
|
+
sw["fa0/2"].connect(r1["fa0/0"])
|
|
11
|
+
r1["fa0/0"].address = "192.168.1.1/24"
|
|
12
|
+
net.save("lab.pkt")
|
|
13
|
+
|
|
14
|
+
net = pktlib.load("lab.pkt")
|
|
15
|
+
print(pktlib.to_python(net)) # any .pkt -> a script that rebuilds it
|
|
16
|
+
|
|
17
|
+
Everything public is here, at the top level, and in pktlib.lab. Modules whose names
|
|
18
|
+
start with an underscore are pktlib's own and may change in any release.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextlib as _contextlib
|
|
22
|
+
from importlib import metadata as _metadata
|
|
23
|
+
|
|
24
|
+
from ._audit import Problem, audit
|
|
25
|
+
from ._codec import decode, encode
|
|
26
|
+
from ._editions import EDITIONS, LATEST
|
|
27
|
+
from ._errors import FileFormatError, NotFound, PktError, PktWarning, TagMismatch
|
|
28
|
+
from ._export import to_python
|
|
29
|
+
from ._iosconfig import refused
|
|
30
|
+
from ._model import (
|
|
31
|
+
Address,
|
|
32
|
+
Cable,
|
|
33
|
+
Device,
|
|
34
|
+
End,
|
|
35
|
+
Interface,
|
|
36
|
+
Link,
|
|
37
|
+
Network,
|
|
38
|
+
Pdu,
|
|
39
|
+
Port,
|
|
40
|
+
Ports,
|
|
41
|
+
Profile,
|
|
42
|
+
RsaKey,
|
|
43
|
+
load,
|
|
44
|
+
models,
|
|
45
|
+
new,
|
|
46
|
+
)
|
|
47
|
+
from ._modules import Slot, module_models
|
|
48
|
+
from ._passwords import matches as password_matches
|
|
49
|
+
from ._ports import canonical_port
|
|
50
|
+
from ._shapes import Note, Shape
|
|
51
|
+
from .lab import Fault, Lab, Result
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"EDITIONS",
|
|
55
|
+
"LATEST",
|
|
56
|
+
"Address",
|
|
57
|
+
"Cable",
|
|
58
|
+
"Device",
|
|
59
|
+
"End",
|
|
60
|
+
"Fault",
|
|
61
|
+
"FileFormatError",
|
|
62
|
+
"Interface",
|
|
63
|
+
"Lab",
|
|
64
|
+
"Link",
|
|
65
|
+
"Network",
|
|
66
|
+
"NotFound",
|
|
67
|
+
"Note",
|
|
68
|
+
"Pdu",
|
|
69
|
+
"PktError",
|
|
70
|
+
"PktWarning",
|
|
71
|
+
"Port",
|
|
72
|
+
"Ports",
|
|
73
|
+
"Problem",
|
|
74
|
+
"Profile",
|
|
75
|
+
"Result",
|
|
76
|
+
"RsaKey",
|
|
77
|
+
"Shape",
|
|
78
|
+
"Slot",
|
|
79
|
+
"TagMismatch",
|
|
80
|
+
"audit",
|
|
81
|
+
"canonical_port",
|
|
82
|
+
"decode",
|
|
83
|
+
"encode",
|
|
84
|
+
"load",
|
|
85
|
+
"models",
|
|
86
|
+
"module_models",
|
|
87
|
+
"new",
|
|
88
|
+
"password_matches",
|
|
89
|
+
"refused",
|
|
90
|
+
"to_python",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
# Public names say they're pktlib's, not a private module's: pktlib.NotFound in a
|
|
94
|
+
# traceback, and help(pktlib.Network) names the right place.
|
|
95
|
+
for _name in __all__:
|
|
96
|
+
_obj = globals()[_name]
|
|
97
|
+
if getattr(_obj, "__module__", "").startswith("pktlib."):
|
|
98
|
+
with _contextlib.suppress(AttributeError, TypeError): # typing aliases (Cable, Address) can't be
|
|
99
|
+
_obj.__module__ = __name__
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
__version__ = _metadata.version("pktlib")
|
|
103
|
+
except _metadata.PackageNotFoundError: # running from a source tree that isn't installed
|
|
104
|
+
__version__ = "0+unknown"
|
pktlib/__main__.py
ADDED
pktlib/_audit.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Lint a network before you hand it in: what can be checked without running
|
|
2
|
+
the simulation.
|
|
3
|
+
|
|
4
|
+
pktlib audit lab.pkt # from a shell; exits 1 if it finds anything
|
|
5
|
+
problems = pktlib.audit(net) # from Python
|
|
6
|
+
|
|
7
|
+
Duplicate addresses (MAC, IPv4, IPv6 link-local), config Packet Tracer 8.2 is
|
|
8
|
+
known to refuse, PC gateways nobody owns, the two ends of a link in different
|
|
9
|
+
subnets, cabled ports left shut down, serial DCE ends with no clock rate,
|
|
10
|
+
routers with more than one network and no routing at all, shared OSPF
|
|
11
|
+
router-ids, router ports given their subnet's network or broadcast address.
|
|
12
|
+
A clean audit doesn't prove a ping works: still test in Packet Tracer.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ipaddress
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
|
|
20
|
+
from ._iosconfig import blocks, refused
|
|
21
|
+
from ._model import Device, Network
|
|
22
|
+
|
|
23
|
+
GIGABIT_ONLY = {"PT-REPEATER-NM-1CGE": "PT-REPEATER-NM-1CFE"} # card -> the FastEthernet one to use
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Problem(str):
|
|
27
|
+
"""One thing the audit found: the sentence it prints, plus `devices`,
|
|
28
|
+
the names of the devices involved (for a grader to point at)."""
|
|
29
|
+
|
|
30
|
+
devices: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
def __new__(cls, text: str, *devices: str) -> Problem:
|
|
33
|
+
p = super().__new__(cls, text)
|
|
34
|
+
p.devices = tuple(dict.fromkeys(devices))
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def audit(net: Network) -> list[Problem]:
|
|
39
|
+
"""Everything wrong that can be seen in the file, one Problem each."""
|
|
40
|
+
problems: list[Problem] = []
|
|
41
|
+
owners: dict[str, list[tuple[str, str]]] = defaultdict(list) # ip -> [(device, port), ...]
|
|
42
|
+
router_ifaces: list[tuple[Device, str, ipaddress.IPv4Interface]] = []
|
|
43
|
+
gateways: set[str] = set() # addresses a host may use as its gateway, HSRP virtual IPs included
|
|
44
|
+
for d in net.devices:
|
|
45
|
+
lan = d.engine.findtext("LAN_IP_ADDRESS")
|
|
46
|
+
if lan: # a home router's LAN address, set in its GUI (192.168.0.1 out of the box)
|
|
47
|
+
gateways.add(lan)
|
|
48
|
+
if d.has_ios: # every interface in the config, sub-interfaces and SVIs included
|
|
49
|
+
for name, (addr, _up) in d.interfaces.items():
|
|
50
|
+
if addr:
|
|
51
|
+
try:
|
|
52
|
+
iface = ipaddress.IPv4Interface(addr)
|
|
53
|
+
except ValueError:
|
|
54
|
+
problems.append(Problem(f"{d.name}.{name}: {addr} isn't an IPv4 address and mask", d.name))
|
|
55
|
+
continue
|
|
56
|
+
owners[str(iface.ip)].append((d.name, name))
|
|
57
|
+
router_ifaces.append((d, name, iface))
|
|
58
|
+
# Packet Tracer 8.2 drops either silently when it loads the file and the
|
|
59
|
+
# port ends up with no address (checks 6 and 25, 27 Oct 2026)
|
|
60
|
+
nw = iface.network
|
|
61
|
+
if nw.prefixlen < 31 and iface.ip in (nw.network_address, nw.broadcast_address):
|
|
62
|
+
which = "network" if iface.ip == nw.network_address else "broadcast"
|
|
63
|
+
problems.append(
|
|
64
|
+
Problem(
|
|
65
|
+
f"{d.name}.{name}: {iface.ip} is the {which} address of {nw}; "
|
|
66
|
+
"Packet Tracer drops it, so the port has no address",
|
|
67
|
+
d.name,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
gateways.add(str(iface.ip))
|
|
71
|
+
for key, line in d.interface_config(name).items():
|
|
72
|
+
if key.startswith("standby ") and key.endswith(" ip"):
|
|
73
|
+
gateways.add(line.split()[-1])
|
|
74
|
+
elif line.startswith("ip address ") and line.endswith(" secondary"):
|
|
75
|
+
gateways.add(line.split()[2]) # a second subnet on the same port
|
|
76
|
+
else:
|
|
77
|
+
for p in d:
|
|
78
|
+
if p.address:
|
|
79
|
+
owners[str(ipaddress.IPv4Interface(p.address).ip)].append((d.name, p.name))
|
|
80
|
+
|
|
81
|
+
for mac, who in net.duplicate_macs().items():
|
|
82
|
+
problems.append(
|
|
83
|
+
Problem(
|
|
84
|
+
f"MAC {mac} is used by more than one device: {', '.join(who)}",
|
|
85
|
+
*(w.split()[0].split(".")[0] for w in who),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
for ll, who in net.duplicate_link_locals().items():
|
|
89
|
+
problems.append(
|
|
90
|
+
Problem(
|
|
91
|
+
f"IPv6 link-local {ll} is on more than one device: {', '.join(who)}", *(w.split(".")[0] for w in who)
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
# two routers with one OSPF router-id: PT left the link in EXSTART and no
|
|
95
|
+
# OSPF route anywhere (the solar system example, 26 Oct 2026)
|
|
96
|
+
rids: dict[str, list[str]] = defaultdict(list)
|
|
97
|
+
for d in net.devices:
|
|
98
|
+
for header, body in blocks(d.running_config) if d.has_ios else []:
|
|
99
|
+
if header.startswith("router ospf "):
|
|
100
|
+
for line in body:
|
|
101
|
+
if line.startswith("router-id "):
|
|
102
|
+
rids[line.split()[1]].append(d.name)
|
|
103
|
+
for rid, names in rids.items():
|
|
104
|
+
if len(names) > 1:
|
|
105
|
+
problems.append(Problem(f"OSPF router-id {rid} is on more than one router: {', '.join(names)}", *names))
|
|
106
|
+
for d in net.devices:
|
|
107
|
+
for line, why in refused(d.running_config) if d.has_ios else []:
|
|
108
|
+
problems.append(Problem(f"{d.name}: `{line.strip()}` {why}", d.name))
|
|
109
|
+
|
|
110
|
+
for ip, where in owners.items():
|
|
111
|
+
if len(where) > 1:
|
|
112
|
+
problems.append(
|
|
113
|
+
Problem(
|
|
114
|
+
f"{ip} is on more than one port: {', '.join(f'{a}.{b}' for a, b in where)}", *(a for a, _ in where)
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
for d in net.devices:
|
|
119
|
+
if d.has_ios or d.dhcp or d.kind == "IpPhone": # DHCP clients get theirs when the file opens
|
|
120
|
+
continue
|
|
121
|
+
for p in d:
|
|
122
|
+
if p.el.find("IP") is None or p.type in ("eCopperCoaxial", "eModem"): # no IP over coax or phone line
|
|
123
|
+
continue
|
|
124
|
+
if not p.address:
|
|
125
|
+
if p.link:
|
|
126
|
+
problems.append(Problem(f"{d.name}.{p.name} is cabled but has no IP", d.name))
|
|
127
|
+
continue
|
|
128
|
+
iface = ipaddress.IPv4Interface(p.address)
|
|
129
|
+
if not d.gateway:
|
|
130
|
+
# only a problem when there's a router address in its subnet to point at:
|
|
131
|
+
# a switch-only lab has no gateway to give (found by the CCNA examples)
|
|
132
|
+
if any(i.network == iface.network for _, _, i in router_ifaces):
|
|
133
|
+
problems.append(Problem(f"{d.name} has no default gateway", d.name))
|
|
134
|
+
elif d.gateway not in gateways:
|
|
135
|
+
problems.append(Problem(f"{d.name}'s gateway {d.gateway} isn't any router's address", d.name))
|
|
136
|
+
elif ipaddress.IPv4Address(d.gateway) not in iface.network:
|
|
137
|
+
problems.append(Problem(f"{d.name}'s gateway {d.gateway} is outside {iface.network}", d.name))
|
|
138
|
+
|
|
139
|
+
for link in net.links:
|
|
140
|
+
pa, pb = link.ports
|
|
141
|
+
(a, ap), (b, bp) = link.ends
|
|
142
|
+
for p in (pa, pb):
|
|
143
|
+
if p.device.has_ios and p.device.kind == "Router" and not p.up:
|
|
144
|
+
problems.append(Problem(f"{p.device.name}.{p.name} is cabled but shut down", p.device.name))
|
|
145
|
+
if pa.address and pb.address:
|
|
146
|
+
na, nb = ipaddress.IPv4Interface(pa.address).network, ipaddress.IPv4Interface(pb.address).network
|
|
147
|
+
if na != nb:
|
|
148
|
+
problems.append(
|
|
149
|
+
Problem(f"{a}.{ap} ({na}) and {b}.{bp} ({nb}) are cabled together but in different subnets", a, b)
|
|
150
|
+
)
|
|
151
|
+
for p, other in ((pa, pb), (pb, pa)):
|
|
152
|
+
# the AccessPoint-PT-AC's stock card won't step down to 100 Mb/s
|
|
153
|
+
# (seen in Packet Tracer 8.2: red to Fa0/1 and to a PC, green to Gi0/1)
|
|
154
|
+
if p.module in GIGABIT_ONLY and other.type != "eCopperGigabitEthernet":
|
|
155
|
+
problems.append(
|
|
156
|
+
Problem(
|
|
157
|
+
f"{p.device.name}.{p.name} ({p.module}) only links to gigabit ports, not "
|
|
158
|
+
f"{other.device.name}.{other.name}; fit {GIGABIT_ONLY[p.module]} instead",
|
|
159
|
+
p.device.name,
|
|
160
|
+
other.device.name,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
if link.cable == "serial" and link.dce:
|
|
164
|
+
dce = net.device(link.dce.device)[link.dce.port]
|
|
165
|
+
if not dce.setting("clock rate"):
|
|
166
|
+
problems.append(
|
|
167
|
+
Problem(f"{dce.device.name}.{dce.name} is the DCE end with no clock rate", dce.device.name)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
routers = [d for d in net.devices if d.kind == "Router"]
|
|
171
|
+
for d in routers if len(routers) > 1 else []: # one router routes between its own networks
|
|
172
|
+
nets = {str(i.network) for dev, _, i in router_ifaces if dev.name == d.name}
|
|
173
|
+
routing = any(line.startswith(("router ", "ip route ")) for line in d.running_config)
|
|
174
|
+
if len(nets) > 1 and not routing:
|
|
175
|
+
problems.append(
|
|
176
|
+
Problem(f"{d.name} has {len(nets)} networks but no routing protocol or static route", d.name)
|
|
177
|
+
)
|
|
178
|
+
return problems
|
pktlib/_cli.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""pktlib command line.
|
|
2
|
+
|
|
3
|
+
pktlib decode in.pkt [out.xml] .pkt -> Packet Tracer XML
|
|
4
|
+
pktlib encode in.xml out.pkt XML -> .pkt
|
|
5
|
+
pktlib info in.pkt devices, ports in use, links, addresses
|
|
6
|
+
pktlib audit in.pkt what's wrong that can be seen in the file (exit 1 if anything)
|
|
7
|
+
pktlib dump in.pkt [out.py] .pkt -> a Python script that rebuilds it
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ._audit import audit
|
|
17
|
+
from ._codec import decode, encode
|
|
18
|
+
from ._errors import PktError
|
|
19
|
+
from ._export import to_python
|
|
20
|
+
from ._model import Network, load
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _out(path: str | None, data: bytes | str) -> None:
|
|
24
|
+
if path is None:
|
|
25
|
+
sys.stdout.write(data if isinstance(data, str) else data.decode("utf-8", "replace"))
|
|
26
|
+
elif isinstance(data, str):
|
|
27
|
+
Path(path).write_text(data, encoding="utf-8")
|
|
28
|
+
else:
|
|
29
|
+
Path(path).write_bytes(data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _info(path: str) -> str:
|
|
33
|
+
net = load(path, strict=False) # reading only: say what it is, whatever version
|
|
34
|
+
edition = f"edition {net.edition}" if net.edition else "an edition pktlib doesn't know"
|
|
35
|
+
lines = [f"Packet Tracer {net.version} ({edition})", ""]
|
|
36
|
+
for d in net.devices:
|
|
37
|
+
x, y = d.xy
|
|
38
|
+
lines.append(f"{d.name} {d.kind} {d.model} at {x:g},{y:g}")
|
|
39
|
+
for p in d.ports.values():
|
|
40
|
+
link = p.link
|
|
41
|
+
if link is None and not p.ip:
|
|
42
|
+
continue
|
|
43
|
+
addr = f" {p.address}" if p.address else ""
|
|
44
|
+
peer = ""
|
|
45
|
+
if link is not None:
|
|
46
|
+
other = link.other(p)
|
|
47
|
+
peer = f" -> {other.device.name}.{other.name} ({link.cable})"
|
|
48
|
+
lines.append(f" {p.name}{addr}{peer}")
|
|
49
|
+
if not d.has_ios and d.gateway:
|
|
50
|
+
lines.append(f" gateway {d.gateway}")
|
|
51
|
+
return "\n".join(lines) + "\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _audit_files(paths: list[str]) -> int:
|
|
55
|
+
"""Audit each file; exit 1 if any has a problem or can't be read."""
|
|
56
|
+
bad = 0
|
|
57
|
+
for path in paths:
|
|
58
|
+
try:
|
|
59
|
+
found = audit(load(path))
|
|
60
|
+
except PktError as e:
|
|
61
|
+
print(f"pktlib: {path}: {e}", file=sys.stderr)
|
|
62
|
+
bad += 1
|
|
63
|
+
continue
|
|
64
|
+
except OSError as e:
|
|
65
|
+
print(f"pktlib: {path}: {e.strerror or e}", file=sys.stderr)
|
|
66
|
+
bad += 1
|
|
67
|
+
continue
|
|
68
|
+
lines = "".join(f" x {p}\n" for p in found)
|
|
69
|
+
_out(None, lines + (f"{len(found)} problem(s) in {path}\n" if found else f"{path}: nothing found\n"))
|
|
70
|
+
bad += bool(found)
|
|
71
|
+
if len(paths) > 1:
|
|
72
|
+
_out(None, f"{len(paths) - bad} of {len(paths)} files clean\n")
|
|
73
|
+
return 1 if bad else 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: list[str] | None = None) -> int:
|
|
77
|
+
"""Run the command line; returns the exit code."""
|
|
78
|
+
ap = argparse.ArgumentParser(prog="pktlib", description="Work with Cisco Packet Tracer .pkt files.")
|
|
79
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
80
|
+
for name, nargs in (("decode", "?"), ("dump", "?")):
|
|
81
|
+
p = sub.add_parser(name)
|
|
82
|
+
p.add_argument("src")
|
|
83
|
+
p.add_argument("dst", nargs=nargs)
|
|
84
|
+
p = sub.add_parser("encode")
|
|
85
|
+
p.add_argument("src")
|
|
86
|
+
p.add_argument("dst")
|
|
87
|
+
sub.add_parser("info").add_argument("src")
|
|
88
|
+
sub.add_parser("audit").add_argument("src", nargs="+")
|
|
89
|
+
args = ap.parse_args(argv)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
if args.cmd == "decode":
|
|
93
|
+
_out(args.dst, decode(Path(args.src).read_bytes()))
|
|
94
|
+
elif args.cmd == "encode":
|
|
95
|
+
xml = Path(args.src).read_bytes()
|
|
96
|
+
Network.from_xml(xml, strict=False) # refuse XML Packet Tracer couldn't open
|
|
97
|
+
_out(args.dst, encode(xml))
|
|
98
|
+
elif args.cmd == "info":
|
|
99
|
+
_out(None, _info(args.src))
|
|
100
|
+
elif args.cmd == "audit":
|
|
101
|
+
return _audit_files(args.src)
|
|
102
|
+
elif args.cmd == "dump":
|
|
103
|
+
out = Path(args.dst).with_suffix(".pkt").name if args.dst else "out.pkt"
|
|
104
|
+
_out(args.dst, to_python(load(args.src), out))
|
|
105
|
+
except BrokenPipeError: # `pktlib decode x.pkt | head`: the reader left, nothing to report
|
|
106
|
+
sys.stderr.close()
|
|
107
|
+
return 0
|
|
108
|
+
except PktError as e:
|
|
109
|
+
print(f"pktlib: {args.src}: {e}", file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
except OSError as e:
|
|
112
|
+
print(f"pktlib: {e.filename or args.src}: {e.strerror or e}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
except Exception as e: # noqa: BLE001 a bug in pktlib: say so, without a wall of traceback
|
|
115
|
+
print(
|
|
116
|
+
f"pktlib: {args.src}: internal error ({type(e).__name__}: {e}). Please report it at "
|
|
117
|
+
f"https://github.com/conor-sometimes/pktlib/issues",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
return 2
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
sys.exit(main())
|
pktlib/_codec.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Turn .pkt / .pka bytes into Packet Tracer's XML and back.
|
|
2
|
+
|
|
3
|
+
The layers (Packet Tracer 6 onwards, worked out by the pka2xml project):
|
|
4
|
+
|
|
5
|
+
1. the whole file is byte-reversed and XORed with (L - i*L) & 0xFF, L = file length
|
|
6
|
+
2. Twofish-EAX, key sixteen 0x89 bytes, nonce sixteen 0x10 bytes, 16-byte tag at the end
|
|
7
|
+
3. the plaintext is XORed with (n - i) & 0xFF, n = plaintext length
|
|
8
|
+
4. what is left is Qt's qCompress: a 4-byte big-endian length, then a zlib stream
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import zlib
|
|
14
|
+
|
|
15
|
+
from ._errors import FileFormatError
|
|
16
|
+
from ._twofish import eax_decrypt, eax_encrypt
|
|
17
|
+
|
|
18
|
+
KEY = b"\x89" * 16
|
|
19
|
+
NONCE = b"\x10" * 16
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _unscramble_outer(data: bytes) -> bytes:
|
|
23
|
+
n = len(data)
|
|
24
|
+
return bytes(data[n - 1 - i] ^ ((n - i * n) & 0xFF) for i in range(n))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _scramble_outer(data: bytes) -> bytes:
|
|
28
|
+
n = len(data)
|
|
29
|
+
out = bytearray(n)
|
|
30
|
+
for i in range(n):
|
|
31
|
+
out[n - 1 - i] = data[i] ^ ((n - i * n) & 0xFF)
|
|
32
|
+
return bytes(out)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _xor_inner(data: bytes) -> bytes:
|
|
36
|
+
n = len(data)
|
|
37
|
+
return bytes(b ^ ((n - i) & 0xFF) for i, b in enumerate(data))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def decode(data: bytes | bytearray, *, verify: bool = True) -> bytes:
|
|
41
|
+
"""Encrypted .pkt bytes -> XML bytes."""
|
|
42
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
43
|
+
raise TypeError("decode() wants bytes")
|
|
44
|
+
plain = eax_decrypt(KEY, NONCE, _unscramble_outer(bytes(data)), verify=verify)
|
|
45
|
+
packed = _xor_inner(plain)
|
|
46
|
+
if len(packed) < 4:
|
|
47
|
+
raise FileFormatError("too short to be a .pkt")
|
|
48
|
+
size = int.from_bytes(packed[:4], "big")
|
|
49
|
+
try:
|
|
50
|
+
xml = zlib.decompress(packed[4:])
|
|
51
|
+
except zlib.error as e:
|
|
52
|
+
raise FileFormatError(f"not a .pkt (zlib: {e})") from None
|
|
53
|
+
if len(xml) != size:
|
|
54
|
+
raise FileFormatError(f"header says {size} bytes, zlib gave {len(xml)}")
|
|
55
|
+
return xml
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def encode(xml: bytes, level: int = 9) -> bytes:
|
|
59
|
+
"""XML bytes -> encrypted .pkt bytes Packet Tracer will open."""
|
|
60
|
+
if not isinstance(xml, (bytes, bytearray)):
|
|
61
|
+
raise TypeError("encode() wants bytes")
|
|
62
|
+
xml = bytes(xml)
|
|
63
|
+
packed = len(xml).to_bytes(4, "big") + zlib.compress(xml, level)
|
|
64
|
+
return _scramble_outer(eax_encrypt(KEY, NONCE, _xor_inner(packed)))
|
pktlib/_editions.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Editions: which Packet Tracer a file is for.
|
|
2
|
+
|
|
3
|
+
A .pkt file carries one version stamp, PACKETTRACER5/VERSION, e.g. 8.2.0.0162
|
|
4
|
+
(major.minor.patch.build). Packet Tracer 8.2.0 checks it on open (tried 25 Oct
|
|
5
|
+
2026): a newer stamp, even 8.2.2, is refused ("This file requires Cisco Packet
|
|
6
|
+
Tracer version ..."), an older one opens, and a stamp that isn't a version is
|
|
7
|
+
refused.
|
|
8
|
+
|
|
9
|
+
pktlib works like Rust editions: a network has an edition, and pktlib only
|
|
10
|
+
writes editions it has device templates and tested quirks for. Asking for any
|
|
11
|
+
other is an error, so pktlib can't produce a file stamped for a Packet Tracer
|
|
12
|
+
nobody has checked it against.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
from ._errors import FileFormatError, PktError
|
|
20
|
+
|
|
21
|
+
# edition -> the exact stamp Packet Tracer writes. One per release pktlib was
|
|
22
|
+
# tested on; add one only with templates and a test run in that release.
|
|
23
|
+
EDITIONS: dict[str, str] = {"8.2": "8.2.0.0162"}
|
|
24
|
+
LATEST = "8.2"
|
|
25
|
+
|
|
26
|
+
_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)\.(\d+)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse(version: str) -> tuple[int, int, int, int]:
|
|
30
|
+
"""'8.2.0.0162' -> (8, 2, 0, 162). Anything else is an error, as it is to
|
|
31
|
+
Packet Tracer."""
|
|
32
|
+
m = _VERSION_RE.fullmatch((version or "").strip())
|
|
33
|
+
if not m:
|
|
34
|
+
raise FileFormatError(f"{version!r} isn't a Packet Tracer version (like 8.2.0.0162)")
|
|
35
|
+
major, minor, patch, build = (int(g) for g in m.groups())
|
|
36
|
+
return major, minor, patch, build
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stamp(edition: str) -> str:
|
|
40
|
+
"""The version stamp pktlib writes for an edition."""
|
|
41
|
+
if not isinstance(edition, str):
|
|
42
|
+
raise TypeError(f"an edition is a string like '8.2', got {edition!r}")
|
|
43
|
+
if edition not in EDITIONS:
|
|
44
|
+
raise PktError(
|
|
45
|
+
f"pktlib doesn't know Packet Tracer edition {edition!r} (it knows {', '.join(sorted(EDITIONS))})"
|
|
46
|
+
)
|
|
47
|
+
return EDITIONS[edition]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def edition_of(version: str) -> str | None:
|
|
51
|
+
"""The edition a stamp belongs to ('8.2.0.0162' -> '8.2'), or None if pktlib
|
|
52
|
+
doesn't have that release. A later patch of the same minor (8.2.2) isn't
|
|
53
|
+
the 8.2 edition: Packet Tracer 8.2.0 refuses those files."""
|
|
54
|
+
v = parse(version)
|
|
55
|
+
name = f"{v[0]}.{v[1]}"
|
|
56
|
+
return name if name in EDITIONS and v <= parse(EDITIONS[name]) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def newer_than_known(version: str) -> bool:
|
|
60
|
+
"""True for a file from a Packet Tracer newer than any edition pktlib knows."""
|
|
61
|
+
newest = max(parse(v) for v in EDITIONS.values())
|
|
62
|
+
return parse(version) > newest
|
pktlib/_errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""pktlib's exceptions. Catch PktError for anything pktlib refuses.
|
|
2
|
+
|
|
3
|
+
PktError (a ValueError)
|
|
4
|
+
├── NotFound (also a KeyError): no such device, port, slot, VLAN, model...
|
|
5
|
+
└── FileFormatError: the bytes or XML aren't a .pkt pktlib can read
|
|
6
|
+
└── TagMismatch: the file's checksum (its EAX tag) is wrong
|
|
7
|
+
|
|
8
|
+
PktWarning (a UserWarning): done, but Packet Tracer may not take it
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PktError(ValueError):
|
|
15
|
+
"""Something asked of pktlib doesn't fit the network or the file."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFound(PktError, KeyError):
|
|
19
|
+
"""A lookup found nothing: `net["R9"]`, `r1["fa9/9"]`, a VLAN, a slot, a
|
|
20
|
+
model name. It is a KeyError too, so code written for dicts catches it."""
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
# KeyError's own __str__ quotes the message as if it were a key
|
|
24
|
+
return str(self.args[0]) if self.args else ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileFormatError(PktError):
|
|
28
|
+
"""The data isn't a .pkt pktlib can read: damaged, truncated, not Packet
|
|
29
|
+
Tracer's, or from a release pktlib refuses (see strict=False)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TagMismatch(FileFormatError):
|
|
33
|
+
"""The EAX tag didn't verify: the file is damaged or isn't a .pkt."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PktWarning(UserWarning):
|
|
37
|
+
"""pktlib did what was asked, but Packet Tracer may not: a config line it
|
|
38
|
+
is known to drop, a VLAN still in use, and the like."""
|