packet-tracer-skill 0.2.3 → 0.3.1
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 +491 -73
- package/README.md +575 -442
- package/SKILL.md +513 -262
- package/bin/packet-tracer-skill.js +29 -2
- package/docs/github-launch-ops-0.2.3.md +37 -0
- package/docs/github-metadata.md +6 -4
- package/docs/hero-demo-plan.md +1 -1
- package/docs/home-iot-donor-proof.md +4 -4
- package/docs/l2-security-qos-proof.md +1 -1
- package/docs/packet-tracer-feature-gap-atlas.md +4 -4
- package/docs/post-launch-follow-up.md +9 -5
- package/docs/proof-readiness-dashboard.md +69 -0
- package/docs/publish-preview-roadmap.md +6 -5
- package/docs/release-checklist.md +17 -8
- package/docs/release-notes-0.2.4.md +20 -0
- package/docs/runtime-truth.md +33 -8
- package/docs/security-edge-deepening-proof.md +1 -1
- package/examples/README.md +98 -69
- package/examples/complex_campus_master_edit_v4.inventory.json +12 -2
- package/examples/gallery.md +94 -6
- package/examples/home_iot_cli_edit_v1.inventory.json +11 -2
- package/examples/index.json +932 -4
- package/examples/local-sample-evidence.json +24 -0
- package/examples/proof-cards.json +117 -0
- package/examples/service_heavy_cli_edit_v1.inventory.json +11 -2
- package/package.json +61 -53
- package/pytest.ini +9 -0
- package/references/packettracer-sample-catalog.json +45287 -4525
- package/references/packettracer-sample-catalog.md +599 -259
- package/references/proof-readiness-candidates.json +352 -0
- package/scripts/build_examples_index.py +228 -35
- package/scripts/build_sample_catalog.py +62 -40
- package/scripts/corpus_runner.py +430 -0
- package/scripts/coverage_matrix.py +1842 -1812
- package/scripts/donor_cache.py +354 -0
- package/scripts/donor_diagnostics.py +3 -1
- package/scripts/generate_pkt.py +12738 -4228
- package/scripts/intent_parser.py +2269 -1657
- package/scripts/lab_coherence.py +455 -0
- package/scripts/local_donors.py +340 -0
- package/scripts/packet_tracer_env.py +846 -391
- package/scripts/pkt_annotate.py +218 -0
- package/scripts/pkt_codec.py +420 -181
- package/scripts/pkt_editor.py +2487 -1703
- package/scripts/pkt_transformer.py +1170 -727
- package/scripts/pkt_verify.py +461 -0
- package/scripts/runtime_doctor.py +80 -29
- package/scripts/sample_catalog.py +1425 -1250
- package/scripts/twofish_diagnostics.py +48 -31
- package/scripts/usage_ledger.py +218 -0
- package/scripts/vendor/README.md +44 -37
- package/scripts/vendor/twofish_pure.py +321 -0
- package/scripts/workspace_repair.py +548 -508
- package/templates/pt900/donors/README.md +15 -0
|
@@ -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}"
|