packet-tracer-skill 0.3.0 → 0.3.2
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/CHANGELOG.md +116 -0
- package/README.md +55 -9
- package/SKILL.md +214 -2
- package/package.json +2 -1
- package/scripts/build_sample_catalog.py +42 -0
- package/scripts/generate_pkt.py +4408 -187
- package/scripts/intent_parser.py +31 -4
- package/scripts/lab_coherence.py +455 -0
- package/scripts/pkt_editor.py +117 -35
- package/scripts/pkt_transformer.py +99 -1
- package/scripts/sample_catalog.py +65 -12
- package/scripts/session_log.py +325 -0
- package/scripts/usage_ledger.py +230 -218
package/scripts/intent_parser.py
CHANGED
|
@@ -212,8 +212,15 @@ NATURAL_DEVICE_ALIASES = {
|
|
|
212
212
|
"iot device", "iot cihaz", "smart device", "agilli cihaz", "sensor",
|
|
213
213
|
"smart light", "smart door", "motion sensor", "temperature sensor",
|
|
214
214
|
"smoke detector", "fan", "lamp", "termostat", "thermostat",
|
|
215
|
+
"camera", "kamera", "cctv", "webcam", "iot",
|
|
215
216
|
],
|
|
216
|
-
|
|
217
|
+
# A camera is an IoT thing in Packet Tracer, not a kind of its own. The
|
|
218
|
+
# `CCTVCamera` key it used to carry is one the planner produced and no lab
|
|
219
|
+
# contains: counted across the 642 catalogued samples, zero devices of any
|
|
220
|
+
# camera-like type, against 439 IoT things. It was the same defect the
|
|
221
|
+
# `Smartphone` and `Cable Modem` notes describe -- an askable kind with no
|
|
222
|
+
# donor behind it, so asking for a camera could only ever be reported back
|
|
223
|
+
# as an undelivered device.
|
|
217
224
|
"Hub": ["hub", "konsentrator"],
|
|
218
225
|
"Repeater": ["repeater", "təkrarlayıcı", "tekrarlayici"],
|
|
219
226
|
# Added from what the donor pool actually holds, not from Packet Tracer's
|
|
@@ -268,6 +275,10 @@ NATURAL_DEVICE_ALIASES = {
|
|
|
268
275
|
#
|
|
269
276
|
# So a palette of 74 entries is not 74 askable kinds. These four are the
|
|
270
277
|
# ones that survive as distinct types in a saved lab.
|
|
278
|
+
# 45 devices across 37 of the bundled samples, and no way to ask for one
|
|
279
|
+
# until now: `MCU` survives as its own type in a saved lab, unlike the
|
|
280
|
+
# palette entries folded away above.
|
|
281
|
+
"MCU": ["mcu", "microcontroller", "mikrokontroller", "micro controller"],
|
|
271
282
|
"SBC": ["sbc", "single board computer", "raspberry", "raspberry pi"],
|
|
272
283
|
"WiredEndDevice": ["wired end device", "simli son cihaz"],
|
|
273
284
|
"WirelessEndDevice": ["wireless end device", "simsiz son cihaz"],
|
|
@@ -434,11 +445,21 @@ def _number_word_pattern() -> re.Pattern[str]:
|
|
|
434
445
|
preposition in English, so a bare substitution turns `dhcp on router` into
|
|
435
446
|
`dhcp 10 router` and silently orders ten routers. Requiring a device alias
|
|
436
447
|
next confines the rewrite to phrases that are actually counts.
|
|
448
|
+
|
|
449
|
+
A group noun counts too. `2 ofis, her ofisde 1 switch ve 4 komputer` builds
|
|
450
|
+
two offices with four PCs each; the same sentence written `iki ofis` built
|
|
451
|
+
one office with four, because `ofis` is not a device alias so `iki` was
|
|
452
|
+
never turned into a digit and the group extractor, which matches `\\d+`,
|
|
453
|
+
found nothing. Both spellings now mean the same thing.
|
|
437
454
|
"""
|
|
438
455
|
words = "|".join(sorted(NUMBER_WORDS, key=len, reverse=True))
|
|
439
|
-
|
|
440
|
-
|
|
456
|
+
followers = "|".join(
|
|
457
|
+
(
|
|
458
|
+
_any_device_alias_pattern(),
|
|
459
|
+
_group_noun_alternation(GROUP_NOUN_STEMS + GROUP_NOUN_LOCATIVES),
|
|
460
|
+
)
|
|
441
461
|
)
|
|
462
|
+
return re.compile(rf"\b({words})\b(?=\s+(?:dene|eded|tane)?\s*(?:{followers})\b)")
|
|
442
463
|
|
|
443
464
|
|
|
444
465
|
def _digits_for_number_words(text: str) -> str:
|
|
@@ -783,8 +804,14 @@ def _extract_per_department_devices(normalized_prompt: str) -> dict[str, int]:
|
|
|
783
804
|
stems = _group_noun_alternation(GROUP_NOUN_STEMS)
|
|
784
805
|
locatives = _group_noun_alternation(GROUP_NOUN_LOCATIVES)
|
|
785
806
|
group_segment = ""
|
|
807
|
+
# The segment ends at a capability word, because `her ofisde 4 komputer,
|
|
808
|
+
# dhcp ile` describes one office and then the whole lab. `router` used to
|
|
809
|
+
# end it too, which truncated any per-office list that happens to start
|
|
810
|
+
# with one: `her ofisde 1 router 1 switch 4 komputer` left a segment of
|
|
811
|
+
# `1 ` and the four PCs were counted once for the whole lab instead of once
|
|
812
|
+
# per office. A router is a device in that sentence, not a capability.
|
|
786
813
|
segment_match = re.search(
|
|
787
|
-
rf"\bher\s+(?:{locatives})\s+(.+?)(?=\b(?:
|
|
814
|
+
rf"\bher\s+(?:{locatives})\s+(.+?)(?=\b(?:dhcp|vlan|ssid|acl|telnet|ospf|eigrp|rip|nat)\b|$)",
|
|
788
815
|
normalized_prompt,
|
|
789
816
|
)
|
|
790
817
|
if segment_match:
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Cross-check the facts a lab states about itself in more than one place.
|
|
2
|
+
|
|
3
|
+
Every defect this project has paid for has the same shape: one fact derived
|
|
4
|
+
twice, in two passes, with nothing comparing the derivations. A port's VLAN and
|
|
5
|
+
its host's address. A pool's network and the interface that serves it. A
|
|
6
|
+
bundle's members and the cables that join them. Each half read correctly on its
|
|
7
|
+
own, the lab opened, every static check passed, and nothing could reach
|
|
8
|
+
anything.
|
|
9
|
+
|
|
10
|
+
The checks below are not a style guide. Each one is a contradiction that was
|
|
11
|
+
measured in a generated lab and cost a working network:
|
|
12
|
+
|
|
13
|
+
* a printer in VLAN 200 holding a 192.168.110.x address, because the port's
|
|
14
|
+
VLAN and the host's address were assigned by different passes
|
|
15
|
+
* four routers carrying 10.10.10.1 as a real interface address while it is
|
|
16
|
+
also the HSRP virtual address on a fifth
|
|
17
|
+
* `channel-group 1 mode on` on a port whose peer was not bundling, which took
|
|
18
|
+
the switch behind it off the network
|
|
19
|
+
* a DHCP pool for a network no interface serves
|
|
20
|
+
* two cables on one switch port
|
|
21
|
+
* a config block for an interface the device does not have
|
|
22
|
+
|
|
23
|
+
Findings are reported, never repaired: a checker that fixes what it finds stops
|
|
24
|
+
being able to tell you whether the thing it checks is working.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
import xml.etree.ElementTree as ET
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
SWITCH_TYPES = {"Switch", "MultiLayerSwitch"}
|
|
34
|
+
HOST_TYPES = {"Pc", "Laptop", "Server", "Printer", "IpPhone", "Tablet", "Smartphone"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Finding:
|
|
39
|
+
"""One contradiction, named by the two things that disagree."""
|
|
40
|
+
|
|
41
|
+
kind: str
|
|
42
|
+
where: str
|
|
43
|
+
detail: str
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return f"{self.kind}: {self.where} -- {self.detail}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _address_to_int(text: str) -> int | None:
|
|
50
|
+
parts = (text or "").strip().split(".")
|
|
51
|
+
if len(parts) != 4:
|
|
52
|
+
return None
|
|
53
|
+
value = 0
|
|
54
|
+
for part in parts:
|
|
55
|
+
if not part.isdigit() or not 0 <= int(part) <= 255:
|
|
56
|
+
return None
|
|
57
|
+
value = (value << 8) | int(part)
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _network_of(address: str, mask: str) -> tuple[int, int] | None:
|
|
62
|
+
left, right = _address_to_int(address), _address_to_int(mask)
|
|
63
|
+
if left is None or right is None:
|
|
64
|
+
return None
|
|
65
|
+
return left & right, right
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _name(device: ET.Element) -> str:
|
|
69
|
+
return (device.findtext("./ENGINE/NAME") or "").strip()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _kind(device: ET.Element) -> str:
|
|
73
|
+
return (device.findtext("./ENGINE/TYPE") or "").strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _config_blocks(device: ET.Element) -> dict[str, list[str]]:
|
|
77
|
+
"""Interface name -> the lines inside its block, in file order."""
|
|
78
|
+
blocks: dict[str, list[str]] = {}
|
|
79
|
+
current = ""
|
|
80
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
81
|
+
text = (node.text or "").strip()
|
|
82
|
+
if text.startswith("interface "):
|
|
83
|
+
current = text.split(None, 1)[1]
|
|
84
|
+
blocks.setdefault(current, [])
|
|
85
|
+
elif current and text and not text.startswith("!"):
|
|
86
|
+
blocks.setdefault(current, []).append(text)
|
|
87
|
+
elif text.startswith("!"):
|
|
88
|
+
current = ""
|
|
89
|
+
return blocks
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _interface_addresses(device: ET.Element) -> list[tuple[str, str, str]]:
|
|
93
|
+
"""(port, address, mask) for every configured interface address."""
|
|
94
|
+
found: list[tuple[str, str, str]] = []
|
|
95
|
+
for port, body in _config_blocks(device).items():
|
|
96
|
+
for line in body:
|
|
97
|
+
match = re.match(r"^ip address (\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$", line)
|
|
98
|
+
if match:
|
|
99
|
+
found.append((port, match.group(1), match.group(2)))
|
|
100
|
+
return found
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _standby_addresses(device: ET.Element) -> list[tuple[str, str]]:
|
|
104
|
+
"""(port, virtual address) for every HSRP group."""
|
|
105
|
+
found: list[tuple[str, str]] = []
|
|
106
|
+
for port, body in _config_blocks(device).items():
|
|
107
|
+
for line in body:
|
|
108
|
+
match = re.match(r"^standby \d+ ip (\d+\.\d+\.\d+\.\d+)$", line)
|
|
109
|
+
if match:
|
|
110
|
+
found.append((port, match.group(1)))
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _host_addresses(device: ET.Element) -> list[tuple[str, str, str]]:
|
|
115
|
+
"""(port name, address, mask) a host really uses.
|
|
116
|
+
|
|
117
|
+
A port with `PORT_DHCP_ENABLE` set is a DHCP client: Packet Tracer ignores
|
|
118
|
+
whatever address and gateway the file still carries and leases new ones at
|
|
119
|
+
runtime. Reading those stale fields made the checker report 82 hosts in the
|
|
120
|
+
enterprise lab as pointing at a gateway nobody answered for, when 78 of
|
|
121
|
+
them were simply waiting for a lease. The checker had the defect it looks
|
|
122
|
+
for -- a fact read from one place while the device is governed by another.
|
|
123
|
+
"""
|
|
124
|
+
found: list[tuple[str, str, str]] = []
|
|
125
|
+
for port in device.findall(".//PORT"):
|
|
126
|
+
if (port.findtext("PORT_DHCP_ENABLE") or "").strip().lower() == "true":
|
|
127
|
+
continue
|
|
128
|
+
address = (port.findtext("IP") or "").strip()
|
|
129
|
+
mask = (port.findtext("SUBNET") or "").strip()
|
|
130
|
+
if address and address != "0.0.0.0":
|
|
131
|
+
found.append(((port.findtext("NAME") or "").strip(), address, mask))
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cabled_ports(root: ET.Element) -> dict[tuple[str, str], list[tuple[str, str]]]:
|
|
136
|
+
"""(device, port) -> the far ends cabled to it. More than one is a defect."""
|
|
137
|
+
by_ref = {
|
|
138
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
139
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
140
|
+
}
|
|
141
|
+
ends: dict[tuple[str, str], list[tuple[str, str]]] = {}
|
|
142
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
143
|
+
cable = link.find("./CABLE")
|
|
144
|
+
if cable is None:
|
|
145
|
+
continue
|
|
146
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
147
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
148
|
+
if len(ports) < 2:
|
|
149
|
+
continue
|
|
150
|
+
left, right = by_ref.get(refs[0]), by_ref.get(refs[1])
|
|
151
|
+
if left is None or right is None:
|
|
152
|
+
continue
|
|
153
|
+
ends.setdefault((_name(left), ports[0]), []).append((_name(right), ports[1]))
|
|
154
|
+
ends.setdefault((_name(right), ports[1]), []).append((_name(left), ports[0]))
|
|
155
|
+
return ends
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_addresses_are_unique(devices: list[ET.Element]) -> list[Finding]:
|
|
159
|
+
"""No address may be held by two interfaces, or double as a virtual one.
|
|
160
|
+
|
|
161
|
+
Measured on the enterprise lab: R6, R7 and R8 each carried 10.10.10.1 on a
|
|
162
|
+
real subinterface while R1 offered the same address as its HSRP virtual
|
|
163
|
+
gateway. Nothing complained, because the pass that wrote the subinterfaces
|
|
164
|
+
and the pass that wrote HSRP each read correctly on its own.
|
|
165
|
+
"""
|
|
166
|
+
real: dict[str, list[str]] = {}
|
|
167
|
+
virtual: dict[str, list[str]] = {}
|
|
168
|
+
for device in devices:
|
|
169
|
+
for port, address, _mask in _interface_addresses(device):
|
|
170
|
+
real.setdefault(address, []).append(f"{_name(device)}:{port}")
|
|
171
|
+
if _kind(device) in HOST_TYPES:
|
|
172
|
+
# A router's PORT node mirrors the address its config already
|
|
173
|
+
# states. Counting both made every router interface look like two
|
|
174
|
+
# holders of one address -- the checker's own version of the defect
|
|
175
|
+
# it looks for.
|
|
176
|
+
for _port, address, _mask in _host_addresses(device):
|
|
177
|
+
real.setdefault(address, []).append(_name(device))
|
|
178
|
+
for port, address in _standby_addresses(device):
|
|
179
|
+
virtual.setdefault(address, []).append(f"{_name(device)}:{port}")
|
|
180
|
+
|
|
181
|
+
findings: list[Finding] = []
|
|
182
|
+
for address, holders in sorted(real.items()):
|
|
183
|
+
if len(holders) > 1:
|
|
184
|
+
findings.append(
|
|
185
|
+
Finding("duplicate_address", address, f"held by {', '.join(sorted(holders))}")
|
|
186
|
+
)
|
|
187
|
+
if address in virtual:
|
|
188
|
+
findings.append(
|
|
189
|
+
Finding(
|
|
190
|
+
"real_address_is_also_virtual",
|
|
191
|
+
address,
|
|
192
|
+
f"real on {', '.join(sorted(holders))}; HSRP virtual on "
|
|
193
|
+
f"{', '.join(sorted(virtual[address]))}",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return findings
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_ports_carry_one_cable(root: ET.Element) -> list[Finding]:
|
|
200
|
+
"""One socket, one cable. Two clones once shared a port and the lab refused."""
|
|
201
|
+
findings: list[Finding] = []
|
|
202
|
+
for (device, port), ends in sorted(_cabled_ports(root).items()):
|
|
203
|
+
if len(ends) > 1:
|
|
204
|
+
joined = ", ".join(f"{name}:{far}" for name, far in sorted(ends))
|
|
205
|
+
findings.append(Finding("port_double_booked", f"{device}:{port}", f"cabled to {joined}"))
|
|
206
|
+
return findings
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _check_config_names_real_ports(devices: list[ET.Element]) -> list[Finding]:
|
|
210
|
+
"""Packet Tracer refuses a lab naming an interface the device does not have."""
|
|
211
|
+
from pkt_transformer import port_exists
|
|
212
|
+
|
|
213
|
+
findings: list[Finding] = []
|
|
214
|
+
for device in devices:
|
|
215
|
+
for port in _config_blocks(device):
|
|
216
|
+
if port.startswith(("Vlan", "Loopback", "Port-channel", "Tunnel")) or "." in port:
|
|
217
|
+
continue
|
|
218
|
+
if not port_exists(device, port):
|
|
219
|
+
findings.append(
|
|
220
|
+
Finding("port_not_on_device", f"{_name(device)}:{port}", f"a {_kind(device)} has no such interface")
|
|
221
|
+
)
|
|
222
|
+
return findings
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _check_trunk_ends_agree(root: ET.Element, devices: list[ET.Element]) -> list[Finding]:
|
|
226
|
+
"""A native VLAN mismatch makes spanning tree block a cabled, configured port."""
|
|
227
|
+
by_name = {_name(device): device for device in devices}
|
|
228
|
+
blocks = {name: _config_blocks(device) for name, device in by_name.items()}
|
|
229
|
+
|
|
230
|
+
def native(name: str, port: str) -> str:
|
|
231
|
+
for line in blocks.get(name, {}).get(port, []):
|
|
232
|
+
match = re.match(r"^switchport trunk native vlan (\d+)$", line)
|
|
233
|
+
if match:
|
|
234
|
+
return match.group(1)
|
|
235
|
+
return "1"
|
|
236
|
+
|
|
237
|
+
def is_trunk(name: str, port: str) -> bool:
|
|
238
|
+
return "switchport mode trunk" in blocks.get(name, {}).get(port, [])
|
|
239
|
+
|
|
240
|
+
findings: list[Finding] = []
|
|
241
|
+
seen: set[tuple[str, str]] = set()
|
|
242
|
+
for (device, port), ends in sorted(_cabled_ports(root).items()):
|
|
243
|
+
for far_device, far_port in ends:
|
|
244
|
+
key = tuple(sorted(((device, port), (far_device, far_port))))
|
|
245
|
+
if key in seen:
|
|
246
|
+
continue
|
|
247
|
+
seen.add(key)
|
|
248
|
+
if _kind(by_name.get(device, ET.Element("x"))) not in SWITCH_TYPES:
|
|
249
|
+
continue
|
|
250
|
+
if _kind(by_name.get(far_device, ET.Element("x"))) not in SWITCH_TYPES:
|
|
251
|
+
continue
|
|
252
|
+
if not (is_trunk(device, port) and is_trunk(far_device, far_port)):
|
|
253
|
+
continue
|
|
254
|
+
here, there = native(device, port), native(far_device, far_port)
|
|
255
|
+
if here != there:
|
|
256
|
+
findings.append(
|
|
257
|
+
Finding(
|
|
258
|
+
"native_vlan_mismatch",
|
|
259
|
+
f"{device}:{port} <-> {far_device}:{far_port}",
|
|
260
|
+
f"native VLAN {here} against {there}",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return findings
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _check_etherchannel_members_are_paired(root: ET.Element, devices: list[ET.Element]) -> list[Finding]:
|
|
267
|
+
"""A channel-group whose peer does not bundle takes the switch off the network."""
|
|
268
|
+
blocks = {_name(device): _config_blocks(device) for device in devices}
|
|
269
|
+
|
|
270
|
+
def bundles(name: str, port: str) -> bool:
|
|
271
|
+
return any(line.startswith("channel-group ") for line in blocks.get(name, {}).get(port, []))
|
|
272
|
+
|
|
273
|
+
findings: list[Finding] = []
|
|
274
|
+
cabled = _cabled_ports(root)
|
|
275
|
+
for (device, port), ends in sorted(cabled.items()):
|
|
276
|
+
if not bundles(device, port):
|
|
277
|
+
continue
|
|
278
|
+
if not ends:
|
|
279
|
+
findings.append(Finding("etherchannel_member_uncabled", f"{device}:{port}", "bundled but no cable"))
|
|
280
|
+
continue
|
|
281
|
+
for far_device, far_port in ends:
|
|
282
|
+
if not bundles(far_device, far_port):
|
|
283
|
+
findings.append(
|
|
284
|
+
Finding(
|
|
285
|
+
"etherchannel_peer_does_not_bundle",
|
|
286
|
+
f"{device}:{port}",
|
|
287
|
+
f"peer {far_device}:{far_port} carries no channel-group",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
for device in devices:
|
|
291
|
+
for port, body in _config_blocks(device).items():
|
|
292
|
+
if any(line.startswith("channel-group ") for line in body) and (_name(device), port) not in cabled:
|
|
293
|
+
findings.append(
|
|
294
|
+
Finding("etherchannel_member_uncabled", f"{_name(device)}:{port}", "bundled but no cable")
|
|
295
|
+
)
|
|
296
|
+
return findings
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _check_hosts_can_reach_their_gateway(devices: list[ET.Element]) -> list[Finding]:
|
|
300
|
+
"""A host's address, its mask and its gateway have to describe one network.
|
|
301
|
+
|
|
302
|
+
Measured on the enterprise lab: a workstation sat on a port in VLAN 200 and
|
|
303
|
+
carried 192.168.110.12, because the pass that placed ports in VLANs and the
|
|
304
|
+
pass that handed out addresses each had its own plan and neither read the
|
|
305
|
+
other's. The lab opened and the host could reach nothing off its subnet.
|
|
306
|
+
"""
|
|
307
|
+
served: set[tuple[int, int]] = set()
|
|
308
|
+
gateways: set[str] = set()
|
|
309
|
+
for device in devices:
|
|
310
|
+
for _port, address, mask in _interface_addresses(device):
|
|
311
|
+
network = _network_of(address, mask)
|
|
312
|
+
if network is not None:
|
|
313
|
+
served.add(network)
|
|
314
|
+
gateways.add(address)
|
|
315
|
+
for _port, address in _standby_addresses(device):
|
|
316
|
+
gateways.add(address)
|
|
317
|
+
# A home router's LAN address is a setting, not a config line, so
|
|
318
|
+
# reading only running configs made it invisible: the checker went on
|
|
319
|
+
# reporting `gateway_answers_for_nobody` against a router that had just
|
|
320
|
+
# been moved onto exactly that address. Packet Tracer puts it on
|
|
321
|
+
# `Vlan1`, which the live device reports as a real interface.
|
|
322
|
+
lan = (device.findtext("./ENGINE/LAN_IP_ADDRESS") or "").strip()
|
|
323
|
+
lan_mask = (device.findtext("./ENGINE/LAN_SUBNET_MASK") or "").strip()
|
|
324
|
+
if lan and lan != "0.0.0.0":
|
|
325
|
+
gateways.add(lan)
|
|
326
|
+
network = _network_of(lan, lan_mask) if lan_mask else None
|
|
327
|
+
if network is not None:
|
|
328
|
+
served.add(network)
|
|
329
|
+
|
|
330
|
+
findings: list[Finding] = []
|
|
331
|
+
for device in devices:
|
|
332
|
+
if _kind(device) not in HOST_TYPES:
|
|
333
|
+
continue
|
|
334
|
+
gateway = (device.findtext(".//GATEWAY") or "").strip()
|
|
335
|
+
for _port, address, mask in _host_addresses(device):
|
|
336
|
+
if not gateway or gateway == "0.0.0.0":
|
|
337
|
+
findings.append(Finding("host_without_gateway", _name(device), f"{address} has no default gateway"))
|
|
338
|
+
continue
|
|
339
|
+
here, there = _network_of(address, mask), _network_of(gateway, mask)
|
|
340
|
+
if here is None or there is None or here != there:
|
|
341
|
+
findings.append(
|
|
342
|
+
Finding(
|
|
343
|
+
"gateway_off_host_subnet",
|
|
344
|
+
_name(device),
|
|
345
|
+
f"{address}/{mask} cannot reach gateway {gateway}",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
continue
|
|
349
|
+
if gateway not in gateways:
|
|
350
|
+
findings.append(
|
|
351
|
+
Finding("gateway_answers_for_nobody", _name(device), f"no interface holds {gateway}")
|
|
352
|
+
)
|
|
353
|
+
return findings
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _check_pools_have_an_interface(devices: list[ET.Element]) -> list[Finding]:
|
|
357
|
+
"""A pool for a network no interface serves hands out unreachable addresses."""
|
|
358
|
+
served: set[tuple[int, int]] = set()
|
|
359
|
+
for device in devices:
|
|
360
|
+
for _port, address, mask in _interface_addresses(device):
|
|
361
|
+
network = _network_of(address, mask)
|
|
362
|
+
if network is not None:
|
|
363
|
+
served.add(network)
|
|
364
|
+
|
|
365
|
+
findings: list[Finding] = []
|
|
366
|
+
for device in devices:
|
|
367
|
+
pool = ""
|
|
368
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
369
|
+
text = (node.text or "").strip()
|
|
370
|
+
if text.startswith("ip dhcp pool "):
|
|
371
|
+
pool = text.split(None, 3)[3] if len(text.split()) > 3 else text
|
|
372
|
+
continue
|
|
373
|
+
match = re.match(r"^network (\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$", text)
|
|
374
|
+
if match and pool:
|
|
375
|
+
network = _network_of(match.group(1), match.group(2))
|
|
376
|
+
if network is not None and network not in served:
|
|
377
|
+
findings.append(
|
|
378
|
+
Finding(
|
|
379
|
+
"pool_without_interface",
|
|
380
|
+
f"{_name(device)} pool {pool}",
|
|
381
|
+
f"{match.group(1)} {match.group(2)} matches no interface",
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
pool = ""
|
|
385
|
+
return findings
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _check_no_interface_is_declared_twice(devices: list[ET.Element]) -> list[Finding]:
|
|
389
|
+
"""One interface, one block.
|
|
390
|
+
|
|
391
|
+
IOS applies repeated blocks in order and keeps the last; every reader that
|
|
392
|
+
scans for the first sees a different device. Measured on the enterprise
|
|
393
|
+
lab: the standby router had each subinterface written five times, once per
|
|
394
|
+
build, because a generated lab becomes the donor for the next one and the
|
|
395
|
+
HSRP pass appended instead of merging. `10.10.40.1`, then `10.10.40.3`,
|
|
396
|
+
then `10.10.40.1` again -- all of them true of the same file.
|
|
397
|
+
"""
|
|
398
|
+
findings: list[Finding] = []
|
|
399
|
+
for device in devices:
|
|
400
|
+
counts: dict[str, int] = {}
|
|
401
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
402
|
+
text = (node.text or "").strip()
|
|
403
|
+
if text.startswith("interface "):
|
|
404
|
+
port = text.split(None, 1)[1]
|
|
405
|
+
counts[port] = counts.get(port, 0) + 1
|
|
406
|
+
for port, count in sorted(counts.items()):
|
|
407
|
+
if count > 1:
|
|
408
|
+
findings.append(
|
|
409
|
+
Finding("interface_declared_twice", f"{_name(device)}:{port}", f"{count} blocks")
|
|
410
|
+
)
|
|
411
|
+
return findings
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _check_no_pool_is_declared_twice(devices: list[ET.Element]) -> list[Finding]:
|
|
415
|
+
"""IOS merges two pools of one name and keeps the last."""
|
|
416
|
+
findings: list[Finding] = []
|
|
417
|
+
for device in devices:
|
|
418
|
+
counts: dict[str, int] = {}
|
|
419
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
420
|
+
text = (node.text or "").strip()
|
|
421
|
+
if text.startswith("ip dhcp pool "):
|
|
422
|
+
name = text[len("ip dhcp pool ") :].strip()
|
|
423
|
+
counts[name] = counts.get(name, 0) + 1
|
|
424
|
+
for name, count in sorted(counts.items()):
|
|
425
|
+
if count > 1:
|
|
426
|
+
findings.append(
|
|
427
|
+
Finding("dhcp_pool_declared_twice", f"{_name(device)} pool {name}", f"{count} blocks")
|
|
428
|
+
)
|
|
429
|
+
return findings
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def check_lab_coherence(root: ET.Element) -> list[Finding]:
|
|
433
|
+
"""Every contradiction the lab states about itself, most structural first."""
|
|
434
|
+
devices = root.findall(".//DEVICES/DEVICE")
|
|
435
|
+
return [
|
|
436
|
+
*_check_no_interface_is_declared_twice(devices),
|
|
437
|
+
*_check_no_pool_is_declared_twice(devices),
|
|
438
|
+
*_check_config_names_real_ports(devices),
|
|
439
|
+
*_check_ports_carry_one_cable(root),
|
|
440
|
+
*_check_addresses_are_unique(devices),
|
|
441
|
+
*_check_trunk_ends_agree(root, devices),
|
|
442
|
+
*_check_etherchannel_members_are_paired(root, devices),
|
|
443
|
+
*_check_hosts_can_reach_their_gateway(devices),
|
|
444
|
+
*_check_pools_have_an_interface(devices),
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def summarise(findings: list[Finding]) -> str:
|
|
449
|
+
if not findings:
|
|
450
|
+
return "coherent: no contradictions found"
|
|
451
|
+
counts: dict[str, int] = {}
|
|
452
|
+
for finding in findings:
|
|
453
|
+
counts[finding.kind] = counts.get(finding.kind, 0) + 1
|
|
454
|
+
parts = ", ".join(f"{kind} {count}" for kind, count in sorted(counts.items()))
|
|
455
|
+
return f"{len(findings)} contradiction(s): {parts}"
|