packet-tracer-skill 0.3.0 → 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 +67 -0
- package/README.md +27 -9
- package/SKILL.md +178 -2
- package/package.json +2 -1
- package/scripts/build_sample_catalog.py +42 -0
- package/scripts/generate_pkt.py +4091 -115
- 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/generate_pkt.py
CHANGED
|
@@ -41,8 +41,8 @@ from packet_tracer_env import (
|
|
|
41
41
|
)
|
|
42
42
|
from pkt_builder import build_packet_tracer_xml
|
|
43
43
|
from pkt_codec import decode_pkt_file, decode_pkt_modern, encode_pkt_modern, serialize_pkt_xml
|
|
44
|
-
from pkt_editor import _set_config_block, apply_plan_operations, decode_pkt_to_root, edit_pkt_file, inventory_devices, inventory_links, inventory_root
|
|
45
|
-
from pkt_transformer import donor_interface_names, port_capacity, port_exists, transform_from_blueprint
|
|
44
|
+
from pkt_editor import _align_hostname_with_name, _ensure_text, _profile_nodes, _set_config_block, apply_plan_operations, decode_pkt_to_root, edit_pkt_file, inventory_devices, inventory_links, inventory_root
|
|
45
|
+
from pkt_transformer import donor_interface_names, port_capacity, port_exists, transform_from_blueprint, wireless_router_port_names
|
|
46
46
|
import pkt_verify
|
|
47
47
|
import usage_ledger
|
|
48
48
|
from remote_search import (
|
|
@@ -52,7 +52,7 @@ from remote_search import (
|
|
|
52
52
|
search_remote_candidates,
|
|
53
53
|
write_remote_sample_audit,
|
|
54
54
|
)
|
|
55
|
-
from sample_catalog import ReferencePattern, SampleCandidate, SampleDescriptor, load_catalog, load_curated_donor_catalog, load_reference_catalog, summarize_pkt_descriptor
|
|
55
|
+
from sample_catalog import ReferencePattern, SampleCandidate, SampleDescriptor, load_catalog, load_curated_donor_catalog, load_reference_catalog, normalize_device_type as _normalize_device_type, summarize_pkt_descriptor
|
|
56
56
|
from sample_selector import rank_curated_donor_samples, rank_reference_samples, rank_samples, select_best_sample
|
|
57
57
|
from workspace_repair import inspect_donor_coherence, inspect_workspace_integrity, validate_donor_coherence, validate_workspace_integrity
|
|
58
58
|
|
|
@@ -1302,6 +1302,42 @@ def _record_generation_outcome(
|
|
|
1302
1302
|
return
|
|
1303
1303
|
|
|
1304
1304
|
|
|
1305
|
+
def _switch_model_affinity(sample: object, blueprint: dict[str, object]) -> int:
|
|
1306
|
+
"""How many of this donor's switches are the model the plan asked for.
|
|
1307
|
+
|
|
1308
|
+
Donor-prune reuses the donor's devices, so the model in the blueprint is
|
|
1309
|
+
advisory: `_choose_switch_model` returns `2960-24TT` and the lab ships
|
|
1310
|
+
whatever the donor happened to hold. Measured across the corpus, 43 of 53
|
|
1311
|
+
switches came out on a different model than planned, and 42 of them were
|
|
1312
|
+
`IE-9320` -- an industrial switch, in labs asked for as plain campus
|
|
1313
|
+
networks.
|
|
1314
|
+
|
|
1315
|
+
The material is there: of 70 bundled labs, 27 hold a `2960-24TT` and 36
|
|
1316
|
+
such switches exist in total. So this is a selection problem, not a
|
|
1317
|
+
scarcity one, and the donor that already owns the right model is the one to
|
|
1318
|
+
prefer.
|
|
1319
|
+
|
|
1320
|
+
Counted rather than scored as a fraction: a donor with four matching
|
|
1321
|
+
switches serves a four-switch prompt better than one with a single match,
|
|
1322
|
+
and the count says so directly.
|
|
1323
|
+
"""
|
|
1324
|
+
wanted = {
|
|
1325
|
+
str(device.get("model") or "").strip()
|
|
1326
|
+
for device in blueprint.get("devices", [])
|
|
1327
|
+
if str(device.get("type", "")).endswith("Switch")
|
|
1328
|
+
}
|
|
1329
|
+
wanted.discard("")
|
|
1330
|
+
if not wanted:
|
|
1331
|
+
return 0
|
|
1332
|
+
devices = getattr(sample, "devices", None) or []
|
|
1333
|
+
return sum(
|
|
1334
|
+
1
|
|
1335
|
+
for device in devices
|
|
1336
|
+
if str(device.get("type", "")).endswith("Switch")
|
|
1337
|
+
and str(device.get("model") or "").strip() in wanted
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
|
|
1305
1341
|
def _rerank_candidates_for_blueprint(
|
|
1306
1342
|
candidates: list[SampleCandidate],
|
|
1307
1343
|
blueprint: dict[str, object],
|
|
@@ -1312,7 +1348,7 @@ def _rerank_candidates_for_blueprint(
|
|
|
1312
1348
|
# bet than one that merely scores well on paper.
|
|
1313
1349
|
learned = learned_scores or {}
|
|
1314
1350
|
|
|
1315
|
-
def _sort_key(candidate: SampleCandidate) -> tuple[int, int, int, int, int, int, int]:
|
|
1351
|
+
def _sort_key(candidate: SampleCandidate) -> tuple[int, int, int, int, int, int, int, int]:
|
|
1316
1352
|
fit = build_donor_graph_fit(candidate.sample, blueprint)
|
|
1317
1353
|
acceptance_penalty, _ = _candidate_acceptance_penalty(candidate, blueprint)
|
|
1318
1354
|
adjusted_score = candidate.total_score - acceptance_penalty
|
|
@@ -1325,6 +1361,11 @@ def _rerank_candidates_for_blueprint(
|
|
|
1325
1361
|
fit.fit_score - acceptance_penalty,
|
|
1326
1362
|
-len(fit.port_media_conflicts),
|
|
1327
1363
|
-len(fit.missing_pairs),
|
|
1364
|
+
# Below the fit signals on purpose: a donor that wires up correctly
|
|
1365
|
+
# matters more than one holding the right switch model, and a lab
|
|
1366
|
+
# that opens beats a lab with prettier hardware. This decides
|
|
1367
|
+
# between donors the checks above rate the same.
|
|
1368
|
+
_switch_model_affinity(candidate.sample, blueprint),
|
|
1328
1369
|
adjusted_score,
|
|
1329
1370
|
)
|
|
1330
1371
|
|
|
@@ -1378,7 +1419,7 @@ def _root_has_serial_link(root: ET.Element) -> bool:
|
|
|
1378
1419
|
counted here.
|
|
1379
1420
|
|
|
1380
1421
|
Nor is a serial-looking name enough. A lab pruned from
|
|
1381
|
-
|
|
1422
|
+
the saved serial-WAN lab was measured wiring `Serial0/0/0 <-> Serial0/0/0`
|
|
1382
1423
|
between two routers whose only serial interfaces are `Serial2/0` and
|
|
1383
1424
|
`Serial3/0`; `port_exists` accepts the name, but the devices' own interface
|
|
1384
1425
|
lists do not have it. A cable between ports that do not exist is not a WAN,
|
|
@@ -1739,6 +1780,9 @@ def _default_name_for_type(device_type: str, index: int) -> str:
|
|
|
1739
1780
|
return {
|
|
1740
1781
|
"Router": f"R{index}",
|
|
1741
1782
|
"Switch": f"SW{index}",
|
|
1783
|
+
# Named in the switch series on purpose: it is one of the switches the
|
|
1784
|
+
# prompt counted, promoted to a multilayer model to fit the donors.
|
|
1785
|
+
"MultiLayerSwitch": f"SW{index}",
|
|
1742
1786
|
"PC": f"PC{index}",
|
|
1743
1787
|
"Server": f"Server{index}",
|
|
1744
1788
|
"LightWeightAccessPoint": f"AP{index}",
|
|
@@ -1796,6 +1840,21 @@ HOST_DEVICE_KINDS = {
|
|
|
1796
1840
|
"NetworkController",
|
|
1797
1841
|
"LightWeightAccessPoint",
|
|
1798
1842
|
"MerakiServer",
|
|
1843
|
+
# Each of these arrived in the file with no cable on it. The port each
|
|
1844
|
+
# one uses is measured off real cables in 200 saved labs, below in
|
|
1845
|
+
# `_host_port`; `Wall Mount` is left out because not one lab cables it,
|
|
1846
|
+
# and guessing is what puts hardcoded port names back into the file.
|
|
1847
|
+
"Hub",
|
|
1848
|
+
"WiredEndDevice",
|
|
1849
|
+
"Patch Panel",
|
|
1850
|
+
"Bridge",
|
|
1851
|
+
"Repeater",
|
|
1852
|
+
"TV",
|
|
1853
|
+
# A firewall the prompt asked for used to arrive cabled to nothing:
|
|
1854
|
+
# present in the file, valid, and not part of the network. It is an
|
|
1855
|
+
# end device as far as the link synthesiser is concerned -- one cable,
|
|
1856
|
+
# to the switch.
|
|
1857
|
+
"ASA",
|
|
1799
1858
|
}
|
|
1800
1859
|
|
|
1801
1860
|
|
|
@@ -1807,12 +1866,50 @@ def _is_wireless_client_device(device: dict[str, object]) -> bool:
|
|
|
1807
1866
|
return _device_kind(device) in {"Tablet", "Smartphone"}
|
|
1808
1867
|
|
|
1809
1868
|
|
|
1869
|
+
WIRELESS_ROUTER_KINDS = {"WirelessRouter", "WirelessRouterNewGeneration"}
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
def _wireless_router_lan_port(index: int) -> str:
|
|
1873
|
+
"""A home router's LAN port, counting from 1.
|
|
1874
|
+
|
|
1875
|
+
Measured across 348 saved labs: the new-generation home router's cables sit
|
|
1876
|
+
on `GigabitEthernet 1` .. `GigabitEthernet 4`, twenty of them, and the older
|
|
1877
|
+
Linksys model's on `Ethernet 1` .. `Ethernet 4`, two. The space is part of
|
|
1878
|
+
the name. Both count from one, which is why the index is not offset.
|
|
1879
|
+
|
|
1880
|
+
`Internet` is deliberately not reachable from here. Fourteen cables in those
|
|
1881
|
+
labs use it and every one is an uplink -- it is the WAN port, and a host
|
|
1882
|
+
plugged into it is on the wrong side of the router.
|
|
1883
|
+
"""
|
|
1884
|
+
return f"GigabitEthernet {index}"
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
# Router interface naming, read off Packet Tracer's own device list rather
|
|
1888
|
+
# than guessed from a prefix. The table used to know `2901` and `ISR` and fell
|
|
1889
|
+
# back to FastEthernet for everything else, which is wrong for most of the
|
|
1890
|
+
# modern models: a 2911 has `GigabitEthernet0/0` .. `0/2` and no FastEthernet
|
|
1891
|
+
# at all.
|
|
1892
|
+
#
|
|
1893
|
+
# The cost of getting it wrong is not a rejected file. `FastEthernet0/0` on a
|
|
1894
|
+
# 2911 is invalid, so the port repair relocates the cable to the first free
|
|
1895
|
+
# valid interface -- `GigabitEthernet0/0`, the one addressing had already made
|
|
1896
|
+
# the WAN uplink. The lab then carried its router-on-a-stick subinterfaces on
|
|
1897
|
+
# `GigabitEthernet0/1`, which no cable reached: ten subinterfaces
|
|
1898
|
+
# protocol-down, no gateway for any VLAN, and a lab that opened and read
|
|
1899
|
+
# correctly throughout.
|
|
1900
|
+
ROUTER_PORT_SHAPES = {
|
|
1901
|
+
"GigabitEthernet0/{n}": ("1941", "2901", "2911", "CGR1240"),
|
|
1902
|
+
"GigabitEthernet0/0/{n}": ("ISR4321", "ISR4331", "ISR"),
|
|
1903
|
+
"GigabitEthernet{n}": ("819HG", "819HGW", "829"),
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
|
|
1810
1907
|
def _router_port(device: dict[str, object], index: int = 1) -> str:
|
|
1811
1908
|
model = str(device.get("model") or "")
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1909
|
+
for shape, models in ROUTER_PORT_SHAPES.items():
|
|
1910
|
+
if any(model.startswith(prefix) for prefix in models):
|
|
1911
|
+
return shape.format(n=index - 1)
|
|
1912
|
+
# 1841, 2620XM, 2621XM, 2811 and the generic Router-PT.
|
|
1816
1913
|
return f"FastEthernet0/{index - 1}"
|
|
1817
1914
|
|
|
1818
1915
|
|
|
@@ -1886,6 +1983,26 @@ def _host_port(device: dict[str, object]) -> str:
|
|
|
1886
1983
|
return "GigabitEthernet0"
|
|
1887
1984
|
if kind == "WirelessLanController":
|
|
1888
1985
|
return "GigabitEthernet1"
|
|
1986
|
+
# Measured the same way, off the cables in 150 saved labs: every ASA
|
|
1987
|
+
# link uses `Ethernet0/N`, while the live device palette reports
|
|
1988
|
+
# `GigabitEthernet1/1` for a 5506-X. The models differ, so this is the
|
|
1989
|
+
# starting name and `_repair_invalid_link_ports` moves it to whatever
|
|
1990
|
+
# the chosen device really has -- the same way router and switch ports
|
|
1991
|
+
# are already corrected per model.
|
|
1992
|
+
if kind == "ASA":
|
|
1993
|
+
return "Ethernet0/0"
|
|
1994
|
+
# Read off the cables in 200 saved labs, the same way as the kinds
|
|
1995
|
+
# above. A hub is the strongest of them, cabled in six labs on
|
|
1996
|
+
# `FastEthernet0`; the rest appear once each, and the port repair
|
|
1997
|
+
# corrects any that do not fit the device that is finally chosen.
|
|
1998
|
+
if kind == "Patch Panel":
|
|
1999
|
+
return "PunchDown1"
|
|
2000
|
+
if kind == "Bridge":
|
|
2001
|
+
return "Ethernet0/1"
|
|
2002
|
+
if kind == "Repeater":
|
|
2003
|
+
return "Ethernet0"
|
|
2004
|
+
if kind == "TV":
|
|
2005
|
+
return "Port 0"
|
|
1889
2006
|
return "FastEthernet0"
|
|
1890
2007
|
|
|
1891
2008
|
|
|
@@ -1961,6 +2078,13 @@ def _operation_category(bucket_name: str, operation: dict[str, object]) -> str:
|
|
|
1961
2078
|
# `port_reassignment` put it on the blocked list and made donor-prune
|
|
1962
2079
|
# generation forbid its own core operation.
|
|
1963
2080
|
return "link_prune"
|
|
2081
|
+
if op_name == "apply_cli":
|
|
2082
|
+
# `cli R1: ...` writes IOS text into a device's configuration and
|
|
2083
|
+
# touches nothing else -- the same class as the switch and router
|
|
2084
|
+
# operations below. Falling through to the default named it a
|
|
2085
|
+
# physical workspace change, which is blocked in open-first mode, so
|
|
2086
|
+
# the first prompt that carried arbitrary CLI refused to generate.
|
|
2087
|
+
return "config_mutation"
|
|
1964
2088
|
return "workspace_physical_mutation"
|
|
1965
2089
|
if bucket_name in {"switch_ops", "router_ops", "management_ops"}:
|
|
1966
2090
|
return "config_mutation"
|
|
@@ -2409,15 +2533,103 @@ def _write_pkt_root(root: ET.Element, pkt_path: Path, xml_path: Path | None = No
|
|
|
2409
2533
|
_repair_invalid_link_ports(root)
|
|
2410
2534
|
_assign_unique_macs(root)
|
|
2411
2535
|
_match_link_port_families(root)
|
|
2536
|
+
# After the families agree and before the addresses are handed out: a
|
|
2537
|
+
# copper cable in a fibre socket is dropped by Packet Tracer on load,
|
|
2538
|
+
# silently, in a file that still opens.
|
|
2539
|
+
_move_copper_cables_off_fibre_ports(root)
|
|
2412
2540
|
_assign_unique_interface_addresses(root)
|
|
2413
2541
|
_assign_unique_switch_management_ips(root)
|
|
2414
2542
|
_reconcile_cable_media(root)
|
|
2543
|
+
# First of the configuration repairs: a block for hardware the device does
|
|
2544
|
+
# not have carries the donor's whole address plan into every pass that
|
|
2545
|
+
# reads the router's networks.
|
|
2546
|
+
_retarget_config_to_real_interface_names(root)
|
|
2547
|
+
_drop_config_for_absent_interfaces(root)
|
|
2415
2548
|
_trunk_uplinks_in_file(root)
|
|
2549
|
+
# Before the access-VLAN pass, which would otherwise strip the tagging:
|
|
2550
|
+
# a switch port facing router subinterfaces has to be a trunk. The
|
|
2551
|
+
# subinterfaces move to the cabled port first, or there is nothing there
|
|
2552
|
+
# for the trunk to carry.
|
|
2553
|
+
_move_subinterfaces_to_the_cabled_port(root)
|
|
2554
|
+
_trunk_router_on_a_stick(root)
|
|
2416
2555
|
_align_router_access_vlan(root)
|
|
2556
|
+
# After the router's own port is settled: a host whose address belongs to
|
|
2557
|
+
# one VLAN and whose port sits in another cannot reach its own subnet.
|
|
2558
|
+
_align_host_vlans_to_addresses(root)
|
|
2417
2559
|
_align_router_gateway(root)
|
|
2560
|
+
# Last, because `_align_router_gateway` writes the gateway onto the
|
|
2561
|
+
# physical cabled interface -- correct for an access link, wrong for a
|
|
2562
|
+
# trunk, where every frame arrives tagged and the address has to sit on
|
|
2563
|
+
# the subinterface for its VLAN. Running before it left VLAN 10 with its
|
|
2564
|
+
# gateway stranded on `GigabitEthernet0/0` and no host able to reach it,
|
|
2565
|
+
# while every other VLAN routed.
|
|
2566
|
+
_move_subinterfaces_to_the_cabled_port(root)
|
|
2567
|
+
# A learned sticky MAC belongs to the donor's device, not to the one now
|
|
2568
|
+
# plugged in, and `restrict` drops every frame that does not match it.
|
|
2569
|
+
_drop_inherited_sticky_macs(root)
|
|
2570
|
+
# Both ends of every trunk must name the same native VLAN, or spanning
|
|
2571
|
+
# tree blocks the port and the cable carries nothing.
|
|
2572
|
+
_match_trunk_native_vlans(root)
|
|
2573
|
+
# After every trunk is settled: port security on a trunk cuts the switch
|
|
2574
|
+
# behind it off entirely.
|
|
2575
|
+
_drop_port_security_from_trunks(root)
|
|
2576
|
+
# After the trunks are settled: a channel-group naming ports the cable
|
|
2577
|
+
# never joined takes the switch behind it off the network.
|
|
2578
|
+
_align_etherchannels_with_cabling(root)
|
|
2579
|
+
_align_dhcp_pools_with_interfaces(root)
|
|
2580
|
+
# After the pools point at real networks: a pool with no client is not
|
|
2581
|
+
# DHCP, and the segmented path never emitted the client half.
|
|
2582
|
+
# Before the clients are switched over: a VLAN with hosts and no gateway
|
|
2583
|
+
# cannot serve any of them.
|
|
2584
|
+
# A port with no VLAN sits in VLAN 1, which the plan never gives a
|
|
2585
|
+
# gateway, so the host on it is isolated whatever else is right.
|
|
2586
|
+
_place_hosts_in_a_vlan(root)
|
|
2587
|
+
_serve_every_populated_vlan(root)
|
|
2588
|
+
# Again, now that new VLANs exist: the router-facing trunk lists the
|
|
2589
|
+
# VLANs it may carry, and it was written before those VLANs were
|
|
2590
|
+
# created -- so their hosts had a gateway the trunk would not pass.
|
|
2591
|
+
_trunk_router_on_a_stick(root)
|
|
2592
|
+
# Again, now that the VLAN gateways exist. The first call ran before
|
|
2593
|
+
# `_serve_every_populated_vlan` created them, so it judged the plan's pools
|
|
2594
|
+
# against interfaces that were not there yet: a pool named VLAN20 carrying
|
|
2595
|
+
# the default 192.168.1.0 survived, and the VLAN-20 pass then added a
|
|
2596
|
+
# second pool of the same name for 192.168.20.0. IOS merges pools by name
|
|
2597
|
+
# and keeps the last, so VLAN 20 served a network nothing routes.
|
|
2418
2598
|
_align_dhcp_pools_with_interfaces(root)
|
|
2599
|
+
_drop_duplicate_dhcp_pools(root)
|
|
2600
|
+
_merge_repeated_interface_blocks(root)
|
|
2601
|
+
_separate_config_blocks(root)
|
|
2602
|
+
_add_missing_vlans_to_the_database(root)
|
|
2603
|
+
_drop_vlan_subinterfaces_off_router_links(root)
|
|
2604
|
+
_move_static_hosts_onto_their_vlan_network(root)
|
|
2605
|
+
# A home router keeps the donor's LAN network unless someone moves it onto
|
|
2606
|
+
# the one its own clients are addressed for, and its clients keep another
|
|
2607
|
+
# donor's network name unless someone puts them on the air it broadcasts.
|
|
2608
|
+
_join_wireless_clients_to_the_network_that_exists(root)
|
|
2609
|
+
_align_home_router_lan_with_its_clients(root)
|
|
2610
|
+
_match_wireless_security_to_the_access_point(root)
|
|
2611
|
+
# After the LAN move, which needs the planned addresses to know where to
|
|
2612
|
+
# put the pool. Once it has, the pool addresses the clients.
|
|
2613
|
+
_let_the_home_router_address_its_own_clients(root)
|
|
2614
|
+
# Last, so the profile copies whatever the passes above settled on.
|
|
2615
|
+
_make_the_wireless_profile_agree_with_the_port(root)
|
|
2616
|
+
_put_workstations_on_dhcp(root)
|
|
2617
|
+
# Snooping without a trusted uplink eats every offer the router sends.
|
|
2618
|
+
_trust_uplinks_for_dhcp_snooping(root)
|
|
2619
|
+
# Last: the standby gateway takes the address the hosts already use,
|
|
2620
|
+
# so nothing written before it has to change.
|
|
2621
|
+
_add_hsrp_gateway_redundancy(root)
|
|
2622
|
+
# A router with no path to another router carries none of its routes.
|
|
2623
|
+
_drop_stale_point_to_point_addresses(root)
|
|
2624
|
+
_mesh_routers_with_point_to_point_links(root)
|
|
2419
2625
|
_group_hosts_under_their_switch(root)
|
|
2420
2626
|
_separate_overlapping_devices(root)
|
|
2627
|
+
# After the separation pass, so a leftover nudged sideways is still pulled in.
|
|
2628
|
+
_compact_stray_devices(root)
|
|
2629
|
+
# Last of the layout: a radio link is made by distance, so a wireless
|
|
2630
|
+
# client has to end up inside its access point's reach whatever the rows
|
|
2631
|
+
# above decided.
|
|
2632
|
+
_keep_wireless_clients_within_reach_of_their_access_point(root)
|
|
2421
2633
|
_save_running_config_to_startup(root)
|
|
2422
2634
|
prune_unused_images(root)
|
|
2423
2635
|
xml_bytes = serialize_pkt_xml(root)
|
|
@@ -2498,8 +2710,25 @@ def _seed_devices_from_plan(plan: IntentPlan) -> list[dict[str, object]]:
|
|
|
2498
2710
|
for device_type, count in plan.device_requirements.items():
|
|
2499
2711
|
existing = current_counts.get(device_type, 0)
|
|
2500
2712
|
for next_index in range(existing + 1, count + 1):
|
|
2713
|
+
# A multilayer switch is still one of the switches the prompt asked
|
|
2714
|
+
# for, so it is numbered in the same series. `3 switch 1 router ve 4
|
|
2715
|
+
# komputer qur` is promoted to two switches plus one multilayer
|
|
2716
|
+
# switch to fit the donor pool, and the third one used to arrive
|
|
2717
|
+
# called `MultiLayerSwitch1`: the prompt asked for `SW3`, the lab
|
|
2718
|
+
# shipped without one, and the shortfall check reported a device
|
|
2719
|
+
# missing that was standing there under another name.
|
|
2720
|
+
name_index = next_index
|
|
2721
|
+
if device_type == "MultiLayerSwitch":
|
|
2722
|
+
# Counted once, not summed: whether the plain switches were
|
|
2723
|
+
# seeded before or after this loop reaches the multilayer one
|
|
2724
|
+
# depends on requirement ordering, and adding both counts gave
|
|
2725
|
+
# `SW5` for the third switch of three.
|
|
2726
|
+
name_index += max(
|
|
2727
|
+
current_counts.get("Switch", 0),
|
|
2728
|
+
plan.device_requirements.get("Switch", 0),
|
|
2729
|
+
)
|
|
2501
2730
|
device: dict[str, object] = {
|
|
2502
|
-
"name": _default_name_for_type(device_type,
|
|
2731
|
+
"name": _default_name_for_type(device_type, name_index),
|
|
2503
2732
|
"type": device_type,
|
|
2504
2733
|
}
|
|
2505
2734
|
if device_type == "Switch":
|
|
@@ -2684,7 +2913,29 @@ def _synthesize_links(plan: IntentPlan, devices: list[dict[str, object]]) -> lis
|
|
|
2684
2913
|
switches.sort(key=lambda device: _device_kind(device) != "MultiLayerSwitch")
|
|
2685
2914
|
hosts = [device for device in devices if _is_host_device(device)]
|
|
2686
2915
|
if not switches:
|
|
2687
|
-
|
|
2916
|
+
# A home-router lab has no switch, and returning nothing here left both
|
|
2917
|
+
# `wireless_home` and `wireless_ssid` as devices with no path between
|
|
2918
|
+
# them: two laptops on 1.1.10.20 and .21, a router on 192.168.0.1, 0/4
|
|
2919
|
+
# twice over while the labs opened and every static check passed.
|
|
2920
|
+
#
|
|
2921
|
+
# The laptops are wired on purpose: a Laptop-PT arrives with a copper
|
|
2922
|
+
# port and no wireless card, so the cable is the only path it has.
|
|
2923
|
+
# Tablets and phones are skipped -- they associate instead.
|
|
2924
|
+
routers_wireless = [
|
|
2925
|
+
device for device in devices if _device_kind(device) in WIRELESS_ROUTER_KINDS
|
|
2926
|
+
]
|
|
2927
|
+
if not routers_wireless:
|
|
2928
|
+
return []
|
|
2929
|
+
access_point = routers_wireless[0]
|
|
2930
|
+
wired_hosts = [device for device in hosts if not _is_wireless_client_device(device)]
|
|
2931
|
+
return [
|
|
2932
|
+
{
|
|
2933
|
+
"a": {"dev": device["name"], "port": _host_port(device)},
|
|
2934
|
+
"b": {"dev": access_point["name"], "port": _wireless_router_lan_port(index)},
|
|
2935
|
+
"media": "straight-through",
|
|
2936
|
+
}
|
|
2937
|
+
for index, device in enumerate(wired_hosts, start=1)
|
|
2938
|
+
]
|
|
2688
2939
|
|
|
2689
2940
|
if archetype == "chain":
|
|
2690
2941
|
links: list[dict[str, object]] = []
|
|
@@ -3166,6 +3417,12 @@ def _synthesize_security_ops(plan: IntentPlan, devices: list[dict[str, object]])
|
|
|
3166
3417
|
},
|
|
3167
3418
|
)
|
|
3168
3419
|
|
|
3420
|
+
# These interface names are a guess -- no cable exists yet, so nothing here
|
|
3421
|
+
# can know which ports face the core or whether the peer bundles too. The
|
|
3422
|
+
# guess is deliberate and it is not the final word: it records the intent to
|
|
3423
|
+
# bundle, and `_align_etherchannels_with_cabling` decides against the real
|
|
3424
|
+
# cabling, keeping the members of a genuine two-cable bundle and removing
|
|
3425
|
+
# the rest. Left on its own the guess cost SW2 its uplink.
|
|
3169
3426
|
if switches and {"etherchannel", "lacp", "pagp"} & capabilities and len(switches) >= 2:
|
|
3170
3427
|
mode = "active" if "lacp" in capabilities else ("desirable" if "pagp" in capabilities else "on")
|
|
3171
3428
|
for switch in switches[:2]:
|
|
@@ -3344,6 +3601,7 @@ def _draw_lab_annotations(root, blueprint, plan, add_note, add_rectangle, clear_
|
|
|
3344
3601
|
# are. Both are in the file.
|
|
3345
3602
|
placed: dict[str, tuple[float, float]] = {}
|
|
3346
3603
|
kinds: dict[str, str] = {}
|
|
3604
|
+
parked = _parked_names(root)
|
|
3347
3605
|
for device in root.findall(".//DEVICES/DEVICE"):
|
|
3348
3606
|
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
3349
3607
|
if not name:
|
|
@@ -3353,7 +3611,7 @@ def _draw_lab_annotations(root, blueprint, plan, add_note, add_rectangle, clear_
|
|
|
3353
3611
|
y = float((device.findtext("./WORKSPACE/LOGICAL/Y") or "").strip())
|
|
3354
3612
|
except ValueError:
|
|
3355
3613
|
continue
|
|
3356
|
-
if
|
|
3614
|
+
if name in parked:
|
|
3357
3615
|
continue
|
|
3358
3616
|
placed[name] = (x, y)
|
|
3359
3617
|
kinds[name] = (device.findtext("./ENGINE/TYPE") or "").strip()
|
|
@@ -4106,6 +4364,18 @@ def _collect_donor_groups(
|
|
|
4106
4364
|
"members_by_type": members_by_type,
|
|
4107
4365
|
}
|
|
4108
4366
|
)
|
|
4367
|
+
# Prefix grouping only means anything when the prefixes actually gather
|
|
4368
|
+
# hosts. A donor whose switches are `SW-IDR`, `SW-MUH`, ... collapses into
|
|
4369
|
+
# one group named `SW` holding nothing, because its hosts are `PC-IDR1` and
|
|
4370
|
+
# `SRV-IDR` -- different prefixes. Those empty groups then suppressed the
|
|
4371
|
+
# link-based fallback below, which groups that donor correctly, and every
|
|
4372
|
+
# request for five switch groups was refused against a donor that had five.
|
|
4373
|
+
if groups and not any(
|
|
4374
|
+
any(_fallback_group_member_type(str(member["type"])) for member in group["members"])
|
|
4375
|
+
for group in groups
|
|
4376
|
+
):
|
|
4377
|
+
groups = []
|
|
4378
|
+
|
|
4109
4379
|
if groups:
|
|
4110
4380
|
groups.sort(key=lambda item: _name_sort_key(str(item["group_name"])))
|
|
4111
4381
|
return groups
|
|
@@ -4598,6 +4868,11 @@ def _align_router_access_vlan(root: ET.Element) -> list[str]:
|
|
|
4598
4868
|
if kind(refs[near]) in host_types:
|
|
4599
4869
|
host_vlans[access_vlan(devices[refs[far]], ports[far])] += 1
|
|
4600
4870
|
elif kind(refs[near]) == "Router":
|
|
4871
|
+
# A router-on-a-stick end is a trunk, not an access port;
|
|
4872
|
+
# forcing it into the hosts' VLAN would strip the tagging every
|
|
4873
|
+
# other VLAN depends on.
|
|
4874
|
+
if _router_subinterface_vlans(devices[refs[near]], ports[near]):
|
|
4875
|
+
continue
|
|
4601
4876
|
router_ends.append((devices[refs[far]], ports[far]))
|
|
4602
4877
|
|
|
4603
4878
|
if not host_vlans or not router_ends:
|
|
@@ -4622,111 +4897,3006 @@ def _align_router_access_vlan(root: ET.Element) -> list[str]:
|
|
|
4622
4897
|
return notes
|
|
4623
4898
|
|
|
4624
4899
|
|
|
4625
|
-
def
|
|
4626
|
-
"""
|
|
4900
|
+
def _add_hsrp_gateway_redundancy(root: ET.Element) -> list[str]:
|
|
4901
|
+
"""Give the VLAN gateways a standby router.
|
|
4627
4902
|
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
addressed 192.168.1.x with a gateway that existed, on an interface attached
|
|
4633
|
-
to nothing.
|
|
4903
|
+
`hsrp olsun` parsed, and nothing came of it: the only implementation here
|
|
4904
|
+
is `set_hsrp_ipv6`, so an IPv4 lab asked for HSRP and got a configuration
|
|
4905
|
+
with no `standby` line anywhere. Measured on the 140-device enterprise
|
|
4906
|
+
lab -- eight routers, five of them with no cable at all.
|
|
4634
4907
|
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4908
|
+
The arrangement is the one the specification asks for and the one every
|
|
4909
|
+
textbook uses: the address the hosts already point at becomes the virtual
|
|
4910
|
+
one, and the two routers move aside to .2 and .3. Nothing the hosts or the
|
|
4911
|
+
DHCP pools say has to change, which is what makes it safe to add last.
|
|
4912
|
+
|
|
4913
|
+
10.10.30.1 virtual, what the hosts use
|
|
4914
|
+
10.10.30.2 primary, priority 110, preempt
|
|
4915
|
+
10.10.30.3 standby
|
|
4916
|
+
|
|
4917
|
+
The second router is one the topology already contains and left uncabled,
|
|
4918
|
+
trunked to the same switch as the first: a standby gateway on a different
|
|
4919
|
+
switch would be a different lab.
|
|
4639
4920
|
"""
|
|
4921
|
+
from pkt_editor import _ensure_link
|
|
4922
|
+
|
|
4640
4923
|
devices = {
|
|
4641
|
-
(device.findtext("./ENGINE/SAVE_REF_ID") or ""): device
|
|
4924
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
4642
4925
|
for device in root.findall(".//DEVICES/DEVICE")
|
|
4643
4926
|
}
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4927
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
4928
|
+
cabled: set[str] = set()
|
|
4929
|
+
used_ports: set[tuple[str, str]] = set()
|
|
4930
|
+
primary: tuple[ET.Element, str, ET.Element, str] | None = None
|
|
4931
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
4932
|
+
cable = link.find("./CABLE")
|
|
4933
|
+
if cable is None:
|
|
4934
|
+
continue
|
|
4935
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
4936
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
4937
|
+
if len(ports) < 2:
|
|
4938
|
+
continue
|
|
4939
|
+
for index, ref in enumerate(refs):
|
|
4940
|
+
device = devices.get(ref)
|
|
4941
|
+
if device is not None:
|
|
4942
|
+
cabled.add((device.findtext("./ENGINE/NAME") or "").strip())
|
|
4943
|
+
used_ports.add(((device.findtext("./ENGINE/NAME") or "").strip(), ports[index]))
|
|
4944
|
+
for near, far in ((0, 1), (1, 0)):
|
|
4945
|
+
router = devices.get(refs[near])
|
|
4946
|
+
switch = devices.get(refs[far])
|
|
4947
|
+
if router is None or switch is None or primary is not None:
|
|
4948
|
+
continue
|
|
4949
|
+
if (router.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
4950
|
+
continue
|
|
4951
|
+
if (switch.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
4952
|
+
continue
|
|
4953
|
+
if _router_subinterface_vlans(router, ports[near]):
|
|
4954
|
+
primary = (router, ports[near], switch, ports[far])
|
|
4647
4955
|
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4956
|
+
if primary is None:
|
|
4957
|
+
return []
|
|
4958
|
+
router, router_port, switch, switch_port = primary
|
|
4959
|
+
spare = next(
|
|
4960
|
+
(
|
|
4961
|
+
device
|
|
4962
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
4963
|
+
if (device.findtext("./ENGINE/TYPE") or "") == "Router"
|
|
4964
|
+
and (device.findtext("./ENGINE/NAME") or "").strip() not in cabled
|
|
4965
|
+
and port_exists(device, router_port)
|
|
4966
|
+
),
|
|
4967
|
+
None,
|
|
4968
|
+
)
|
|
4969
|
+
if spare is None:
|
|
4970
|
+
return []
|
|
4971
|
+
|
|
4972
|
+
vlans = _router_subinterface_vlans(router, router_port)
|
|
4973
|
+
subnets = _vlan_subnets_from_router(root)
|
|
4974
|
+
shared = [vlan for vlan in vlans if subnets.get(vlan)]
|
|
4975
|
+
if not shared:
|
|
4976
|
+
return []
|
|
4977
|
+
|
|
4978
|
+
switch_name = (switch.findtext("./ENGINE/NAME") or "").strip()
|
|
4979
|
+
free = next(
|
|
4980
|
+
(
|
|
4981
|
+
f"FastEthernet0/{index}"
|
|
4982
|
+
for index in range(1, 25)
|
|
4983
|
+
if (switch_name, f"FastEthernet0/{index}") not in used_ports
|
|
4984
|
+
and port_exists(switch, f"FastEthernet0/{index}")
|
|
4985
|
+
),
|
|
4986
|
+
"",
|
|
4987
|
+
)
|
|
4988
|
+
if not free:
|
|
4989
|
+
return []
|
|
4990
|
+
|
|
4991
|
+
spare_name = (spare.findtext("./ENGINE/NAME") or "").strip()
|
|
4992
|
+
_ensure_link(root, spare_name, router_port, switch_name, free, "copper")
|
|
4993
|
+
|
|
4994
|
+
allowed = ",".join(shared)
|
|
4995
|
+
body = [" switchport mode trunk", f" switchport trunk allowed vlan {allowed}"]
|
|
4996
|
+
if (switch.findtext("./ENGINE/TYPE") or "") == "MultiLayerSwitch":
|
|
4997
|
+
body.insert(0, " switchport trunk encapsulation dot1q")
|
|
4998
|
+
switch_config = switch.find("./ENGINE/RUNNINGCONFIG")
|
|
4999
|
+
if switch_config is not None:
|
|
5000
|
+
_set_config_block(switch_config, f"interface {free}", body)
|
|
5001
|
+
|
|
5002
|
+
def standby_block(subnet: str, vlan: str, address: str, priority: int | None) -> list[str]:
|
|
5003
|
+
lines = [
|
|
5004
|
+
f" ip address {address} 255.255.255.0",
|
|
5005
|
+
f" standby {vlan} ip {subnet}.1",
|
|
5006
|
+
]
|
|
5007
|
+
if priority is not None:
|
|
5008
|
+
lines.append(f" standby {vlan} priority {priority}")
|
|
5009
|
+
lines.append(f" standby {vlan} preempt")
|
|
5010
|
+
return lines
|
|
5011
|
+
|
|
5012
|
+
config = router.find("./ENGINE/RUNNINGCONFIG")
|
|
5013
|
+
spare_config = spare.find("./ENGINE/RUNNINGCONFIG")
|
|
5014
|
+
if config is None or spare_config is None:
|
|
5015
|
+
return []
|
|
5016
|
+
for vlan in shared:
|
|
5017
|
+
subnet = subnets[vlan]
|
|
5018
|
+
_set_config_block(
|
|
5019
|
+
config, f"interface {router_port}.{vlan}", standby_block(subnet, vlan, f"{subnet}.2", 110)
|
|
5020
|
+
)
|
|
5021
|
+
# Appending the block was the obvious way to write it and it is wrong:
|
|
5022
|
+
# a generated lab becomes the donor for the next build, so this pass
|
|
5023
|
+
# runs again over its own output. Measured on the enterprise lab after
|
|
5024
|
+
# five builds -- every subinterface on the standby router written five
|
|
5025
|
+
# times over, `10.10.40.1` then `10.10.40.3` then `10.10.40.1` again.
|
|
5026
|
+
# IOS applies them in order and keeps the last; every reader that scans
|
|
5027
|
+
# for the first sees a different address. A pass has to be safe to run
|
|
5028
|
+
# on what it already produced.
|
|
5029
|
+
_set_config_block(
|
|
5030
|
+
spare_config,
|
|
5031
|
+
f"interface {router_port}.{vlan}",
|
|
5032
|
+
[
|
|
5033
|
+
f" description VLAN{vlan} standby",
|
|
5034
|
+
f" encapsulation dot1Q {vlan}",
|
|
5035
|
+
*standby_block(subnet, vlan, f"{subnet}.3", None),
|
|
5036
|
+
],
|
|
5037
|
+
)
|
|
5038
|
+
_set_config_block(spare_config, f"interface {router_port}", [" no shutdown"])
|
|
5039
|
+
|
|
5040
|
+
name = (router.findtext("./ENGINE/NAME") or "").strip()
|
|
5041
|
+
return [
|
|
5042
|
+
f"HSRP on {len(shared)} VLAN(s): {name} .2 priority 110, {spare_name} .3, "
|
|
5043
|
+
f"virtual .1 -- the address the hosts already use"
|
|
4653
5044
|
]
|
|
4654
|
-
|
|
4655
|
-
|
|
5045
|
+
|
|
5046
|
+
|
|
5047
|
+
def _trust_uplinks_for_dhcp_snooping(root: ET.Element) -> list[str]:
|
|
5048
|
+
"""A switch running DHCP snooping has to trust the way to the server.
|
|
5049
|
+
|
|
5050
|
+
Snooping drops server-sourced DHCP messages arriving on untrusted ports,
|
|
5051
|
+
and every port is untrusted until told otherwise. Enabling it without
|
|
5052
|
+
trusting the uplink is therefore a switch that silently discards every
|
|
5053
|
+
offer the router sends back.
|
|
5054
|
+
|
|
5055
|
+
Measured on the 140-device enterprise lab: `dhcp snooping olsun` turned it
|
|
5056
|
+
on across eighteen switches, no port was trusted, and every workstation
|
|
5057
|
+
fell to APIPA while the statically addressed servers beside them reached
|
|
5058
|
+
their gateway 4/4 -- the path was fine, only the offers were being eaten.
|
|
5059
|
+
|
|
5060
|
+
The trunk is the way out of the switch, so the trunks are what get
|
|
5061
|
+
trusted; the access ports stay untrusted, which is the whole point of
|
|
5062
|
+
turning snooping on.
|
|
5063
|
+
"""
|
|
5064
|
+
trusted: list[str] = []
|
|
5065
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5066
|
+
if (device.findtext("./ENGINE/TYPE") or "") not in {"Switch", "MultiLayerSwitch"}:
|
|
5067
|
+
continue
|
|
5068
|
+
config = device.find("./ENGINE/RUNNINGCONFIG")
|
|
5069
|
+
if config is None:
|
|
5070
|
+
continue
|
|
5071
|
+
lines = [(node.text or "") for node in config.findall("LINE")]
|
|
5072
|
+
if not any(line.strip() == "ip dhcp snooping" for line in lines):
|
|
5073
|
+
continue
|
|
5074
|
+
current = ""
|
|
5075
|
+
trunks: list[str] = []
|
|
5076
|
+
for line in lines:
|
|
5077
|
+
text = line.strip()
|
|
5078
|
+
if text.startswith("interface "):
|
|
5079
|
+
current = text.split(None, 1)[1]
|
|
5080
|
+
elif text == "switchport mode trunk" and current:
|
|
5081
|
+
trunks.append(current)
|
|
5082
|
+
for port in dict.fromkeys(trunks):
|
|
5083
|
+
_set_config_block(config, f"interface {port}", [" ip dhcp snooping trust"])
|
|
5084
|
+
trusted.append(f"{device.findtext('./ENGINE/NAME') or ''}:{port}")
|
|
5085
|
+
if not trusted:
|
|
4656
5086
|
return []
|
|
5087
|
+
return [f"DHCP snooping trusts {len(trusted)} uplink(s): " + ", ".join(trusted[:6])]
|
|
5088
|
+
|
|
4657
5089
|
|
|
5090
|
+
def _place_hosts_in_a_vlan(root: ET.Element) -> list[str]:
|
|
5091
|
+
"""Every host port in a VLAN lab has to name a VLAN.
|
|
5092
|
+
|
|
5093
|
+
A port with no `switchport access vlan` sits in VLAN 1, which the plan
|
|
5094
|
+
never gives a gateway, so the host on it is isolated however well the rest
|
|
5095
|
+
of the lab is configured. Measured on the 140-device enterprise lab: 50 of
|
|
5096
|
+
112 cabled host ports named no VLAN at all, and every host behind them was
|
|
5097
|
+
stranded.
|
|
5098
|
+
|
|
5099
|
+
The switch decides which one. An access switch serves a department, so the
|
|
5100
|
+
VLAN most of its other host ports already use is the VLAN its bare ports
|
|
5101
|
+
belong to -- and a switch whose ports are all bare takes the lab's lowest
|
|
5102
|
+
VLAN rather than inventing one.
|
|
5103
|
+
"""
|
|
5104
|
+
devices_by_ref = {
|
|
5105
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
5106
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
5107
|
+
}
|
|
5108
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
5109
|
+
|
|
5110
|
+
def access_vlan(switch: ET.Element, port: str) -> str:
|
|
5111
|
+
inside = False
|
|
5112
|
+
for node in switch.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
5113
|
+
line = (node.text or "").strip()
|
|
5114
|
+
if line.startswith("interface "):
|
|
5115
|
+
inside = line == f"interface {port}"
|
|
5116
|
+
elif inside:
|
|
5117
|
+
if line.startswith("switchport access vlan "):
|
|
5118
|
+
return line.split()[-1]
|
|
5119
|
+
if line.startswith("switchport mode trunk"):
|
|
5120
|
+
return "TRUNK"
|
|
5121
|
+
return ""
|
|
5122
|
+
|
|
5123
|
+
attachments: list[tuple[ET.Element, str, ET.Element]] = []
|
|
4658
5124
|
for link in root.findall(".//LINKS/LINK"):
|
|
4659
5125
|
cable = link.find("./CABLE")
|
|
4660
5126
|
if cable is None:
|
|
4661
5127
|
continue
|
|
4662
|
-
refs = [(cable.findtext(
|
|
5128
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
4663
5129
|
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
4664
|
-
|
|
4665
|
-
|
|
5130
|
+
if len(ports) < 2:
|
|
5131
|
+
continue
|
|
5132
|
+
for near, far in ((0, 1), (1, 0)):
|
|
5133
|
+
host = devices_by_ref.get(refs[near])
|
|
5134
|
+
switch = devices_by_ref.get(refs[far])
|
|
5135
|
+
if host is None or switch is None:
|
|
4666
5136
|
continue
|
|
4667
|
-
if
|
|
5137
|
+
if (switch.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
4668
5138
|
continue
|
|
4669
|
-
|
|
4670
|
-
|
|
5139
|
+
if not _is_host_device(
|
|
5140
|
+
{"type": _normalize_device_type(host.findtext("./ENGINE/TYPE") or "")}
|
|
5141
|
+
):
|
|
5142
|
+
continue
|
|
5143
|
+
attachments.append((switch, ports[far], host))
|
|
5144
|
+
|
|
5145
|
+
declared: set[str] = set()
|
|
5146
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5147
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
5148
|
+
text = (node.text or "")
|
|
5149
|
+
match = re.match(r"^vlan (\d+)$", text.strip())
|
|
5150
|
+
if match and not text.startswith(" "):
|
|
5151
|
+
declared.add(match.group(1))
|
|
5152
|
+
fallback = min(declared, key=int) if declared else ""
|
|
5153
|
+
|
|
5154
|
+
votes: dict[str, Counter[str]] = {}
|
|
5155
|
+
for switch, port, _host in attachments:
|
|
5156
|
+
vlan = access_vlan(switch, port)
|
|
5157
|
+
if vlan and vlan != "TRUNK":
|
|
5158
|
+
name = (switch.findtext("./ENGINE/NAME") or "").strip()
|
|
5159
|
+
votes.setdefault(name, Counter())[vlan] += 1
|
|
5160
|
+
|
|
5161
|
+
placed: list[str] = []
|
|
5162
|
+
for switch, port, host in attachments:
|
|
5163
|
+
if access_vlan(switch, port):
|
|
5164
|
+
continue
|
|
5165
|
+
name = (switch.findtext("./ENGINE/NAME") or "").strip()
|
|
5166
|
+
wanted = votes[name].most_common(1)[0][0] if votes.get(name) else fallback
|
|
5167
|
+
if not wanted:
|
|
5168
|
+
continue
|
|
5169
|
+
config = switch.find("./ENGINE/RUNNINGCONFIG")
|
|
5170
|
+
if config is None:
|
|
5171
|
+
continue
|
|
5172
|
+
_set_config_block(
|
|
5173
|
+
config,
|
|
5174
|
+
f"interface {port}",
|
|
5175
|
+
[" switchport mode access", f" switchport access vlan {wanted}"],
|
|
5176
|
+
)
|
|
5177
|
+
placed.append(f"{name}:{port} -> VLAN {wanted} ({host.findtext('./ENGINE/NAME') or ''})")
|
|
5178
|
+
return placed
|
|
5179
|
+
|
|
5180
|
+
|
|
5181
|
+
def _drop_stale_point_to_point_addresses(root: ET.Element) -> list[str]:
|
|
5182
|
+
"""A router-to-router address on a port with no cable is left over.
|
|
5183
|
+
|
|
5184
|
+
`_mesh_routers_with_point_to_point_links` only ever takes free ports, so it
|
|
5185
|
+
cannot overwrite a live link -- but a generated lab is the next build's
|
|
5186
|
+
donor, and pruning can take the cable while leaving the address behind. The
|
|
5187
|
+
pass then picks a different free port, and the router ends up holding the
|
|
5188
|
+
same /30 address twice. Measured on a six-switch lab: R1 carried
|
|
5189
|
+
`10.255.0.2` on both `GigabitEthernet0/1` and `GigabitEthernet0/2`, one of
|
|
5190
|
+
them cabled and one not.
|
|
5191
|
+
|
|
5192
|
+
Only the range this file hands out is touched, and only where no cable
|
|
5193
|
+
arrives, so a donor's own addressing is never disturbed.
|
|
5194
|
+
"""
|
|
5195
|
+
cabled: set[tuple[str, str]] = set()
|
|
5196
|
+
devices_by_ref = {
|
|
5197
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
5198
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
5199
|
+
}
|
|
5200
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
5201
|
+
cable = link.find("./CABLE")
|
|
5202
|
+
if cable is None:
|
|
5203
|
+
continue
|
|
5204
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
5205
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
5206
|
+
for index, ref in enumerate(refs):
|
|
5207
|
+
device = devices_by_ref.get(ref)
|
|
5208
|
+
if device is not None and index < len(ports):
|
|
5209
|
+
cabled.add(((device.findtext("./ENGINE/NAME") or "").strip(), ports[index]))
|
|
5210
|
+
|
|
5211
|
+
dropped: list[str] = []
|
|
5212
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5213
|
+
if (device.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
5214
|
+
continue
|
|
5215
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
5216
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
5217
|
+
config = device.find(f"./ENGINE/{section}")
|
|
4671
5218
|
if config is None:
|
|
4672
5219
|
continue
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
5220
|
+
port = ""
|
|
5221
|
+
doomed: list[ET.Element] = []
|
|
5222
|
+
for node in config.findall("LINE"):
|
|
5223
|
+
text = (node.text or "").strip()
|
|
5224
|
+
if text.startswith("interface "):
|
|
5225
|
+
port = text.split(None, 1)[1]
|
|
5226
|
+
elif (
|
|
5227
|
+
port
|
|
5228
|
+
and "." not in port
|
|
5229
|
+
and (name, port) not in cabled
|
|
5230
|
+
and re.match(r"^ip address 10\.255\.\d+\.\d+ ", text)
|
|
5231
|
+
):
|
|
5232
|
+
doomed.append(node)
|
|
5233
|
+
for node in doomed:
|
|
5234
|
+
if node in list(config):
|
|
5235
|
+
config.remove(node)
|
|
5236
|
+
if doomed and section == "RUNNINGCONFIG":
|
|
5237
|
+
dropped.append(f"{name}: dropped {len(doomed)} stale point-to-point address(es)")
|
|
5238
|
+
return dropped
|
|
5239
|
+
|
|
5240
|
+
|
|
5241
|
+
def _mesh_routers_with_point_to_point_links(root: ET.Element) -> list[str]:
|
|
5242
|
+
"""Join the routers to each other on /30 links.
|
|
5243
|
+
|
|
5244
|
+
A campus drawing is mostly routers wired to each other -- `10.10.10.0/30`,
|
|
5245
|
+
`10.10.10.4/30`, `10.10.10.8/30` and so on -- and the generated labs had
|
|
5246
|
+
routers standing alone: eight of them in the enterprise lab, five with no
|
|
5247
|
+
cable at all. A router with no path to another router cannot carry a route
|
|
5248
|
+
from it, so OSPF had nothing to exchange.
|
|
5249
|
+
|
|
5250
|
+
Each pair takes the next /30 and both ends are advertised, which is what
|
|
5251
|
+
makes the mesh do something rather than merely exist. Only interfaces the
|
|
5252
|
+
device really has are used, and a router already carrying the VLAN trunk
|
|
5253
|
+
keeps it -- the trunk is its job, and a second address on the same port
|
|
5254
|
+
would take it away.
|
|
5255
|
+
"""
|
|
5256
|
+
from pkt_editor import _ensure_link
|
|
5257
|
+
|
|
5258
|
+
routers = [
|
|
5259
|
+
device
|
|
5260
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
5261
|
+
if (device.findtext("./ENGINE/TYPE") or "") == "Router"
|
|
5262
|
+
]
|
|
5263
|
+
if len(routers) < 2:
|
|
5264
|
+
return []
|
|
5265
|
+
|
|
5266
|
+
busy: set[tuple[str, str]] = set()
|
|
5267
|
+
devices_by_ref = {
|
|
5268
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
5269
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
5270
|
+
}
|
|
5271
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
5272
|
+
cable = link.find("./CABLE")
|
|
5273
|
+
if cable is None:
|
|
5274
|
+
continue
|
|
5275
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
5276
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
5277
|
+
for index, ref in enumerate(refs):
|
|
5278
|
+
device = devices_by_ref.get(ref)
|
|
5279
|
+
if device is not None and index < len(ports):
|
|
5280
|
+
busy.add(((device.findtext("./ENGINE/NAME") or "").strip(), ports[index]))
|
|
5281
|
+
|
|
5282
|
+
def free_port(router: ET.Element) -> str:
|
|
5283
|
+
name = (router.findtext("./ENGINE/NAME") or "").strip()
|
|
5284
|
+
for candidate in donor_interface_names(router) or []:
|
|
5285
|
+
if candidate.startswith(("GigabitEthernet", "FastEthernet")) and "." not in candidate:
|
|
5286
|
+
if (name, candidate) not in busy and port_exists(router, candidate):
|
|
5287
|
+
return candidate
|
|
5288
|
+
for shape in ("GigabitEthernet0/{n}", "FastEthernet0/{n}"):
|
|
5289
|
+
for index in range(0, 4):
|
|
5290
|
+
candidate = shape.format(n=index)
|
|
5291
|
+
if (name, candidate) not in busy and port_exists(router, candidate):
|
|
5292
|
+
return candidate
|
|
5293
|
+
return ""
|
|
5294
|
+
|
|
5295
|
+
def octets(value: int) -> str:
|
|
5296
|
+
return ".".join(str((value >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
5297
|
+
|
|
5298
|
+
base = _address_to_int("10.255.0.0") or 0
|
|
5299
|
+
notes: list[str] = []
|
|
5300
|
+
made = 0
|
|
5301
|
+
for position, left in enumerate(routers):
|
|
5302
|
+
for right in routers[position + 1 :]:
|
|
5303
|
+
left_port, right_port = free_port(left), free_port(right)
|
|
5304
|
+
if not left_port or not right_port:
|
|
5305
|
+
continue
|
|
5306
|
+
left_name = (left.findtext("./ENGINE/NAME") or "").strip()
|
|
5307
|
+
right_name = (right.findtext("./ENGINE/NAME") or "").strip()
|
|
5308
|
+
network = base + made * 4
|
|
5309
|
+
_ensure_link(root, left_name, left_port, right_name, right_port, "copper")
|
|
5310
|
+
busy.add((left_name, left_port))
|
|
5311
|
+
busy.add((right_name, right_port))
|
|
5312
|
+
for device, port, address in (
|
|
5313
|
+
(left, left_port, network + 1),
|
|
5314
|
+
(right, right_port, network + 2),
|
|
5315
|
+
):
|
|
5316
|
+
config = device.find("./ENGINE/RUNNINGCONFIG")
|
|
5317
|
+
if config is None:
|
|
4682
5318
|
continue
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
5319
|
+
_set_config_block(
|
|
5320
|
+
config,
|
|
5321
|
+
f"interface {port}",
|
|
5322
|
+
[
|
|
5323
|
+
f" description point-to-point to {right_name if device is left else left_name}",
|
|
5324
|
+
f" ip address {octets(address)} 255.255.255.252",
|
|
5325
|
+
" ip ospf network point-to-point",
|
|
5326
|
+
" no shutdown",
|
|
5327
|
+
],
|
|
5328
|
+
)
|
|
5329
|
+
if any(
|
|
5330
|
+
(node.text or "").strip().startswith("router ospf")
|
|
5331
|
+
for node in config.findall("LINE")
|
|
5332
|
+
):
|
|
5333
|
+
_set_config_block(
|
|
5334
|
+
config,
|
|
5335
|
+
"router ospf 1",
|
|
5336
|
+
[f" network {octets(network)} 0.0.0.3 area 0"],
|
|
4687
5337
|
)
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
_set_config_block(config, f"interface {interface}", [" no ip address"])
|
|
5338
|
+
notes.append(f"{left_name}:{left_port} <-> {right_name}:{right_port} {octets(network)}/30")
|
|
5339
|
+
made += 1
|
|
5340
|
+
if not notes:
|
|
5341
|
+
return []
|
|
5342
|
+
return [f"{made} point-to-point link(s): " + "; ".join(notes[:5])]
|
|
4694
5343
|
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
note += f"; cleared overlapping address on {', '.join(cleared)}"
|
|
4704
|
-
return [note]
|
|
4705
|
-
return []
|
|
5344
|
+
|
|
5345
|
+
def _host_mask_on_port(device: ET.Element) -> str:
|
|
5346
|
+
"""The mask a host is actually using, or an empty string."""
|
|
5347
|
+
for port in device.findall(".//PORT"):
|
|
5348
|
+
mask = (port.findtext("SUBNET") or "").strip()
|
|
5349
|
+
if re.fullmatch(r"\d+\.\d+\.\d+\.\d+", mask) and mask != "0.0.0.0":
|
|
5350
|
+
return mask
|
|
5351
|
+
return ""
|
|
4706
5352
|
|
|
4707
5353
|
|
|
4708
|
-
|
|
5354
|
+
def _pool_window(network: str, mask: str) -> tuple[str, str, str]:
|
|
5355
|
+
"""Where a pool's reserved range ends and its handout range begins.
|
|
5356
|
+
|
|
5357
|
+
Half the subnet is kept back for the gateway, the servers and the printers
|
|
5358
|
+
that are addressed by hand, and the rest is handed out. On a /24 that is
|
|
5359
|
+
the familiar .1 to .99 reserved and .100 upwards served; on a /26 it is .1
|
|
5360
|
+
to .30 and .31 upwards, which is the point of doing the arithmetic rather
|
|
5361
|
+
than writing .99 everywhere -- a /26 has no .99, so the excluded range
|
|
5362
|
+
covered the whole subnet and the pool had nothing left to give.
|
|
5363
|
+
"""
|
|
5364
|
+
base = _address_to_int(network + ".0") if network.count(".") == 2 else _address_to_int(network)
|
|
5365
|
+
mask_value = _address_to_int(mask)
|
|
5366
|
+
if base is None or mask_value is None:
|
|
5367
|
+
return "", "", ""
|
|
5368
|
+
size = (~mask_value) & 0xFFFFFFFF
|
|
5369
|
+
if size < 3:
|
|
5370
|
+
return "", "", ""
|
|
5371
|
+
start = (base & mask_value) + 1
|
|
5372
|
+
half = (base & mask_value) + max(2, size // 2)
|
|
5373
|
+
|
|
5374
|
+
def text(value: int) -> str:
|
|
5375
|
+
return ".".join(str((value >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
5376
|
+
|
|
5377
|
+
return text(start), text(half - 1), text(half)
|
|
5378
|
+
|
|
5379
|
+
|
|
5380
|
+
def _serve_every_populated_vlan(root: ET.Element) -> list[str]:
|
|
5381
|
+
"""Give every VLAN that carries hosts a gateway and a pool.
|
|
5382
|
+
|
|
5383
|
+
A twenty-VLAN prompt produced a router with subinterfaces for seven of
|
|
5384
|
+
them. The other thirteen had hosts cabled into them, on access ports, in
|
|
5385
|
+
VLANs the router had never heard of -- so those hosts had no gateway, no
|
|
5386
|
+
pool, and no way off their own switch. Measured on a 140-device lab: 23
|
|
5387
|
+
distinct host subnets, half of them the donor's 192.168.x, none of which
|
|
5388
|
+
any router interface served.
|
|
5389
|
+
|
|
5390
|
+
The VLAN plan is the statement of intent, and 10.10.<vlan>.0/24 is the
|
|
5391
|
+
scheme the rest of the generator already uses, so a VLAN that hosts sit in
|
|
5392
|
+
gets that network: a subinterface on the trunk the router already has, and
|
|
5393
|
+
a pool behind it. Nothing is invented for a VLAN nobody is plugged into.
|
|
5394
|
+
"""
|
|
5395
|
+
devices_by_ref = {
|
|
5396
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
5397
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
5398
|
+
}
|
|
5399
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
5400
|
+
|
|
5401
|
+
populated: set[str] = set()
|
|
5402
|
+
# What the hosts in a VLAN are already addressed as. Imposing a scheme here
|
|
5403
|
+
# is how a lab ends up with two address plans: this pass used
|
|
5404
|
+
# 10.10.<vlan>.0/24 while the addressing pass had put that VLAN's hosts on
|
|
5405
|
+
# 192.168.<vlan>.0/24, so the gateway it created served a network none of
|
|
5406
|
+
# them were on. The hosts decide; 10.10.<vlan> is only the fallback for a
|
|
5407
|
+
# VLAN whose hosts carry no address yet.
|
|
5408
|
+
host_subnets: dict[str, Counter[str]] = {}
|
|
5409
|
+
# And with what mask. A VLAN plan that uses /26 or /27 -- as a real one
|
|
5410
|
+
# does -- gets a gateway and a pool of that size rather than a /24 nobody
|
|
5411
|
+
# asked for.
|
|
5412
|
+
host_masks: dict[str, Counter[str]] = {}
|
|
5413
|
+
router_trunk: tuple[ET.Element, str] | None = None
|
|
5414
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
5415
|
+
cable = link.find("./CABLE")
|
|
5416
|
+
if cable is None:
|
|
5417
|
+
continue
|
|
5418
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
5419
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
5420
|
+
if len(ports) < 2:
|
|
5421
|
+
continue
|
|
5422
|
+
for near, far in ((0, 1), (1, 0)):
|
|
5423
|
+
near_device = devices_by_ref.get(refs[near])
|
|
5424
|
+
far_device = devices_by_ref.get(refs[far])
|
|
5425
|
+
if near_device is None or far_device is None:
|
|
5426
|
+
continue
|
|
5427
|
+
far_kind = (far_device.findtext("./ENGINE/TYPE") or "")
|
|
5428
|
+
near_kind = (near_device.findtext("./ENGINE/TYPE") or "")
|
|
5429
|
+
if far_kind not in switch_types:
|
|
5430
|
+
continue
|
|
5431
|
+
if near_kind == "Router" and router_trunk is None:
|
|
5432
|
+
if _router_subinterface_vlans(near_device, ports[near]):
|
|
5433
|
+
router_trunk = (near_device, ports[near])
|
|
5434
|
+
continue
|
|
5435
|
+
if not _is_host_device({"type": _normalize_device_type(near_kind)}):
|
|
5436
|
+
continue
|
|
5437
|
+
inside = False
|
|
5438
|
+
for node in far_device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
5439
|
+
line = (node.text or "").strip()
|
|
5440
|
+
if line.startswith("interface "):
|
|
5441
|
+
inside = line == f"interface {ports[far]}"
|
|
5442
|
+
elif inside and line.startswith("switchport access vlan "):
|
|
5443
|
+
vlan = line.split()[-1]
|
|
5444
|
+
populated.add(vlan)
|
|
5445
|
+
for candidate in near_device.iter():
|
|
5446
|
+
text = (candidate.text or "").strip()
|
|
5447
|
+
if candidate.tag.upper() == "IP" and re.fullmatch(
|
|
5448
|
+
r"\d+\.\d+\.\d+\.\d+", text
|
|
5449
|
+
):
|
|
5450
|
+
host_subnets.setdefault(vlan, Counter())[
|
|
5451
|
+
text.rsplit(".", 1)[0]
|
|
5452
|
+
] += 1
|
|
5453
|
+
mask = _host_mask_on_port(near_device)
|
|
5454
|
+
if mask:
|
|
5455
|
+
host_masks.setdefault(vlan, Counter())[mask] += 1
|
|
5456
|
+
break
|
|
5457
|
+
break
|
|
5458
|
+
if router_trunk is None:
|
|
5459
|
+
return []
|
|
5460
|
+
|
|
5461
|
+
router, parent = router_trunk
|
|
5462
|
+
config = router.find("./ENGINE/RUNNINGCONFIG")
|
|
5463
|
+
if config is None:
|
|
5464
|
+
return []
|
|
5465
|
+
known = set(_router_subinterface_vlans(router, parent))
|
|
5466
|
+
# A subinterface without a pool is half the job: the VLAN routes but hands
|
|
5467
|
+
# out no addresses, and its workstations stay on whatever the donor gave
|
|
5468
|
+
# them. Both halves are counted separately for that reason.
|
|
5469
|
+
# Keyed by network, not by VLAN number: a pool's network says nothing about
|
|
5470
|
+
# which VLAN it serves unless the lab happens to number them alike, and
|
|
5471
|
+
# matching on `10.10.<vlan>` missed every 192.168 pool the addressing pass
|
|
5472
|
+
# had made.
|
|
5473
|
+
pooled_networks: set[str] = set()
|
|
5474
|
+
in_pool = False
|
|
5475
|
+
for node in config.findall("LINE"):
|
|
5476
|
+
text = (node.text or "").strip()
|
|
5477
|
+
if text.startswith("ip dhcp pool"):
|
|
5478
|
+
in_pool = True
|
|
5479
|
+
continue
|
|
5480
|
+
if in_pool:
|
|
5481
|
+
match = re.match(r"^network (\d+\.\d+\.\d+)\.\d+ ", text)
|
|
5482
|
+
if match:
|
|
5483
|
+
pooled_networks.add(match.group(1))
|
|
5484
|
+
in_pool = False
|
|
5485
|
+
elif text.startswith(("interface ", "ip dhcp pool", "router ")):
|
|
5486
|
+
in_pool = False
|
|
5487
|
+
|
|
5488
|
+
# VLAN 1 is nobody's plan, and a number past the third octet has no place
|
|
5489
|
+
# in this scheme.
|
|
5490
|
+
wanted = sorted(
|
|
5491
|
+
(vlan for vlan in populated if vlan != "1" and int(vlan) <= 254), key=int
|
|
5492
|
+
)
|
|
5493
|
+
def already_pooled(vlan: str) -> bool:
|
|
5494
|
+
votes = host_subnets.get(vlan)
|
|
5495
|
+
network = votes.most_common(1)[0][0] if votes else f"10.10.{vlan}"
|
|
5496
|
+
return network in pooled_networks
|
|
5497
|
+
|
|
5498
|
+
if not any(vlan not in known or not already_pooled(vlan) for vlan in wanted):
|
|
5499
|
+
return []
|
|
5500
|
+
|
|
5501
|
+
added: list[str] = []
|
|
5502
|
+
for vlan in wanted:
|
|
5503
|
+
votes = host_subnets.get(vlan)
|
|
5504
|
+
network = votes.most_common(1)[0][0] if votes else f"10.10.{vlan}"
|
|
5505
|
+
masks = host_masks.get(vlan)
|
|
5506
|
+
mask = masks.most_common(1)[0][0] if masks else "255.255.255.0"
|
|
5507
|
+
first, last_reserved, first_served = _pool_window(network, mask)
|
|
5508
|
+
if not first:
|
|
5509
|
+
mask, = ("255.255.255.0",)
|
|
5510
|
+
first, last_reserved, first_served = _pool_window(network, mask)
|
|
5511
|
+
lines: list[str] = []
|
|
5512
|
+
if vlan not in known:
|
|
5513
|
+
lines += [
|
|
5514
|
+
f"interface {parent}.{vlan}",
|
|
5515
|
+
f" description VLAN{vlan}",
|
|
5516
|
+
f" encapsulation dot1Q {vlan}",
|
|
5517
|
+
f" ip address {first} {mask}",
|
|
5518
|
+
" ip nat inside",
|
|
5519
|
+
"!",
|
|
5520
|
+
]
|
|
5521
|
+
if not already_pooled(vlan):
|
|
5522
|
+
lines += [
|
|
5523
|
+
f"ip dhcp excluded-address {first} {last_reserved}",
|
|
5524
|
+
f"ip dhcp pool VLAN{vlan}",
|
|
5525
|
+
f" network {network}.0 {mask}",
|
|
5526
|
+
f" default-router {first}",
|
|
5527
|
+
]
|
|
5528
|
+
if not lines:
|
|
5529
|
+
continue
|
|
5530
|
+
for text in lines:
|
|
5531
|
+
node = ET.SubElement(config, "LINE")
|
|
5532
|
+
node.text = text
|
|
5533
|
+
added.append(
|
|
5534
|
+
f"VLAN {vlan}"
|
|
5535
|
+
+ (" gateway" if vlan not in known else "")
|
|
5536
|
+
+ (" pool" if not already_pooled(vlan) else "")
|
|
5537
|
+
)
|
|
5538
|
+
name = router.findtext("./ENGINE/NAME") or ""
|
|
5539
|
+
return [f"{name}: served {len(added)} VLAN(s) that had hosts and no gateway: " + "; ".join(added)]
|
|
5540
|
+
|
|
5541
|
+
|
|
5542
|
+
def _put_workstations_on_dhcp(root: ET.Element) -> list[str]:
|
|
5543
|
+
"""Let the pools actually serve someone.
|
|
5544
|
+
|
|
5545
|
+
A DHCP pool and a DHCP client are two halves of one feature, and only the
|
|
5546
|
+
first half was ever emitted for a segmented lab: `_synthesize_service_ops`
|
|
5547
|
+
puts hosts on DHCP only when the prompt names no VLAN, so every VLAN lab
|
|
5548
|
+
shipped one pool per VLAN with not a single device asking for an address.
|
|
5549
|
+
The prompt said `dhcp olsun`, the configuration showed the pools, and every
|
|
5550
|
+
workstation sat on a static address typed by the generator.
|
|
4709
5551
|
|
|
5552
|
+
Workstations move; servers and printers do not. A real network gives the
|
|
5553
|
+
first group addresses and pins the second, so only PCs and laptops are
|
|
5554
|
+
switched over, and the pool is told to keep the low addresses free -- the
|
|
5555
|
+
gateway, the servers on .50, the printers on .60 -- and hand out from .100.
|
|
5556
|
+
"""
|
|
5557
|
+
pools: list[tuple[ET.Element, str]] = []
|
|
5558
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5559
|
+
if (device.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
5560
|
+
continue
|
|
5561
|
+
config = device.find("./ENGINE/RUNNINGCONFIG")
|
|
5562
|
+
if config is None:
|
|
5563
|
+
continue
|
|
5564
|
+
in_pool = False
|
|
5565
|
+
for node in config.findall("LINE"):
|
|
5566
|
+
text = (node.text or "").strip()
|
|
5567
|
+
if text.startswith("ip dhcp pool"):
|
|
5568
|
+
in_pool = True
|
|
5569
|
+
continue
|
|
5570
|
+
if in_pool:
|
|
5571
|
+
match = re.match(r"^network (\d+\.\d+\.\d+)\.\d+ 255\.255\.255\.0$", text)
|
|
5572
|
+
if match:
|
|
5573
|
+
pools.append((device, match.group(1)))
|
|
5574
|
+
in_pool = False
|
|
5575
|
+
elif text.startswith(("interface ", "ip dhcp pool", "router ")):
|
|
5576
|
+
in_pool = False
|
|
5577
|
+
if not pools:
|
|
5578
|
+
return []
|
|
5579
|
+
|
|
5580
|
+
served = {subnet for _device, subnet in pools}
|
|
5581
|
+
for device, subnet in pools:
|
|
5582
|
+
config = device.find("./ENGINE/RUNNINGCONFIG")
|
|
5583
|
+
if config is None:
|
|
5584
|
+
continue
|
|
5585
|
+
first, last_reserved, _first_served = _pool_window(subnet, "255.255.255.0")
|
|
5586
|
+
wanted = f"ip dhcp excluded-address {first} {last_reserved}"
|
|
5587
|
+
if any((node.text or "").strip() == wanted for node in config.findall("LINE")):
|
|
5588
|
+
continue
|
|
5589
|
+
node = ET.Element("LINE")
|
|
5590
|
+
node.text = wanted
|
|
5591
|
+
children = list(config)
|
|
5592
|
+
first_pool = next(
|
|
5593
|
+
(
|
|
5594
|
+
child
|
|
5595
|
+
for child in children
|
|
5596
|
+
if (child.text or "").strip().startswith("ip dhcp pool")
|
|
5597
|
+
),
|
|
5598
|
+
None,
|
|
5599
|
+
)
|
|
5600
|
+
config.insert(children.index(first_pool) if first_pool is not None else len(children), node)
|
|
5601
|
+
|
|
5602
|
+
# A workstation's VLAN is the truth about which network it belongs to; its
|
|
5603
|
+
# address may still be the donor's. Pruning leaves hosts on the donor's
|
|
5604
|
+
# plan -- measured on a 140-device lab, 23 subnets across 95 hosts, half of
|
|
5605
|
+
# them 192.168.x that no router interface serves -- and re-addressing each
|
|
5606
|
+
# one by hand is a second address plan waiting to disagree with the first.
|
|
5607
|
+
# Reading the VLAN off the port and letting DHCP supply the address puts
|
|
5608
|
+
# every host on the network it is actually cabled into.
|
|
5609
|
+
vlan_subnets = _vlan_subnets_from_router(root)
|
|
5610
|
+
devices_by_ref = {
|
|
5611
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
5612
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
5613
|
+
}
|
|
5614
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
5615
|
+
|
|
5616
|
+
def access_vlan(switch: ET.Element, port: str) -> str:
|
|
5617
|
+
inside = False
|
|
5618
|
+
for node in switch.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
5619
|
+
line = (node.text or "").strip()
|
|
5620
|
+
if line.startswith("interface "):
|
|
5621
|
+
inside = line == f"interface {port}"
|
|
5622
|
+
elif inside and line.startswith("switchport access vlan "):
|
|
5623
|
+
return line.split()[-1]
|
|
5624
|
+
return ""
|
|
5625
|
+
|
|
5626
|
+
vlan_of_host: dict[str, str] = {}
|
|
5627
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
5628
|
+
cable = link.find("./CABLE")
|
|
5629
|
+
if cable is None:
|
|
5630
|
+
continue
|
|
5631
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
5632
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
5633
|
+
if len(ports) < 2:
|
|
5634
|
+
continue
|
|
5635
|
+
for near, far in ((0, 1), (1, 0)):
|
|
5636
|
+
host = devices_by_ref.get(refs[near])
|
|
5637
|
+
switch = devices_by_ref.get(refs[far])
|
|
5638
|
+
if host is None or switch is None:
|
|
5639
|
+
continue
|
|
5640
|
+
if (switch.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
5641
|
+
continue
|
|
5642
|
+
vlan = access_vlan(switch, ports[far])
|
|
5643
|
+
if vlan:
|
|
5644
|
+
vlan_of_host[(host.findtext("./ENGINE/NAME") or "").strip()] = vlan
|
|
5645
|
+
|
|
5646
|
+
moved: list[str] = []
|
|
5647
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5648
|
+
kind = _normalize_device_type(device.findtext("./ENGINE/TYPE") or "")
|
|
5649
|
+
if kind not in {"PC", "Laptop"}:
|
|
5650
|
+
continue
|
|
5651
|
+
address = ""
|
|
5652
|
+
for node in device.iter():
|
|
5653
|
+
text = (node.text or "").strip()
|
|
5654
|
+
if node.tag.upper() == "IP" and re.fullmatch(r"\d+\.\d+\.\d+\.\d+", text):
|
|
5655
|
+
address = text
|
|
5656
|
+
break
|
|
5657
|
+
vlan = vlan_of_host.get((device.findtext("./ENGINE/NAME") or "").strip(), "")
|
|
5658
|
+
served_by_vlan = vlan_subnets.get(vlan, "") in served if vlan else False
|
|
5659
|
+
if not served_by_vlan and (not address or address.rsplit(".", 1)[0] not in served):
|
|
5660
|
+
continue
|
|
5661
|
+
for port in device.findall(".//PORT"):
|
|
5662
|
+
if port.find("PORT_DHCP_ENABLE") is not None:
|
|
5663
|
+
_ensure_text(port, "PORT_DHCP_ENABLE", "true")
|
|
5664
|
+
engine = device.find("./ENGINE")
|
|
5665
|
+
if engine is not None:
|
|
5666
|
+
for profile in _profile_nodes(engine):
|
|
5667
|
+
_ensure_text(profile, "DHCP_ENABLED", "1")
|
|
5668
|
+
moved.append(f"{device.findtext('./ENGINE/NAME') or ''}: {address} -> DHCP")
|
|
5669
|
+
return moved
|
|
5670
|
+
|
|
5671
|
+
|
|
5672
|
+
def _retarget_config_to_real_interface_names(root: ET.Element) -> list[str]:
|
|
5673
|
+
"""Move a config block onto the interface the device actually has.
|
|
5674
|
+
|
|
5675
|
+
The planner names an interface from the model it planned; the donor supplies
|
|
5676
|
+
a different model; nothing compares the two. `_router_port` gave
|
|
5677
|
+
`GigabitEthernet0/0/0` -- an ISR's shape -- and the device that arrived was a
|
|
5678
|
+
2911 carrying `GigabitEthernet0/0` .. `0/2`. The block was written, and
|
|
5679
|
+
`_drop_config_for_absent_interfaces` then deleted it, which is right for
|
|
5680
|
+
hardware that is genuinely gone and wrong here: the intent was real and the
|
|
5681
|
+
only thing mistaken was the spelling.
|
|
5682
|
+
|
|
5683
|
+
Measured on "2 router 2 switch 6 komputer qur ipv6 ve ospfv3 olsun": the
|
|
5684
|
+
IPv6 address and every OSPFv3 line went out with the block, and the lab
|
|
5685
|
+
shipped with `ipv6 unicast-routing` -- a global line, so it survived -- and
|
|
5686
|
+
not one IPv6 address on anything.
|
|
5687
|
+
|
|
5688
|
+
Only the slot spelling is changed, never the port number: `0/0/2` may become
|
|
5689
|
+
`0/2` and `0/1` may become `0/0/1`, and a subinterface keeps its tag. A name
|
|
5690
|
+
that no rewriting makes real is left for the drop pass to deal with.
|
|
5691
|
+
"""
|
|
5692
|
+
from pkt_transformer import port_exists
|
|
5693
|
+
|
|
5694
|
+
moved: list[str] = []
|
|
5695
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5696
|
+
if (device.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
5697
|
+
continue
|
|
5698
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
5699
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
5700
|
+
config = device.find(f"./ENGINE/{section}")
|
|
5701
|
+
if config is None:
|
|
5702
|
+
continue
|
|
5703
|
+
taken = {
|
|
5704
|
+
(node.text or "").strip().split(None, 1)[1]
|
|
5705
|
+
for node in config.findall("LINE")
|
|
5706
|
+
if (node.text or "").strip().startswith("interface ")
|
|
5707
|
+
}
|
|
5708
|
+
for node in config.findall("LINE"):
|
|
5709
|
+
text = (node.text or "").strip()
|
|
5710
|
+
if not text.startswith("interface "):
|
|
5711
|
+
continue
|
|
5712
|
+
port = text.split(None, 1)[1]
|
|
5713
|
+
parent, _, tag = port.partition(".")
|
|
5714
|
+
if port_exists(device, parent):
|
|
5715
|
+
continue
|
|
5716
|
+
match = re.match(r"^([A-Za-z]+)((?:\d+/)+\d+)$", parent)
|
|
5717
|
+
if match is None:
|
|
5718
|
+
continue
|
|
5719
|
+
kind, numbers = match.group(1), match.group(2).split("/")
|
|
5720
|
+
shapes = []
|
|
5721
|
+
if len(numbers) == 3:
|
|
5722
|
+
shapes.append(f"{kind}{numbers[0]}/{numbers[2]}")
|
|
5723
|
+
elif len(numbers) == 2:
|
|
5724
|
+
shapes.append(f"{kind}{numbers[0]}/0/{numbers[1]}")
|
|
5725
|
+
# Renaming onto a block that already exists is allowed: the
|
|
5726
|
+
# merge pass runs later and combines them by the device's own
|
|
5727
|
+
# rule. Refusing was what made the first version of this do
|
|
5728
|
+
# nothing at all -- `GigabitEthernet0/0/0` maps to
|
|
5729
|
+
# `GigabitEthernet0/0`, and a router already has a block for
|
|
5730
|
+
# its own first interface.
|
|
5731
|
+
replacement = next(
|
|
5732
|
+
(shape for shape in shapes if port_exists(device, shape)),
|
|
5733
|
+
"",
|
|
5734
|
+
)
|
|
5735
|
+
if not replacement:
|
|
5736
|
+
continue
|
|
5737
|
+
wanted = replacement if not tag else f"{replacement}.{tag}"
|
|
5738
|
+
node.text = f"interface {wanted}"
|
|
5739
|
+
taken.discard(port)
|
|
5740
|
+
taken.add(wanted)
|
|
5741
|
+
if section == "RUNNINGCONFIG":
|
|
5742
|
+
moved.append(f"{name}: {port} -> {wanted}, the interface the device has")
|
|
5743
|
+
return moved
|
|
5744
|
+
|
|
5745
|
+
|
|
5746
|
+
def _drop_config_for_absent_interfaces(root: ET.Element) -> list[str]:
|
|
5747
|
+
"""Delete configuration for interfaces the device does not have.
|
|
5748
|
+
|
|
5749
|
+
Pruning a donor leaves whole interface blocks behind for hardware that is
|
|
5750
|
+
no longer there. On the generated company lab R1 was a 2911 -- ports
|
|
5751
|
+
`GigabitEthernet0/0` .. `0/2` -- and still carried
|
|
5752
|
+
`GigabitEthernet0/0/0.10` through `.50`, an ISR's naming, each with a
|
|
5753
|
+
192.168.x address.
|
|
5754
|
+
|
|
5755
|
+
They are not merely untidy. They are read as real interfaces by everything
|
|
5756
|
+
that reasons about the router's networks, and they carry the donor's whole
|
|
5757
|
+
address plan: `_align_dhcp_pools_with_interfaces` saw pools serving
|
|
5758
|
+
192.168.30.0 "matching an interface" and left them pointing at a network
|
|
5759
|
+
no cable reaches, so the lab had DHCP configured and handed out nothing.
|
|
5760
|
+
|
|
5761
|
+
A block is removed only when the device's own port list says the parent
|
|
5762
|
+
does not exist, so a subinterface of a real port is never touched.
|
|
5763
|
+
"""
|
|
5764
|
+
dropped: list[str] = []
|
|
5765
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5766
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
5767
|
+
config = device.find(f"./ENGINE/{section}")
|
|
5768
|
+
if config is None:
|
|
5769
|
+
continue
|
|
5770
|
+
removing = False
|
|
5771
|
+
removed_here: list[str] = []
|
|
5772
|
+
for node in list(config.findall("LINE")):
|
|
5773
|
+
text = (node.text or "")
|
|
5774
|
+
stripped = text.strip()
|
|
5775
|
+
if stripped.startswith("interface "):
|
|
5776
|
+
name = stripped.split(None, 1)[1]
|
|
5777
|
+
parent = name.split(".", 1)[0]
|
|
5778
|
+
# A VLAN interface, a loopback, a port-channel or a tunnel
|
|
5779
|
+
# is configuration, not a socket -- `port_exists` says so
|
|
5780
|
+
# deliberately, and reading that as "the device does not
|
|
5781
|
+
# have it" deletes the management SVI. Measured: "management
|
|
5782
|
+
# vlan 99 ve telnet olsun" produced a lab with VLAN 99
|
|
5783
|
+
# declared, its ports assigned and its trunks allowing it,
|
|
5784
|
+
# and no `interface Vlan99` for anything to telnet into.
|
|
5785
|
+
removing = not parent.startswith(
|
|
5786
|
+
("Vlan", "Loopback", "Port-channel", "Tunnel", "PRP-channel")
|
|
5787
|
+
) and not port_exists(device, parent)
|
|
5788
|
+
if removing:
|
|
5789
|
+
removed_here.append(name)
|
|
5790
|
+
config.remove(node)
|
|
5791
|
+
continue
|
|
5792
|
+
if removing and (text.startswith((" ", "\t")) or stripped == "!"):
|
|
5793
|
+
config.remove(node)
|
|
5794
|
+
continue
|
|
5795
|
+
removing = False
|
|
5796
|
+
if removed_here and section == "RUNNINGCONFIG":
|
|
5797
|
+
dropped.append(
|
|
5798
|
+
f"{device.findtext('./ENGINE/NAME') or ''}: dropped {len(removed_here)} "
|
|
5799
|
+
f"block(s) for absent interfaces ({', '.join(removed_here[:3])})"
|
|
5800
|
+
)
|
|
5801
|
+
return dropped
|
|
5802
|
+
|
|
5803
|
+
|
|
5804
|
+
def _drop_port_security_from_trunks(root: ET.Element) -> list[str]:
|
|
5805
|
+
"""Port security belongs on an access port, never on a trunk.
|
|
5806
|
+
|
|
5807
|
+
A trunk carries every MAC address behind the switch on the other end, so
|
|
5808
|
+
`switchport port-security maximum 2` on one is a violation waiting for the
|
|
5809
|
+
third host to speak. Measured on the generated company lab: SW1's trunk to
|
|
5810
|
+
SW3 carried port security, SW3 was cut off from the rest of the network,
|
|
5811
|
+
and its whole VLAN 10 -- three workstations -- fell back to APIPA because
|
|
5812
|
+
no DHCP offer could reach them. Every other VLAN leased normally.
|
|
5813
|
+
|
|
5814
|
+
The port-security operation is emitted against a port chosen before the
|
|
5815
|
+
trunks are settled, so the two decisions are made independently and only
|
|
5816
|
+
the cable knows which port ended up carrying a trunk.
|
|
5817
|
+
"""
|
|
5818
|
+
dropped: list[str] = []
|
|
5819
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5820
|
+
if (device.findtext("./ENGINE/TYPE") or "") not in {"Switch", "MultiLayerSwitch"}:
|
|
5821
|
+
continue
|
|
5822
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
5823
|
+
config = device.find(f"./ENGINE/{section}")
|
|
5824
|
+
if config is None:
|
|
5825
|
+
continue
|
|
5826
|
+
blocks: dict[str, list[ET.Element]] = {}
|
|
5827
|
+
current = ""
|
|
5828
|
+
for node in config.findall("LINE"):
|
|
5829
|
+
text = (node.text or "").strip()
|
|
5830
|
+
if text.startswith("interface "):
|
|
5831
|
+
current = text.split(None, 1)[1]
|
|
5832
|
+
blocks.setdefault(current, [])
|
|
5833
|
+
elif current:
|
|
5834
|
+
blocks.setdefault(current, []).append(node)
|
|
5835
|
+
for port, body in blocks.items():
|
|
5836
|
+
if not any((n.text or "").strip() == "switchport mode trunk" for n in body):
|
|
5837
|
+
continue
|
|
5838
|
+
removed = [n for n in body if "port-security" in (n.text or "")]
|
|
5839
|
+
for node in removed:
|
|
5840
|
+
if node in list(config):
|
|
5841
|
+
config.remove(node)
|
|
5842
|
+
if removed and section == "RUNNINGCONFIG":
|
|
5843
|
+
dropped.append(
|
|
5844
|
+
f"{device.findtext('./ENGINE/NAME') or ''}:{port} port security "
|
|
5845
|
+
f"removed ({len(removed)} line(s)) -- it is a trunk"
|
|
5846
|
+
)
|
|
5847
|
+
return dropped
|
|
5848
|
+
|
|
5849
|
+
|
|
5850
|
+
def _align_etherchannels_with_cabling(root: ET.Element) -> list[str]:
|
|
5851
|
+
"""An EtherChannel is two or more cables between the same two switches.
|
|
5852
|
+
|
|
5853
|
+
`_synthesize_security_ops` writes `channel-group 1 mode on` onto
|
|
5854
|
+
`GigabitEthernet0/1` and `GigabitEthernet0/2` of the first two switches it
|
|
5855
|
+
finds, without ever asking what those ports are cabled to. Measured on the
|
|
5856
|
+
153-device enterprise lab: SW2's `Gi0/1` is its only uplink to the core and
|
|
5857
|
+
`Gi0/2` has no cable at all, so the pass bundled a live trunk with a dead
|
|
5858
|
+
port towards a peer -- SW18 -- that was not bundling anything. The port
|
|
5859
|
+
joins a `Port-channel1` that no line in the file configures, the trunk
|
|
5860
|
+
settings stop applying, and the switch drops off the network: Printer6, the
|
|
5861
|
+
one routable host behind SW2, could not reach even its own gateway's real
|
|
5862
|
+
address on VLAN 50, while identical hosts behind SW3 and SW4 -- neither of
|
|
5863
|
+
which was given a channel-group -- answered normally.
|
|
5864
|
+
|
|
5865
|
+
Which ports form a bundle is a fact about the cabling, and the plan settles
|
|
5866
|
+
it before any cable exists, so the two decisions are made independently and
|
|
5867
|
+
nothing compares them. This pass is the one that can see the cables.
|
|
5868
|
+
|
|
5869
|
+
A pair asked to bundle needs two of them. Where only one exists and both
|
|
5870
|
+
switches still have a free port, the second is laid here -- the only place
|
|
5871
|
+
that knows both which pair was asked and which ports are still free -- and
|
|
5872
|
+
every cable between the pair becomes a member, with one channel number, one
|
|
5873
|
+
mode, the trunk settings copied onto the ports that were not trunks before,
|
|
5874
|
+
and the `interface Port-channelN` that holds them. Where the second cable
|
|
5875
|
+
cannot be laid the line goes, because a bundle of one is not a bundle: it is
|
|
5876
|
+
an ordinary port carrying configuration that stops it working.
|
|
5877
|
+
"""
|
|
5878
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
5879
|
+
devices_by_ref: dict[str, ET.Element] = {}
|
|
5880
|
+
devices_by_name: dict[str, ET.Element] = {}
|
|
5881
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5882
|
+
devices_by_ref[(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip()] = device
|
|
5883
|
+
devices_by_name[(device.findtext("./ENGINE/NAME") or "").strip()] = device
|
|
5884
|
+
|
|
5885
|
+
members: dict[tuple[str, str], tuple[int, str]] = {}
|
|
5886
|
+
for name, device in devices_by_name.items():
|
|
5887
|
+
if (device.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
5888
|
+
continue
|
|
5889
|
+
current = ""
|
|
5890
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
5891
|
+
text = (node.text or "").strip()
|
|
5892
|
+
if text.startswith("interface "):
|
|
5893
|
+
current = text.split(None, 1)[1]
|
|
5894
|
+
continue
|
|
5895
|
+
match = re.match(r"^channel-group (\d+) mode (\S+)$", text)
|
|
5896
|
+
if match and current:
|
|
5897
|
+
members[(name, current)] = (int(match.group(1)), match.group(2))
|
|
5898
|
+
if not members:
|
|
5899
|
+
return []
|
|
5900
|
+
|
|
5901
|
+
from pkt_editor import _ensure_link
|
|
5902
|
+
|
|
5903
|
+
busy: set[tuple[str, str]] = set()
|
|
5904
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
5905
|
+
cable = link.find("./CABLE")
|
|
5906
|
+
if cable is None:
|
|
5907
|
+
continue
|
|
5908
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
5909
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
5910
|
+
for index, ref in enumerate(refs):
|
|
5911
|
+
device = devices_by_ref.get(ref)
|
|
5912
|
+
if device is not None and index < len(ports):
|
|
5913
|
+
busy.add(((device.findtext("./ENGINE/NAME") or "").strip(), ports[index]))
|
|
5914
|
+
|
|
5915
|
+
bundles: dict[tuple[str, str], list[tuple[tuple[str, str], tuple[str, str]]]] = {}
|
|
5916
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
5917
|
+
cable = link.find("./CABLE")
|
|
5918
|
+
if cable is None:
|
|
5919
|
+
continue
|
|
5920
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
5921
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
5922
|
+
if len(ports) < 2:
|
|
5923
|
+
continue
|
|
5924
|
+
left, right = devices_by_ref.get(refs[0]), devices_by_ref.get(refs[1])
|
|
5925
|
+
if left is None or right is None:
|
|
5926
|
+
continue
|
|
5927
|
+
if (left.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
5928
|
+
continue
|
|
5929
|
+
if (right.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
5930
|
+
continue
|
|
5931
|
+
ends = (
|
|
5932
|
+
((left.findtext("./ENGINE/NAME") or "").strip(), ports[0]),
|
|
5933
|
+
((right.findtext("./ENGINE/NAME") or "").strip(), ports[1]),
|
|
5934
|
+
)
|
|
5935
|
+
# One end asking is enough to record the intent. Which end was given the
|
|
5936
|
+
# line is an accident of the order the planner walked the switches.
|
|
5937
|
+
if ends[0] not in members and ends[1] not in members:
|
|
5938
|
+
continue
|
|
5939
|
+
bundles.setdefault(tuple(sorted((ends[0][0], ends[1][0]))), []).append(ends)
|
|
5940
|
+
|
|
5941
|
+
def trunk_body(name: str, port: str) -> list[str]:
|
|
5942
|
+
device = devices_by_name.get(name)
|
|
5943
|
+
wanted: list[str] = []
|
|
5944
|
+
if device is None:
|
|
5945
|
+
return wanted
|
|
5946
|
+
inside = False
|
|
5947
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
5948
|
+
text = (node.text or "").strip()
|
|
5949
|
+
if text.startswith("interface "):
|
|
5950
|
+
inside = text == f"interface {port}"
|
|
5951
|
+
elif inside and text.startswith("switchport") and text not in wanted:
|
|
5952
|
+
wanted.append(text)
|
|
5953
|
+
return [f" {line}" for line in wanted]
|
|
5954
|
+
|
|
5955
|
+
def drop_line(config: ET.Element, port: str, prefix: str) -> None:
|
|
5956
|
+
"""`_set_config_block` matches on the first two words, so an access VLAN
|
|
5957
|
+
left on a port that is now a trunk survives writing `switchport mode
|
|
5958
|
+
trunk` beside it."""
|
|
5959
|
+
current = ""
|
|
5960
|
+
for node in list(config.findall("LINE")):
|
|
5961
|
+
text = (node.text or "").strip()
|
|
5962
|
+
if text.startswith("interface "):
|
|
5963
|
+
current = text.split(None, 1)[1]
|
|
5964
|
+
elif current == port and text.startswith(prefix):
|
|
5965
|
+
config.remove(node)
|
|
5966
|
+
|
|
5967
|
+
def free_ports(name: str, family: str, count: int) -> list[str]:
|
|
5968
|
+
"""Free sockets of one speed, because a bundle cannot mix them.
|
|
5969
|
+
|
|
5970
|
+
A gigabit port bundled with a FastEthernet one is refused for speed
|
|
5971
|
+
mismatch, and the first attempt at this produced exactly that: SW3
|
|
5972
|
+
offered `GigabitEthernet0/1` to one neighbour and `FastEthernet0/1` to
|
|
5973
|
+
the next, because the search preferred gigabit rather than matching.
|
|
5974
|
+
"""
|
|
5975
|
+
device = devices_by_name.get(name)
|
|
5976
|
+
if device is None or not family:
|
|
5977
|
+
return []
|
|
5978
|
+
found: list[str] = []
|
|
5979
|
+
for candidate in donor_interface_names(device) or []:
|
|
5980
|
+
if not candidate.startswith(family) or "." in candidate:
|
|
5981
|
+
continue
|
|
5982
|
+
if (name, candidate) not in busy and port_exists(device, candidate):
|
|
5983
|
+
found.append(candidate)
|
|
5984
|
+
if len(found) == count:
|
|
5985
|
+
break
|
|
5986
|
+
return found
|
|
5987
|
+
|
|
5988
|
+
def family_of(port: str) -> str:
|
|
5989
|
+
for prefix in ("GigabitEthernet", "FastEthernet"):
|
|
5990
|
+
if port.startswith(prefix):
|
|
5991
|
+
return prefix
|
|
5992
|
+
return ""
|
|
5993
|
+
|
|
5994
|
+
# A switch in two bundles needs two channel numbers: members of different
|
|
5995
|
+
# neighbours cannot share one Port-channel. The planner writes `1` for
|
|
5996
|
+
# every switch it touches, so SW3 came out with four members of "channel 1"
|
|
5997
|
+
# facing two different neighbours.
|
|
5998
|
+
taken_channels: dict[str, set[int]] = {}
|
|
5999
|
+
|
|
6000
|
+
keep: dict[tuple[str, str], tuple[int, str]] = {}
|
|
6001
|
+
notes: list[str] = []
|
|
6002
|
+
for pair, cables in sorted(bundles.items()):
|
|
6003
|
+
stated = [members[end] for cable in cables for end in cable if end in members]
|
|
6004
|
+
template = next((end for cable in cables for end in cable if trunk_body(*end)), cables[0][0])
|
|
6005
|
+
body = trunk_body(*template)
|
|
6006
|
+
|
|
6007
|
+
def lay(left: tuple[str, str], right: tuple[str, str]) -> tuple[tuple[str, str], tuple[str, str]]:
|
|
6008
|
+
_ensure_link(root, left[0], left[1], right[0], right[1], "crossover", allow_parallel=True)
|
|
6009
|
+
busy.add(left)
|
|
6010
|
+
busy.add(right)
|
|
6011
|
+
return (left, right)
|
|
6012
|
+
|
|
6013
|
+
if len(cables) == 1:
|
|
6014
|
+
# A bundle needs a second cable, and the pass that lays cables ran
|
|
6015
|
+
# long before anything asked for a bundle. Adding it here is the
|
|
6016
|
+
# only place both facts are known: which pair was asked to bundle,
|
|
6017
|
+
# and which of their ports are still free.
|
|
6018
|
+
(left_name, left_member), (right_name, right_member) = cables[0]
|
|
6019
|
+
family = family_of(left_member)
|
|
6020
|
+
if family == family_of(right_member):
|
|
6021
|
+
left_free = free_ports(left_name, family, 1)
|
|
6022
|
+
right_free = free_ports(right_name, family, 1)
|
|
6023
|
+
if left_free and right_free:
|
|
6024
|
+
cables.append(lay((left_name, left_free[0]), (right_name, right_free[0])))
|
|
6025
|
+
if len(cables) == 1:
|
|
6026
|
+
# The member's own speed has no socket left -- on a 2960 both
|
|
6027
|
+
# gigabit ports are usually the two uplinks. A bundle can still be
|
|
6028
|
+
# built beside it out of two cables of a speed that does have room;
|
|
6029
|
+
# the original cable stays an ordinary trunk and loses its line.
|
|
6030
|
+
(left_name, _), (right_name, _) = cables[0]
|
|
6031
|
+
for family in ("GigabitEthernet", "FastEthernet"):
|
|
6032
|
+
left_free = free_ports(left_name, family, 2)
|
|
6033
|
+
right_free = free_ports(right_name, family, 2)
|
|
6034
|
+
if len(left_free) == 2 and len(right_free) == 2:
|
|
6035
|
+
cables = [
|
|
6036
|
+
lay((left_name, left_free[index]), (right_name, right_free[index]))
|
|
6037
|
+
for index in range(2)
|
|
6038
|
+
]
|
|
6039
|
+
break
|
|
6040
|
+
if len(cables) < 2:
|
|
6041
|
+
continue
|
|
6042
|
+
mode = stated[0][1]
|
|
6043
|
+
used = taken_channels.setdefault(pair[0], set()) | taken_channels.setdefault(pair[1], set())
|
|
6044
|
+
channel = next(
|
|
6045
|
+
number
|
|
6046
|
+
for number in [min(n for n, _ in stated), *range(1, 65)]
|
|
6047
|
+
if number not in used
|
|
6048
|
+
)
|
|
6049
|
+
taken_channels[pair[0]].add(channel)
|
|
6050
|
+
taken_channels[pair[1]].add(channel)
|
|
6051
|
+
for cable in cables:
|
|
6052
|
+
for end in cable:
|
|
6053
|
+
keep[end] = (channel, mode)
|
|
6054
|
+
if end in members:
|
|
6055
|
+
continue
|
|
6056
|
+
# A port that was never a trunk -- either the far end of the
|
|
6057
|
+
# intent, or the one just cabled -- has to carry what the rest
|
|
6058
|
+
# of the bundle carries, or the two halves disagree.
|
|
6059
|
+
config = devices_by_name[end[0]].find("./ENGINE/RUNNINGCONFIG")
|
|
6060
|
+
if config is not None and body:
|
|
6061
|
+
_set_config_block(config, f"interface {end[1]}", body)
|
|
6062
|
+
drop_line(config, end[1], "switchport access vlan")
|
|
6063
|
+
notes.append(
|
|
6064
|
+
f"{pair[0]} <-> {pair[1]} bundled on Port-channel{channel} "
|
|
6065
|
+
f"({len(cables)} cables, mode {mode})"
|
|
6066
|
+
)
|
|
6067
|
+
|
|
6068
|
+
for (name, port), (channel, mode) in sorted(keep.items()):
|
|
6069
|
+
device = devices_by_name.get(name)
|
|
6070
|
+
config = device.find("./ENGINE/RUNNINGCONFIG") if device is not None else None
|
|
6071
|
+
if config is None:
|
|
6072
|
+
continue
|
|
6073
|
+
_set_config_block(config, f"interface {port}", [f" channel-group {channel} mode {mode}"])
|
|
6074
|
+
body = trunk_body(name, port)
|
|
6075
|
+
if body:
|
|
6076
|
+
_set_config_block(config, f"interface Port-channel{channel}", body)
|
|
6077
|
+
|
|
6078
|
+
removed = 0
|
|
6079
|
+
for name, device in sorted(devices_by_name.items()):
|
|
6080
|
+
if (device.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
6081
|
+
continue
|
|
6082
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
6083
|
+
config = device.find(f"./ENGINE/{section}")
|
|
6084
|
+
if config is None:
|
|
6085
|
+
continue
|
|
6086
|
+
current = ""
|
|
6087
|
+
doomed: list[ET.Element] = []
|
|
6088
|
+
for node in config.findall("LINE"):
|
|
6089
|
+
text = (node.text or "").strip()
|
|
6090
|
+
if text.startswith("interface "):
|
|
6091
|
+
current = text.split(None, 1)[1]
|
|
6092
|
+
elif text.startswith("channel-group ") and (name, current) not in keep:
|
|
6093
|
+
doomed.append(node)
|
|
6094
|
+
for node in doomed:
|
|
6095
|
+
if node in list(config):
|
|
6096
|
+
config.remove(node)
|
|
6097
|
+
if doomed and section == "RUNNINGCONFIG":
|
|
6098
|
+
removed += len(doomed)
|
|
6099
|
+
if removed:
|
|
6100
|
+
notes.append(f"{removed} channel-group line(s) removed -- no bundle on the other end")
|
|
6101
|
+
return notes
|
|
6102
|
+
|
|
6103
|
+
|
|
6104
|
+
def _match_trunk_native_vlans(root: ET.Element) -> list[str]:
|
|
6105
|
+
"""Both ends of a trunk have to agree on the native VLAN.
|
|
6106
|
+
|
|
6107
|
+
One end saying `switchport trunk native vlan 99` while the other says
|
|
6108
|
+
nothing -- which means VLAN 1 -- is a mismatch, and Packet Tracer does not
|
|
6109
|
+
let it pass quietly. Measured on the generated company lab, in SW2's own
|
|
6110
|
+
log:
|
|
6111
|
+
|
|
6112
|
+
%CDP-4-NATIVE_VLAN_MISMATCH: ... Gi0/1 (99), with SW5 Fa0/2 (1)
|
|
6113
|
+
%SPANTREE-2-BLOCK_PVID_LOCAL: Blocking Gi0/1 on VLAN0099
|
|
6114
|
+
|
|
6115
|
+
Spanning tree blocks the port, so the switch is cabled, configured, shown
|
|
6116
|
+
as up, and carrying nothing. Three of the four inter-switch trunks were
|
|
6117
|
+
like that: the access switches took `native vlan 99` from the plan and the
|
|
6118
|
+
core's side of the same cable was written by the uplink pass, which never
|
|
6119
|
+
mentioned a native VLAN.
|
|
6120
|
+
|
|
6121
|
+
The end that names one wins, because that is the deliberate choice; VLAN 1
|
|
6122
|
+
is only ever the default nobody asked for.
|
|
6123
|
+
"""
|
|
6124
|
+
devices = {
|
|
6125
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
6126
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
6127
|
+
}
|
|
6128
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
6129
|
+
|
|
6130
|
+
def native_of(device: ET.Element, port: str) -> str:
|
|
6131
|
+
inside = False
|
|
6132
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
6133
|
+
line = (node.text or "").strip()
|
|
6134
|
+
if line.startswith("interface "):
|
|
6135
|
+
inside = line == f"interface {port}"
|
|
6136
|
+
elif inside:
|
|
6137
|
+
match = re.match(r"^switchport trunk native vlan (\d+)$", line)
|
|
6138
|
+
if match:
|
|
6139
|
+
return match.group(1)
|
|
6140
|
+
return ""
|
|
6141
|
+
|
|
6142
|
+
notes: list[str] = []
|
|
6143
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
6144
|
+
cable = link.find("./CABLE")
|
|
6145
|
+
if cable is None:
|
|
6146
|
+
continue
|
|
6147
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
6148
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
6149
|
+
if len(ports) < 2:
|
|
6150
|
+
continue
|
|
6151
|
+
left, right = devices.get(refs[0]), devices.get(refs[1])
|
|
6152
|
+
if left is None or right is None:
|
|
6153
|
+
continue
|
|
6154
|
+
if (left.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
6155
|
+
continue
|
|
6156
|
+
if (right.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
6157
|
+
continue
|
|
6158
|
+
natives = [native_of(left, ports[0]), native_of(right, ports[1])]
|
|
6159
|
+
if natives[0] == natives[1]:
|
|
6160
|
+
continue
|
|
6161
|
+
wanted = natives[0] or natives[1]
|
|
6162
|
+
if not wanted:
|
|
6163
|
+
continue
|
|
6164
|
+
for device, port, current in ((left, ports[0], natives[0]), (right, ports[1], natives[1])):
|
|
6165
|
+
if current == wanted:
|
|
6166
|
+
continue
|
|
6167
|
+
config = device.find("./ENGINE/RUNNINGCONFIG")
|
|
6168
|
+
if config is None:
|
|
6169
|
+
continue
|
|
6170
|
+
_set_config_block(
|
|
6171
|
+
config,
|
|
6172
|
+
f"interface {port}",
|
|
6173
|
+
[" switchport mode trunk", f" switchport trunk native vlan {wanted}"],
|
|
6174
|
+
)
|
|
6175
|
+
notes.append(
|
|
6176
|
+
f"{device.findtext('./ENGINE/NAME') or ''}:{port} native VLAN "
|
|
6177
|
+
f"{current or '1'} -> {wanted}"
|
|
6178
|
+
)
|
|
6179
|
+
return notes
|
|
6180
|
+
|
|
6181
|
+
|
|
6182
|
+
def _drop_inherited_sticky_macs(root: ET.Element) -> list[str]:
|
|
6183
|
+
"""Remove sticky MAC addresses the donor's devices left behind.
|
|
6184
|
+
|
|
6185
|
+
`switchport port-security mac-address sticky` is a directive: learn the
|
|
6186
|
+
address of whatever is plugged in. `... sticky 00E0.F925.3A9E` is the
|
|
6187
|
+
result of that learning on the donor's hardware, and a generated lab plugs
|
|
6188
|
+
a different device into the port. The address no longer matches, every
|
|
6189
|
+
frame is a violation, and `restrict` drops them all.
|
|
6190
|
+
|
|
6191
|
+
Measured on the company lab the skill generated: PC1's port on SW1 carried
|
|
6192
|
+
the donor's learned address, PC2's port on SW2 carried none. PC2 reached
|
|
6193
|
+
its gateway and routed across VLANs; PC1 could not reach anything outside
|
|
6194
|
+
its own switch. Same generator, same prompt, one stale line apart.
|
|
6195
|
+
|
|
6196
|
+
The directive stays -- port security is what the prompt asked for. Only the
|
|
6197
|
+
learned address goes, and Packet Tracer learns the new one on first frame.
|
|
6198
|
+
"""
|
|
6199
|
+
dropped: list[str] = []
|
|
6200
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
6201
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
6202
|
+
config = device.find(f"./ENGINE/{section}")
|
|
6203
|
+
if config is None:
|
|
6204
|
+
continue
|
|
6205
|
+
for node in list(config.findall("LINE")):
|
|
6206
|
+
text = (node.text or "").strip()
|
|
6207
|
+
if re.fullmatch(
|
|
6208
|
+
r"switchport port-security mac-address sticky [0-9A-Fa-f.:-]{12,}", text
|
|
6209
|
+
):
|
|
6210
|
+
config.remove(node)
|
|
6211
|
+
if section == "RUNNINGCONFIG":
|
|
6212
|
+
dropped.append(f"{device.findtext('./ENGINE/NAME') or ''}: {text}")
|
|
6213
|
+
return dropped
|
|
6214
|
+
|
|
6215
|
+
|
|
6216
|
+
def _router_subinterface_vlans(device: ET.Element, physical_port: str) -> list[str]:
|
|
6217
|
+
"""VLANs the router carries as subinterfaces of `physical_port`."""
|
|
6218
|
+
vlans: list[str] = []
|
|
6219
|
+
on_port = False
|
|
6220
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
6221
|
+
line = (node.text or "").strip()
|
|
6222
|
+
if line.startswith("interface "):
|
|
6223
|
+
name = line.split(None, 1)[1]
|
|
6224
|
+
on_port = name.startswith(f"{physical_port}.")
|
|
6225
|
+
continue
|
|
6226
|
+
if not on_port:
|
|
6227
|
+
continue
|
|
6228
|
+
match = re.match(r"^encapsulation dot1Q (\d+)", line)
|
|
6229
|
+
if match and match.group(1) not in vlans:
|
|
6230
|
+
vlans.append(match.group(1))
|
|
6231
|
+
return sorted(vlans, key=int)
|
|
6232
|
+
|
|
6233
|
+
|
|
6234
|
+
def _move_subinterfaces_to_the_cabled_port(root: ET.Element) -> list[str]:
|
|
6235
|
+
"""Put the router's dot1Q subinterfaces on the port the switch cable uses.
|
|
6236
|
+
|
|
6237
|
+
Addressing writes the subinterfaces onto one interface while the link
|
|
6238
|
+
synthesiser and port reconciliation settle the cable onto another. Measured
|
|
6239
|
+
on a five-switch lab the skill generated: R1 carried
|
|
6240
|
+
`GigabitEthernet0/1.10` .. `.99` and its only cable ran from
|
|
6241
|
+
`GigabitEthernet0/0`. Packet Tracer showed one linked port and ten
|
|
6242
|
+
subinterfaces protocol-down, so every VLAN except the one the access port
|
|
6243
|
+
happened to be in was unreachable.
|
|
6244
|
+
|
|
6245
|
+
The cable is the harder fact -- it exists in the topology and in Packet
|
|
6246
|
+
Tracer's own model -- so the configuration moves to meet it.
|
|
6247
|
+
"""
|
|
6248
|
+
devices = {
|
|
6249
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
6250
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
6251
|
+
}
|
|
6252
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
6253
|
+
cabled_to_switch: dict[str, str] = {}
|
|
6254
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
6255
|
+
cable = link.find("./CABLE")
|
|
6256
|
+
if cable is None:
|
|
6257
|
+
continue
|
|
6258
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
6259
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
6260
|
+
if len(ports) < 2:
|
|
6261
|
+
continue
|
|
6262
|
+
for near, far in ((0, 1), (1, 0)):
|
|
6263
|
+
router = devices.get(refs[near])
|
|
6264
|
+
switch = devices.get(refs[far])
|
|
6265
|
+
if router is None or switch is None:
|
|
6266
|
+
continue
|
|
6267
|
+
if (router.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
6268
|
+
continue
|
|
6269
|
+
if (switch.findtext("./ENGINE/TYPE") or "") in switch_types:
|
|
6270
|
+
cabled_to_switch.setdefault(refs[near], ports[near])
|
|
6271
|
+
|
|
6272
|
+
notes: list[str] = []
|
|
6273
|
+
for ref, cabled_port in cabled_to_switch.items():
|
|
6274
|
+
router = devices[ref]
|
|
6275
|
+
configured = ""
|
|
6276
|
+
for node in router.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
6277
|
+
line = (node.text or "").strip()
|
|
6278
|
+
if not line.startswith("interface ") or "." not in line:
|
|
6279
|
+
continue
|
|
6280
|
+
parent = line.split(None, 1)[1].split(".", 1)[0]
|
|
6281
|
+
if parent != cabled_port and port_exists(router, parent):
|
|
6282
|
+
configured = parent
|
|
6283
|
+
break
|
|
6284
|
+
if not configured or not _router_subinterface_vlans(router, configured):
|
|
6285
|
+
continue
|
|
6286
|
+
for node in router.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
6287
|
+
line = (node.text or "")
|
|
6288
|
+
stripped = line.strip()
|
|
6289
|
+
if stripped.startswith(f"interface {configured}."):
|
|
6290
|
+
node.text = line.replace(f"interface {configured}.", f"interface {cabled_port}.", 1)
|
|
6291
|
+
notes.append(
|
|
6292
|
+
f"{router.findtext('./ENGINE/NAME') or ''}: subinterfaces moved from "
|
|
6293
|
+
f"{configured} to {cabled_port} (where the switch cable is)"
|
|
6294
|
+
)
|
|
6295
|
+
|
|
6296
|
+
# Once the link is a trunk, an address on the physical port is unreachable:
|
|
6297
|
+
# every frame arrives tagged. Measured on the same lab -- VLAN 10's
|
|
6298
|
+
# subinterface carried `encapsulation dot1Q 10` and no address at all,
|
|
6299
|
+
# while `GigabitEthernet0/0` held 10.10.10.1, so VLAN 10 had no gateway.
|
|
6300
|
+
# Moved only when the pairing is unambiguous: one addressless subinterface
|
|
6301
|
+
# and one address stranded on its parent.
|
|
6302
|
+
for ref, cabled_port in cabled_to_switch.items():
|
|
6303
|
+
router = devices[ref]
|
|
6304
|
+
lines = list(router.findall("./ENGINE/RUNNINGCONFIG/LINE"))
|
|
6305
|
+
blocks: dict[str, list[ET.Element]] = {}
|
|
6306
|
+
current = ""
|
|
6307
|
+
for node in lines:
|
|
6308
|
+
text = (node.text or "").strip()
|
|
6309
|
+
if text.startswith("interface "):
|
|
6310
|
+
current = text.split(None, 1)[1]
|
|
6311
|
+
blocks.setdefault(current, [])
|
|
6312
|
+
elif current:
|
|
6313
|
+
blocks.setdefault(current, []).append(node)
|
|
6314
|
+
|
|
6315
|
+
def address_node(name: str) -> ET.Element | None:
|
|
6316
|
+
for node in blocks.get(name, []):
|
|
6317
|
+
if (node.text or "").strip().startswith("ip address "):
|
|
6318
|
+
return node
|
|
6319
|
+
return None
|
|
6320
|
+
|
|
6321
|
+
parent_address = address_node(cabled_port)
|
|
6322
|
+
if parent_address is None:
|
|
6323
|
+
continue
|
|
6324
|
+
orphans = [
|
|
6325
|
+
name
|
|
6326
|
+
for name in blocks
|
|
6327
|
+
if name.startswith(f"{cabled_port}.") and address_node(name) is None
|
|
6328
|
+
]
|
|
6329
|
+
if len(orphans) != 1:
|
|
6330
|
+
continue
|
|
6331
|
+
moved_text = (parent_address.text or "").strip()
|
|
6332
|
+
# The subinterface is addressless because it carries `no ip address`.
|
|
6333
|
+
# Leaving that line in place lets IOS apply the address and then wipe
|
|
6334
|
+
# it -- measured: VLAN 10's gateway read 0.0.0.0 in Packet Tracer while
|
|
6335
|
+
# the configuration plainly showed 10.10.10.1 three lines above.
|
|
6336
|
+
for node in list(blocks.get(orphans[0], [])):
|
|
6337
|
+
if (node.text or "").strip() == "no ip address":
|
|
6338
|
+
config_parent = router.find("./ENGINE/RUNNINGCONFIG")
|
|
6339
|
+
if config_parent is not None and node in list(config_parent):
|
|
6340
|
+
config_parent.remove(node)
|
|
6341
|
+
blocks[orphans[0]].remove(node)
|
|
6342
|
+
blocks[orphans[0]].append(parent_address)
|
|
6343
|
+
config = router.find("./ENGINE/RUNNINGCONFIG")
|
|
6344
|
+
if config is None:
|
|
6345
|
+
continue
|
|
6346
|
+
index = list(config).index(parent_address)
|
|
6347
|
+
config.remove(parent_address)
|
|
6348
|
+
# After `encapsulation dot1Q`, not straight after the header. IOS
|
|
6349
|
+
# refuses an address on a subinterface that has no encapsulation yet,
|
|
6350
|
+
# and the refusal is silent in a saved file: measured, VLAN 10's
|
|
6351
|
+
# gateway read 0.0.0.0 in Packet Tracer while `ip address 10.10.10.1`
|
|
6352
|
+
# sat two lines above `encapsulation dot1Q 10`. The subinterfaces that
|
|
6353
|
+
# worked all carry the address after the encapsulation.
|
|
6354
|
+
anchor = next(
|
|
6355
|
+
(
|
|
6356
|
+
node
|
|
6357
|
+
for node in blocks.get(orphans[0], [])
|
|
6358
|
+
if (node.text or "").strip().startswith("encapsulation ")
|
|
6359
|
+
),
|
|
6360
|
+
None,
|
|
6361
|
+
)
|
|
6362
|
+
if anchor is None:
|
|
6363
|
+
anchor = next(
|
|
6364
|
+
(
|
|
6365
|
+
node
|
|
6366
|
+
for node in config.findall("LINE")
|
|
6367
|
+
if (node.text or "").strip() == f"interface {orphans[0]}"
|
|
6368
|
+
),
|
|
6369
|
+
None,
|
|
6370
|
+
)
|
|
6371
|
+
if anchor is None:
|
|
6372
|
+
config.insert(index, parent_address)
|
|
6373
|
+
continue
|
|
6374
|
+
config.insert(list(config).index(anchor) + 1, parent_address)
|
|
6375
|
+
notes.append(
|
|
6376
|
+
f"{router.findtext('./ENGINE/NAME') or ''}: '{moved_text}' moved from "
|
|
6377
|
+
f"{cabled_port} to {orphans[0]}, which had none"
|
|
6378
|
+
)
|
|
6379
|
+
|
|
6380
|
+
# A trunk parent cannot also be the WAN uplink. Pruning leaves the donor's
|
|
6381
|
+
# `ip nat outside` and its address on the physical interface, and once the
|
|
6382
|
+
# subinterfaces live there the same port is described as both sides of the
|
|
6383
|
+
# network at once: measured on the company lab, every subinterface came up
|
|
6384
|
+
# and no host could still reach its gateway.
|
|
6385
|
+
for ref, cabled_port in cabled_to_switch.items():
|
|
6386
|
+
router = devices[ref]
|
|
6387
|
+
if not _router_subinterface_vlans(router, cabled_port):
|
|
6388
|
+
continue
|
|
6389
|
+
config = router.find("./ENGINE/RUNNINGCONFIG")
|
|
6390
|
+
if config is None:
|
|
6391
|
+
continue
|
|
6392
|
+
inside = False
|
|
6393
|
+
for node in list(config.findall("LINE")):
|
|
6394
|
+
text = (node.text or "").strip()
|
|
6395
|
+
if text.startswith("interface "):
|
|
6396
|
+
inside = text == f"interface {cabled_port}"
|
|
6397
|
+
continue
|
|
6398
|
+
if not inside:
|
|
6399
|
+
continue
|
|
6400
|
+
if text in {"ip nat outside", "ip nat inside"} or text.startswith("ip address "):
|
|
6401
|
+
config.remove(node)
|
|
6402
|
+
notes.append(
|
|
6403
|
+
f"{router.findtext('./ENGINE/NAME') or ''}: '{text}' removed from "
|
|
6404
|
+
f"{cabled_port}, which is the trunk parent"
|
|
6405
|
+
)
|
|
6406
|
+
return notes
|
|
6407
|
+
|
|
6408
|
+
|
|
6409
|
+
def _trunk_router_on_a_stick(root: ET.Element) -> list[str]:
|
|
6410
|
+
"""A switch port facing router subinterfaces has to be a trunk.
|
|
6411
|
+
|
|
6412
|
+
Measured on a five-switch lab the skill generated: R1 carried eight dot1Q
|
|
6413
|
+
subinterfaces for VLANs 10 to 99, and its single cable landed on an access
|
|
6414
|
+
port in VLAN 10. Packet Tracer reported one linked port on the router and
|
|
6415
|
+
every subinterface protocol-down, so no host could reach its gateway and
|
|
6416
|
+
nothing crossed a VLAN boundary -- while the lab opened and read correctly.
|
|
6417
|
+
|
|
6418
|
+
The router's configuration is the statement of intent here: subinterfaces
|
|
6419
|
+
with `encapsulation dot1Q` mean the link is a trunk.
|
|
6420
|
+
"""
|
|
6421
|
+
devices = {
|
|
6422
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
6423
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
6424
|
+
}
|
|
6425
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
6426
|
+
notes: list[str] = []
|
|
6427
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
6428
|
+
cable = link.find("./CABLE")
|
|
6429
|
+
if cable is None:
|
|
6430
|
+
continue
|
|
6431
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
6432
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
6433
|
+
if len(ports) < 2:
|
|
6434
|
+
continue
|
|
6435
|
+
for near, far in ((0, 1), (1, 0)):
|
|
6436
|
+
router = devices.get(refs[near])
|
|
6437
|
+
switch = devices.get(refs[far])
|
|
6438
|
+
if router is None or switch is None:
|
|
6439
|
+
continue
|
|
6440
|
+
if (router.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
6441
|
+
continue
|
|
6442
|
+
if (switch.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
6443
|
+
continue
|
|
6444
|
+
vlans = _router_subinterface_vlans(router, ports[near])
|
|
6445
|
+
if not vlans:
|
|
6446
|
+
continue
|
|
6447
|
+
config = switch.find("./ENGINE/RUNNINGCONFIG")
|
|
6448
|
+
if config is None:
|
|
6449
|
+
continue
|
|
6450
|
+
body = [" switchport mode trunk", f" switchport trunk allowed vlan {','.join(vlans)}"]
|
|
6451
|
+
if (switch.findtext("./ENGINE/TYPE") or "") == "MultiLayerSwitch":
|
|
6452
|
+
body.insert(0, " switchport trunk encapsulation dot1q")
|
|
6453
|
+
_set_config_block(config, f"interface {ports[far]}", body)
|
|
6454
|
+
notes.append(
|
|
6455
|
+
f"{switch.findtext('./ENGINE/NAME') or ''}:{ports[far]} -> trunk for VLAN "
|
|
6456
|
+
f"{','.join(vlans)} ({router.findtext('./ENGINE/NAME') or ''})"
|
|
6457
|
+
)
|
|
6458
|
+
return notes
|
|
6459
|
+
|
|
6460
|
+
|
|
6461
|
+
def _vlan_subnets_from_router(root: ET.Element) -> dict[str, str]:
|
|
6462
|
+
"""VLAN number -> the /24 its gateway sits in, read off the subinterfaces.
|
|
6463
|
+
|
|
6464
|
+
A router-on-a-stick writes the mapping down: `encapsulation dot1Q 20`
|
|
6465
|
+
followed by `ip address 10.10.20.1` says VLAN 20 is 10.10.20.0. Nothing
|
|
6466
|
+
else in the file states it, and guessing it from the third octet only works
|
|
6467
|
+
for labs that happen to number that way.
|
|
6468
|
+
|
|
6469
|
+
A router keeps subinterfaces the pruning left behind, so the same VLAN can
|
|
6470
|
+
appear twice with different addresses: one lab carried
|
|
6471
|
+
`GigabitEthernet0/0/0.20` on 192.168.20.1 from its donor alongside the live
|
|
6472
|
+
`GigabitEthernet0/1.20` on 10.10.20.1 -- and the stale one named an
|
|
6473
|
+
interface a 2911 does not even have. Only subinterfaces whose parent the
|
|
6474
|
+
device really owns are read.
|
|
6475
|
+
"""
|
|
6476
|
+
subnets: dict[str, str] = {}
|
|
6477
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
6478
|
+
if (device.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
6479
|
+
continue
|
|
6480
|
+
vlan = ""
|
|
6481
|
+
parent_is_real = False
|
|
6482
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
6483
|
+
line = (node.text or "").strip()
|
|
6484
|
+
if line.startswith("interface "):
|
|
6485
|
+
name = line.split(None, 1)[1]
|
|
6486
|
+
parent_is_real = port_exists(device, name.split(".", 1)[0])
|
|
6487
|
+
vlan = ""
|
|
6488
|
+
continue
|
|
6489
|
+
if not parent_is_real:
|
|
6490
|
+
continue
|
|
6491
|
+
match = re.match(r"^encapsulation dot1Q (\d+)", line)
|
|
6492
|
+
if match:
|
|
6493
|
+
vlan = match.group(1)
|
|
6494
|
+
continue
|
|
6495
|
+
address = re.match(r"^ip address (\d+\.\d+\.\d+)\.\d+ ", line)
|
|
6496
|
+
if address and vlan:
|
|
6497
|
+
subnets[vlan] = address.group(1)
|
|
6498
|
+
vlan = ""
|
|
6499
|
+
return subnets
|
|
6500
|
+
|
|
6501
|
+
|
|
6502
|
+
def _align_host_vlans_to_addresses(root: ET.Element) -> list[str]:
|
|
6503
|
+
"""Put each host's access port in the VLAN its address belongs to.
|
|
6504
|
+
|
|
6505
|
+
Addressing and VLAN assignment are decided by different passes, and nothing
|
|
6506
|
+
made them agree. Measured on a five-switch lab the skill generated: PC5 on
|
|
6507
|
+
10.10.10.12 sat in VLAN 30 while PC1 on 10.10.10.11 sat in VLAN 10 -- same
|
|
6508
|
+
subnet, different broadcast domains -- and PC2 on 10.10.20.11 sat in VLAN
|
|
6509
|
+
10. Six of twelve hosts were in the wrong VLAN for their address. The lab
|
|
6510
|
+
opened, every static check passed, and PC1 could not reach PC5.
|
|
6511
|
+
|
|
6512
|
+
The same shape as every other defect here: two independent models of one
|
|
6513
|
+
concept that disagree where nothing looks.
|
|
6514
|
+
|
|
6515
|
+
The address wins, because it is what the router's subinterfaces already
|
|
6516
|
+
agree with -- a host moved to another VLAN would need a new address, a new
|
|
6517
|
+
gateway, and a DHCP pool to match.
|
|
6518
|
+
"""
|
|
6519
|
+
by_subnet = {subnet: vlan for vlan, subnet in _vlan_subnets_from_router(root).items()}
|
|
6520
|
+
|
|
6521
|
+
devices = {
|
|
6522
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
6523
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
6524
|
+
}
|
|
6525
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
6526
|
+
|
|
6527
|
+
def host_subnet(device: ET.Element) -> str:
|
|
6528
|
+
for node in device.iter():
|
|
6529
|
+
text = (node.text or "").strip()
|
|
6530
|
+
if node.tag.upper() in {"IP", "IPADDRESS", "ADDRESS"} and re.fullmatch(
|
|
6531
|
+
r"\d+\.\d+\.\d+\.\d+", text
|
|
6532
|
+
):
|
|
6533
|
+
return text.rsplit(".", 1)[0]
|
|
6534
|
+
return ""
|
|
6535
|
+
|
|
6536
|
+
def current_vlan(switch: ET.Element, port: str) -> str:
|
|
6537
|
+
inside = False
|
|
6538
|
+
for node in switch.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
6539
|
+
line = (node.text or "").strip()
|
|
6540
|
+
if line.startswith("interface "):
|
|
6541
|
+
inside = line == f"interface {port}"
|
|
6542
|
+
elif inside and line.startswith("switchport access vlan "):
|
|
6543
|
+
return line.split()[-1]
|
|
6544
|
+
return ""
|
|
6545
|
+
|
|
6546
|
+
attachments: list[tuple[ET.Element, str, ET.Element, str]] = []
|
|
6547
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
6548
|
+
cable = link.find("./CABLE")
|
|
6549
|
+
if cable is None:
|
|
6550
|
+
continue
|
|
6551
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
6552
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
6553
|
+
if len(ports) < 2:
|
|
6554
|
+
continue
|
|
6555
|
+
for near, far in ((0, 1), (1, 0)):
|
|
6556
|
+
host = devices.get(refs[near])
|
|
6557
|
+
switch = devices.get(refs[far])
|
|
6558
|
+
if host is None or switch is None:
|
|
6559
|
+
continue
|
|
6560
|
+
if (switch.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
6561
|
+
continue
|
|
6562
|
+
# Saved files spell it `Pc`; `HOST_DEVICE_KINDS` holds `PC`. Asking
|
|
6563
|
+
# without normalising skipped every PC in the lab and left eight of
|
|
6564
|
+
# fourteen hosts unexamined.
|
|
6565
|
+
if not _is_host_device(
|
|
6566
|
+
{"type": _normalize_device_type(host.findtext("./ENGINE/TYPE") or "")}
|
|
6567
|
+
):
|
|
6568
|
+
continue
|
|
6569
|
+
subnet = host_subnet(host)
|
|
6570
|
+
if subnet:
|
|
6571
|
+
attachments.append((host, subnet, switch, ports[far]))
|
|
6572
|
+
|
|
6573
|
+
# A subnet the router does not map still has to end up in one VLAN, or its
|
|
6574
|
+
# hosts cannot reach each other. The VLAN most of them already sit in is
|
|
6575
|
+
# the one that needs the fewest ports moved.
|
|
6576
|
+
votes: dict[str, Counter[str]] = {}
|
|
6577
|
+
for _host, subnet, switch, port in attachments:
|
|
6578
|
+
vlan = current_vlan(switch, port)
|
|
6579
|
+
if vlan:
|
|
6580
|
+
votes.setdefault(subnet, Counter())[vlan] += 1
|
|
6581
|
+
wanted_for: dict[str, str] = {}
|
|
6582
|
+
for _host, subnet, _switch, _port in attachments:
|
|
6583
|
+
if subnet in wanted_for:
|
|
6584
|
+
continue
|
|
6585
|
+
chosen = by_subnet.get(subnet)
|
|
6586
|
+
if not chosen and subnet in votes:
|
|
6587
|
+
chosen = votes[subnet].most_common(1)[0][0]
|
|
6588
|
+
if chosen:
|
|
6589
|
+
wanted_for[subnet] = chosen
|
|
6590
|
+
|
|
6591
|
+
notes: list[str] = []
|
|
6592
|
+
for host, subnet, switch, port in attachments:
|
|
6593
|
+
wanted = wanted_for.get(subnet)
|
|
6594
|
+
if not wanted or current_vlan(switch, port) == wanted:
|
|
6595
|
+
continue
|
|
6596
|
+
config = switch.find("./ENGINE/RUNNINGCONFIG")
|
|
6597
|
+
if config is None:
|
|
6598
|
+
continue
|
|
6599
|
+
_set_config_block(
|
|
6600
|
+
config,
|
|
6601
|
+
f"interface {port}",
|
|
6602
|
+
[" switchport mode access", f" switchport access vlan {wanted}"],
|
|
6603
|
+
)
|
|
6604
|
+
notes.append(
|
|
6605
|
+
f"{switch.findtext('./ENGINE/NAME') or ''}:{port} -> VLAN {wanted} "
|
|
6606
|
+
f"({host.findtext('./ENGINE/NAME') or ''})"
|
|
6607
|
+
)
|
|
6608
|
+
return notes
|
|
6609
|
+
|
|
6610
|
+
|
|
6611
|
+
def _align_router_gateway(root: ET.Element) -> list[str]:
|
|
6612
|
+
"""Put the hosts' gateway address on the router interface they reach it by.
|
|
6613
|
+
|
|
6614
|
+
Host addressing is decided while planning, from the router interface the
|
|
6615
|
+
plan plugs into the switch. Port reconciliation then moves that cable: the
|
|
6616
|
+
plan had GigabitEthernet0/0/0, carrying 192.168.1.1, and the finished lab
|
|
6617
|
+
ran the cable from GigabitEthernet0/0/2, carrying 192.168.3.1. Hosts were
|
|
6618
|
+
addressed 192.168.1.x with a gateway that existed, on an interface attached
|
|
6619
|
+
to nothing.
|
|
6620
|
+
|
|
6621
|
+
Adjusting the hosts to follow the cable was the first attempt and it did
|
|
6622
|
+
nothing, because at planning time the two still agreed. The cabling is what
|
|
6623
|
+
changes last, so the address has to follow it: whichever interface ends up
|
|
6624
|
+
facing the switch is given the gateway the hosts were told to use.
|
|
6625
|
+
"""
|
|
6626
|
+
devices = {
|
|
6627
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or ""): device
|
|
6628
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
6629
|
+
}
|
|
6630
|
+
kinds = {
|
|
6631
|
+
ref: (device.findtext("./ENGINE/TYPE") or "") for ref, device in devices.items()
|
|
6632
|
+
}
|
|
6633
|
+
|
|
6634
|
+
gateways = [
|
|
6635
|
+
(port.findtext("PORT_GATEWAY") or "").strip()
|
|
6636
|
+
for device in devices.values()
|
|
6637
|
+
if (device.findtext("./ENGINE/TYPE") or "") in {"Pc", "PC"}
|
|
6638
|
+
for port in device.findall("./ENGINE/MODULE/SLOT/MODULE/PORT")
|
|
6639
|
+
]
|
|
6640
|
+
gateway = next((value for value in gateways if value and value != "0.0.0.0"), "")
|
|
6641
|
+
if not gateway:
|
|
6642
|
+
return []
|
|
6643
|
+
|
|
6644
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
6645
|
+
cable = link.find("./CABLE")
|
|
6646
|
+
if cable is None:
|
|
6647
|
+
continue
|
|
6648
|
+
refs = [(cable.findtext("FROM") or "").strip(), (cable.findtext("TO") or "").strip()]
|
|
6649
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
6650
|
+
for router_index, switch_index in ((0, 1), (1, 0)):
|
|
6651
|
+
if kinds.get(refs[router_index]) != "Router":
|
|
6652
|
+
continue
|
|
6653
|
+
if kinds.get(refs[switch_index]) not in {"Switch", "MultiLayerSwitch"}:
|
|
6654
|
+
continue
|
|
6655
|
+
router = devices[refs[router_index]]
|
|
6656
|
+
config = router.find("./ENGINE/RUNNINGCONFIG")
|
|
6657
|
+
if config is None:
|
|
6658
|
+
continue
|
|
6659
|
+
# Another interface may already hold an address in this subnet --
|
|
6660
|
+
# the one the plan originally chose. Two interfaces on one router
|
|
6661
|
+
# cannot share a subnet, so the stale one gives up its address.
|
|
6662
|
+
prefix = gateway.rsplit(".", 1)[0]
|
|
6663
|
+
lines = [(line.text or "") for line in config.findall("LINE")]
|
|
6664
|
+
cleared: list[str] = []
|
|
6665
|
+
for index, line in enumerate(lines):
|
|
6666
|
+
match = re.match(r"interface ((?:Gigabit|Fast)Ethernet\S*)\s*$", line.strip())
|
|
6667
|
+
if not match or match.group(1) == ports[router_index]:
|
|
6668
|
+
continue
|
|
6669
|
+
cursor = index + 1
|
|
6670
|
+
while cursor < len(lines) and lines[cursor].startswith(" "):
|
|
6671
|
+
address = re.match(
|
|
6672
|
+
r"ip address (\d+\.\d+\.\d+\.\d+) ", lines[cursor].strip()
|
|
6673
|
+
)
|
|
6674
|
+
if address and address.group(1).rsplit(".", 1)[0] == prefix:
|
|
6675
|
+
cleared.append(match.group(1))
|
|
6676
|
+
break
|
|
6677
|
+
cursor += 1
|
|
6678
|
+
for interface in cleared:
|
|
6679
|
+
_set_config_block(config, f"interface {interface}", [" no ip address"])
|
|
6680
|
+
|
|
6681
|
+
_set_config_block(
|
|
6682
|
+
config,
|
|
6683
|
+
f"interface {ports[router_index]}",
|
|
6684
|
+
[f" ip address {gateway} 255.255.255.0", " no shutdown"],
|
|
6685
|
+
)
|
|
6686
|
+
name = router.findtext("./ENGINE/NAME") or ""
|
|
6687
|
+
note = f"{name}: {ports[router_index]} set to {gateway} (gateway the hosts use)"
|
|
6688
|
+
if cleared:
|
|
6689
|
+
note += f"; cleared overlapping address on {', '.join(cleared)}"
|
|
6690
|
+
return [note]
|
|
6691
|
+
return []
|
|
6692
|
+
|
|
6693
|
+
|
|
6694
|
+
_SVI_ADDRESS_PATTERN = re.compile(r"^ip address (\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$")
|
|
6695
|
+
|
|
6696
|
+
|
|
6697
|
+
def _address_to_int(address: str) -> int | None:
|
|
6698
|
+
parts = address.split(".")
|
|
6699
|
+
if len(parts) != 4:
|
|
6700
|
+
return None
|
|
6701
|
+
value = 0
|
|
6702
|
+
for part in parts:
|
|
6703
|
+
if not part.isdigit() or not 0 <= int(part) <= 255:
|
|
6704
|
+
return None
|
|
6705
|
+
value = (value << 8) | int(part)
|
|
6706
|
+
return value
|
|
6707
|
+
|
|
6708
|
+
|
|
6709
|
+
def _same_subnet(left: str, right: str, mask: str) -> bool:
|
|
6710
|
+
left_value = _address_to_int(left)
|
|
6711
|
+
right_value = _address_to_int(right)
|
|
6712
|
+
mask_value = _address_to_int(mask)
|
|
6713
|
+
if left_value is None or right_value is None or mask_value is None:
|
|
6714
|
+
return False
|
|
6715
|
+
return left_value & mask_value == right_value & mask_value
|
|
6716
|
+
|
|
6717
|
+
|
|
6718
|
+
def _drop_duplicate_dhcp_pools(root: ET.Element) -> list[str]:
|
|
6719
|
+
"""One pool name, one block.
|
|
6720
|
+
|
|
6721
|
+
IOS merges two `ip dhcp pool VLAN20` blocks and keeps the last, so a
|
|
6722
|
+
duplicate is never harmless: whichever block the reader happens to see is
|
|
6723
|
+
not necessarily the one that runs. They arise because the plan emits a pool
|
|
6724
|
+
from its default addressing and the VLAN pass emits one from the VLAN's own
|
|
6725
|
+
hosts -- two derivations of the same pool, which is this file's usual
|
|
6726
|
+
defect. Retargeting the first onto the right network leaves the names
|
|
6727
|
+
colliding, so the redundant block goes.
|
|
6728
|
+
|
|
6729
|
+
The first block wins; by the time this runs, alignment has already pointed
|
|
6730
|
+
both at a network the router is on.
|
|
6731
|
+
"""
|
|
6732
|
+
dropped: list[str] = []
|
|
6733
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
6734
|
+
config = device.find("./ENGINE/RUNNINGCONFIG")
|
|
6735
|
+
if config is None:
|
|
6736
|
+
continue
|
|
6737
|
+
seen: set[str] = set()
|
|
6738
|
+
doomed: list[ET.Element] = []
|
|
6739
|
+
current = ""
|
|
6740
|
+
for node in config.findall("LINE"):
|
|
6741
|
+
text = (node.text or "").rstrip()
|
|
6742
|
+
stripped = text.strip()
|
|
6743
|
+
if not text.startswith((" ", " ")):
|
|
6744
|
+
if stripped.startswith("ip dhcp pool "):
|
|
6745
|
+
name = stripped[len("ip dhcp pool ") :].strip()
|
|
6746
|
+
current = name if name in seen else ""
|
|
6747
|
+
seen.add(name)
|
|
6748
|
+
if current:
|
|
6749
|
+
doomed.append(node)
|
|
6750
|
+
continue
|
|
6751
|
+
current = ""
|
|
6752
|
+
continue
|
|
6753
|
+
if current:
|
|
6754
|
+
doomed.append(node)
|
|
6755
|
+
for node in doomed:
|
|
6756
|
+
if node in list(config):
|
|
6757
|
+
config.remove(node)
|
|
6758
|
+
if doomed:
|
|
6759
|
+
dropped.append(
|
|
6760
|
+
f"{device.findtext('./ENGINE/NAME') or ''}: removed a repeated pool block"
|
|
6761
|
+
)
|
|
6762
|
+
return dropped
|
|
6763
|
+
|
|
6764
|
+
|
|
6765
|
+
def _add_missing_vlans_to_the_database(root: ET.Element) -> list[str]:
|
|
6766
|
+
"""Create the VLAN in the switch, not only in its configuration text.
|
|
6767
|
+
|
|
6768
|
+
A `vlan 80` line in the running config does not put VLAN 80 in Packet
|
|
6769
|
+
Tracer's VLAN database; the database is its own structure,
|
|
6770
|
+
`ENGINE/VLANS/VLAN`, carrying `name`, `number` and `rspan`. A port assigned
|
|
6771
|
+
to a VLAN the database does not hold forwards nothing, and the lab reads as
|
|
6772
|
+
fully configured throughout.
|
|
6773
|
+
|
|
6774
|
+
Measured on the enterprise lab, and it is the whole difference between the
|
|
6775
|
+
VLANs that worked and the ones that did not. `pt_read_vlans` on SW5 listed
|
|
6776
|
+
1, 10, 20, 30, 40, 50, 60, 70, 99 and the four factory entries -- exactly
|
|
6777
|
+
the donor's own -- while the file's config declared 80, 120, 130, 140, 150,
|
|
6778
|
+
190, 200, 210 as well. Hosts in the donor's VLANs reached their gateway
|
|
6779
|
+
4/4; hosts in the added ones reached nothing, on any switch.
|
|
6780
|
+
|
|
6781
|
+
This is why the earlier VLAN-declaration pass was reverted as useless: it
|
|
6782
|
+
wrote the config line, which was never the thing that was missing.
|
|
6783
|
+
"""
|
|
6784
|
+
reserved = {1002, 1003, 1004, 1005}
|
|
6785
|
+
added: list[str] = []
|
|
6786
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
6787
|
+
if (device.findtext("./ENGINE/TYPE") or "") not in {"Switch", "MultiLayerSwitch"}:
|
|
6788
|
+
continue
|
|
6789
|
+
wanted: dict[int, str] = {}
|
|
6790
|
+
pending = 0
|
|
6791
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
6792
|
+
text = (node.text or "").strip()
|
|
6793
|
+
match = re.match(r"^vlan (\d+)$", text)
|
|
6794
|
+
if match:
|
|
6795
|
+
pending = int(match.group(1))
|
|
6796
|
+
wanted.setdefault(pending, f"VLAN{pending}")
|
|
6797
|
+
continue
|
|
6798
|
+
named = re.match(r"^name (\S+)$", text)
|
|
6799
|
+
if named and pending:
|
|
6800
|
+
wanted[pending] = named.group(1)
|
|
6801
|
+
continue
|
|
6802
|
+
pending = 0
|
|
6803
|
+
for pattern in (
|
|
6804
|
+
r"^switchport access vlan (\d+)$",
|
|
6805
|
+
r"^switchport trunk native vlan (\d+)$",
|
|
6806
|
+
):
|
|
6807
|
+
hit = re.match(pattern, text)
|
|
6808
|
+
if hit:
|
|
6809
|
+
number = int(hit.group(1))
|
|
6810
|
+
wanted.setdefault(number, f"VLAN{number}")
|
|
6811
|
+
|
|
6812
|
+
for container in device.findall(".//VLANS"):
|
|
6813
|
+
present = {
|
|
6814
|
+
int(entry.get("number") or 0)
|
|
6815
|
+
for entry in container.findall("VLAN")
|
|
6816
|
+
if (entry.get("number") or "").isdigit()
|
|
6817
|
+
}
|
|
6818
|
+
missing = sorted(number for number in wanted if number not in present and number not in reserved)
|
|
6819
|
+
if not missing:
|
|
6820
|
+
continue
|
|
6821
|
+
for number in missing:
|
|
6822
|
+
entry = ET.SubElement(container, "VLAN")
|
|
6823
|
+
entry.set("name", wanted[number])
|
|
6824
|
+
entry.set("number", str(number))
|
|
6825
|
+
entry.set("rspan", "0")
|
|
6826
|
+
parent = next(
|
|
6827
|
+
(node for node in device.iter() if container in list(node)),
|
|
6828
|
+
None,
|
|
6829
|
+
)
|
|
6830
|
+
if parent is not None and parent.find("VLAN_COUNT") is not None:
|
|
6831
|
+
parent.find("VLAN_COUNT").text = str(len(container.findall("VLAN")))
|
|
6832
|
+
added.append(
|
|
6833
|
+
f"{device.findtext('./ENGINE/NAME') or ''}: VLAN {', '.join(str(n) for n in missing)} "
|
|
6834
|
+
f"created in the database"
|
|
6835
|
+
)
|
|
6836
|
+
return added
|
|
6837
|
+
|
|
6838
|
+
|
|
6839
|
+
def _separate_config_blocks(root: ET.Element) -> list[str]:
|
|
6840
|
+
"""Put the `!` back between a sub-block and the command that follows it.
|
|
6841
|
+
|
|
6842
|
+
A pass that appends an `interface` block after an `ip dhcp pool` writes the
|
|
6843
|
+
header straight after the pool's own indented lines. IOS reads a top-level
|
|
6844
|
+
command there as leaving the sub-mode; Packet Tracer does not always, and
|
|
6845
|
+
the block is swallowed. Measured on the enterprise lab: the file carried
|
|
6846
|
+
`interface GigabitEthernet0/1.80` with an address and HSRP, and Packet
|
|
6847
|
+
Tracer's own port list for R1 -- nineteen interfaces -- did not contain it.
|
|
6848
|
+
VLAN 80 had a gateway on paper and nothing answered on it.
|
|
6849
|
+
|
|
6850
|
+
Every subinterface the donor wrote is preceded by `!`; every one this
|
|
6851
|
+
project appended is preceded by ` default-router ...`. That is the whole
|
|
6852
|
+
difference between the ones that exist and the one that did not.
|
|
6853
|
+
"""
|
|
6854
|
+
from pkt_editor import _replace_lines
|
|
6855
|
+
|
|
6856
|
+
separated: list[str] = []
|
|
6857
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
6858
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
6859
|
+
config = device.find(f"./ENGINE/{section}")
|
|
6860
|
+
if config is None:
|
|
6861
|
+
continue
|
|
6862
|
+
lines = [(node.text or "") for node in config.findall("LINE")]
|
|
6863
|
+
rebuilt: list[str] = []
|
|
6864
|
+
added = 0
|
|
6865
|
+
for line in lines:
|
|
6866
|
+
indented = line.startswith((" ", " "))
|
|
6867
|
+
previous = rebuilt[-1] if rebuilt else ""
|
|
6868
|
+
if (
|
|
6869
|
+
not indented
|
|
6870
|
+
and line.strip()
|
|
6871
|
+
and line.strip() != "!"
|
|
6872
|
+
and previous.startswith((" ", " "))
|
|
6873
|
+
):
|
|
6874
|
+
rebuilt.append("!")
|
|
6875
|
+
added += 1
|
|
6876
|
+
rebuilt.append(line)
|
|
6877
|
+
if not added:
|
|
6878
|
+
continue
|
|
6879
|
+
_replace_lines(config, rebuilt)
|
|
6880
|
+
if section == "RUNNINGCONFIG":
|
|
6881
|
+
separated.append(
|
|
6882
|
+
f"{device.findtext('./ENGINE/NAME') or ''}: {added} block separator(s) restored"
|
|
6883
|
+
)
|
|
6884
|
+
return separated
|
|
6885
|
+
|
|
6886
|
+
|
|
6887
|
+
def _merge_repeated_interface_blocks(root: ET.Element) -> list[str]:
|
|
6888
|
+
"""Make the file say what the device would actually do.
|
|
6889
|
+
|
|
6890
|
+
IOS applies repeated `interface` blocks in order and keeps the last value
|
|
6891
|
+
for each setting; every reader here scans for the first. A file carrying
|
|
6892
|
+
both is correct and unreadable at once -- measured on a lab built five
|
|
6893
|
+
times over, where `GigabitEthernet0/1.40` appeared five times holding
|
|
6894
|
+
`10.10.40.1`, then `10.10.40.3`, then `10.10.40.1` again.
|
|
6895
|
+
|
|
6896
|
+
The passes that caused it are fixed, but a lab generated before that, or a
|
|
6897
|
+
donor from anywhere, still carries the damage -- and a generated lab is a
|
|
6898
|
+
candidate donor for the next build, so it propagates. Merging applies the
|
|
6899
|
+
device's own rule: one block, settings in first-appearance order, each
|
|
6900
|
+
holding the value the last block gave it.
|
|
6901
|
+
"""
|
|
6902
|
+
from pkt_editor import _replace_lines, _setting_name
|
|
6903
|
+
|
|
6904
|
+
merged: list[str] = []
|
|
6905
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
6906
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
6907
|
+
config = device.find(f"./ENGINE/{section}")
|
|
6908
|
+
if config is None:
|
|
6909
|
+
continue
|
|
6910
|
+
lines = [(node.text or "") for node in config.findall("LINE")]
|
|
6911
|
+
order: list[str] = []
|
|
6912
|
+
bodies: dict[str, list[str]] = {}
|
|
6913
|
+
other: list[tuple[int, str]] = []
|
|
6914
|
+
current = ""
|
|
6915
|
+
repeated = 0
|
|
6916
|
+
for line in lines:
|
|
6917
|
+
stripped = line.strip()
|
|
6918
|
+
if stripped.startswith("interface "):
|
|
6919
|
+
current = stripped
|
|
6920
|
+
if current in bodies:
|
|
6921
|
+
repeated += 1
|
|
6922
|
+
else:
|
|
6923
|
+
order.append(current)
|
|
6924
|
+
bodies[current] = []
|
|
6925
|
+
continue
|
|
6926
|
+
if current and line.startswith((" ", " ")):
|
|
6927
|
+
# Same rule `_set_config_block` writes by, so a merged
|
|
6928
|
+
# block and a written one agree on what overwrites what.
|
|
6929
|
+
# Keying on the first two words instead kept both
|
|
6930
|
+
# `description Satis` and `description VLAN40 standby`,
|
|
6931
|
+
# where IOS keeps only the last.
|
|
6932
|
+
body = bodies[current]
|
|
6933
|
+
name = _setting_name(line)
|
|
6934
|
+
for index, existing in enumerate(body):
|
|
6935
|
+
if _setting_name(existing) == name:
|
|
6936
|
+
body[index] = line
|
|
6937
|
+
break
|
|
6938
|
+
else:
|
|
6939
|
+
body.append(line)
|
|
6940
|
+
continue
|
|
6941
|
+
current = ""
|
|
6942
|
+
if stripped != "!":
|
|
6943
|
+
# The rebuild puts a separator after every block; keeping
|
|
6944
|
+
# the originals as well doubles them.
|
|
6945
|
+
other.append((len(order), line))
|
|
6946
|
+
if not repeated:
|
|
6947
|
+
continue
|
|
6948
|
+
rebuilt: list[str] = []
|
|
6949
|
+
for index, header in enumerate(order):
|
|
6950
|
+
rebuilt.extend(text for position, text in other if position == index)
|
|
6951
|
+
rebuilt.append(header)
|
|
6952
|
+
rebuilt.extend(bodies[header])
|
|
6953
|
+
rebuilt.append("!")
|
|
6954
|
+
rebuilt.extend(text for position, text in other if position >= len(order))
|
|
6955
|
+
_replace_lines(config, rebuilt)
|
|
6956
|
+
if section == "RUNNINGCONFIG":
|
|
6957
|
+
merged.append(
|
|
6958
|
+
f"{device.findtext('./ENGINE/NAME') or ''}: merged {repeated} repeated interface block(s)"
|
|
6959
|
+
)
|
|
6960
|
+
return merged
|
|
6961
|
+
|
|
6962
|
+
|
|
6963
|
+
def _drop_vlan_subinterfaces_off_router_links(root: ET.Element) -> list[str]:
|
|
6964
|
+
"""A dot1Q subinterface needs a switch on the other end of its parent port.
|
|
6965
|
+
|
|
6966
|
+
Measured on the enterprise lab: R5 through R8 each carried eight VLAN
|
|
6967
|
+
subinterfaces holding `10.10.10.1`, `10.10.20.1` and so on -- the very
|
|
6968
|
+
addresses R1 offers as its HSRP virtual gateways -- on a `GigabitEthernet0/1`
|
|
6969
|
+
cabled to another router. A VLAN cannot cross a routed link, so those
|
|
6970
|
+
subinterfaces could never carry traffic; all they did was claim the gateway
|
|
6971
|
+
address four more times each.
|
|
6972
|
+
|
|
6973
|
+
The parent port is worse off still: the point-to-point pass had given it a
|
|
6974
|
+
/30 address, so one port was a routed link and a trunk at once.
|
|
6975
|
+
|
|
6976
|
+
Only subinterfaces whose parent faces something other than a switch are
|
|
6977
|
+
dropped, so the routers that really are trunked keep everything.
|
|
6978
|
+
"""
|
|
6979
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
6980
|
+
devices_by_ref = {
|
|
6981
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
6982
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
6983
|
+
}
|
|
6984
|
+
faces_switch: set[tuple[str, str]] = set()
|
|
6985
|
+
cabled: set[tuple[str, str]] = set()
|
|
6986
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
6987
|
+
cable = link.find("./CABLE")
|
|
6988
|
+
if cable is None:
|
|
6989
|
+
continue
|
|
6990
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
6991
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
6992
|
+
if len(ports) < 2:
|
|
6993
|
+
continue
|
|
6994
|
+
left, right = devices_by_ref.get(refs[0]), devices_by_ref.get(refs[1])
|
|
6995
|
+
if left is None or right is None:
|
|
6996
|
+
continue
|
|
6997
|
+
for near, far, port in ((left, right, ports[0]), (right, left, ports[1])):
|
|
6998
|
+
name = (near.findtext("./ENGINE/NAME") or "").strip()
|
|
6999
|
+
cabled.add((name, port))
|
|
7000
|
+
if (far.findtext("./ENGINE/TYPE") or "") in switch_types:
|
|
7001
|
+
faces_switch.add((name, port))
|
|
7002
|
+
|
|
7003
|
+
dropped: list[str] = []
|
|
7004
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7005
|
+
if (device.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
7006
|
+
continue
|
|
7007
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
7008
|
+
for section in ("RUNNINGCONFIG", "STARTUPCONFIG"):
|
|
7009
|
+
config = device.find(f"./ENGINE/{section}")
|
|
7010
|
+
if config is None:
|
|
7011
|
+
continue
|
|
7012
|
+
doomed: list[ET.Element] = []
|
|
7013
|
+
removed: set[str] = set()
|
|
7014
|
+
keep = False
|
|
7015
|
+
for node in config.findall("LINE"):
|
|
7016
|
+
text = (node.text or "")
|
|
7017
|
+
stripped = text.strip()
|
|
7018
|
+
if stripped.startswith("interface "):
|
|
7019
|
+
port = stripped.split(None, 1)[1]
|
|
7020
|
+
parent = port.split(".")[0]
|
|
7021
|
+
# An uncabled parent is left alone: the lab may still be
|
|
7022
|
+
# waiting for its trunk, and nothing conflicts until it is
|
|
7023
|
+
# cabled to something that is not a switch.
|
|
7024
|
+
keep = (
|
|
7025
|
+
"." in port
|
|
7026
|
+
and (name, parent) in cabled
|
|
7027
|
+
and (name, parent) not in faces_switch
|
|
7028
|
+
)
|
|
7029
|
+
if keep:
|
|
7030
|
+
doomed.append(node)
|
|
7031
|
+
removed.add(port)
|
|
7032
|
+
continue
|
|
7033
|
+
if keep and (text.startswith((" ", " ")) or stripped == "!"):
|
|
7034
|
+
doomed.append(node)
|
|
7035
|
+
continue
|
|
7036
|
+
keep = False
|
|
7037
|
+
for node in doomed:
|
|
7038
|
+
if node in list(config):
|
|
7039
|
+
config.remove(node)
|
|
7040
|
+
if removed and section == "RUNNINGCONFIG":
|
|
7041
|
+
dropped.append(
|
|
7042
|
+
f"{name}: dropped {len(removed)} VLAN subinterface(s) on a port facing a router"
|
|
7043
|
+
)
|
|
7044
|
+
return dropped
|
|
7045
|
+
|
|
7046
|
+
|
|
7047
|
+
HOST_DEVICE_TYPES = {
|
|
7048
|
+
"Pc",
|
|
7049
|
+
"Laptop",
|
|
7050
|
+
"Server",
|
|
7051
|
+
"Printer",
|
|
7052
|
+
"Tablet",
|
|
7053
|
+
"IpPhone",
|
|
7054
|
+
"HomeVoip",
|
|
7055
|
+
"AnalogPhone",
|
|
7056
|
+
"TV",
|
|
7057
|
+
"IoT",
|
|
7058
|
+
"SBC",
|
|
7059
|
+
"MCU",
|
|
7060
|
+
"WiredEndDevice",
|
|
7061
|
+
"WirelessEndDevice",
|
|
7062
|
+
}
|
|
7063
|
+
|
|
7064
|
+
|
|
7065
|
+
def _network_from(address: str, mask: str) -> tuple[int, int] | None:
|
|
7066
|
+
left, right = _address_to_int(address), _address_to_int(mask)
|
|
7067
|
+
if left is None or right is None:
|
|
7068
|
+
return None
|
|
7069
|
+
return left & right, right
|
|
7070
|
+
|
|
7071
|
+
|
|
7072
|
+
def _octets(value: int) -> str:
|
|
7073
|
+
return ".".join(str((value >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
7074
|
+
|
|
7075
|
+
|
|
7076
|
+
def _join_host_part(address: str, gateway: str, mask: str) -> str:
|
|
7077
|
+
"""Keep the host's own number, move it onto the gateway's network.
|
|
7078
|
+
|
|
7079
|
+
`192.168.80.10` under a gateway of `10.10.80.1/24` becomes `10.10.80.10`,
|
|
7080
|
+
so a renumbered lab still reads the way whoever asked for it expects.
|
|
7081
|
+
"""
|
|
7082
|
+
host, network = _address_to_int(address), _network_from(gateway, mask)
|
|
7083
|
+
if host is None or network is None:
|
|
7084
|
+
return gateway
|
|
7085
|
+
return _octets(network[0] | (host & ~network[1] & 0xFFFFFFFF))
|
|
7086
|
+
|
|
7087
|
+
|
|
7088
|
+
def _next_address(address: str) -> str:
|
|
7089
|
+
value = _address_to_int(address)
|
|
7090
|
+
return address if value is None else _octets(value + 1)
|
|
7091
|
+
|
|
7092
|
+
|
|
7093
|
+
def _set_text(parent: ET.Element, tag: str, value: str) -> None:
|
|
7094
|
+
node = parent.find(tag)
|
|
7095
|
+
if node is None:
|
|
7096
|
+
node = ET.SubElement(parent, tag)
|
|
7097
|
+
node.text = value
|
|
7098
|
+
|
|
7099
|
+
|
|
7100
|
+
def _live_profile_nodes(engine: ET.Element) -> list[ET.Element]:
|
|
7101
|
+
"""The profile a client is actually using, not its saved list.
|
|
7102
|
+
|
|
7103
|
+
`pkt_editor._profile_nodes` returns both, and writing the live network into
|
|
7104
|
+
both is what every working lab does not do. Measured across the ones that
|
|
7105
|
+
ping: `hr-guest` has its client on `home`/WPA2 in `CURRENT_PROFILE` while
|
|
7106
|
+
`PROFILES` still holds the untouched `Default`/open boilerplate, and the
|
|
7107
|
+
donor's own access-point clients are on `TestNetwork`/WEP with `PROFILES`
|
|
7108
|
+
left exactly the same way. Not one working client has its saved list
|
|
7109
|
+
rewritten.
|
|
7110
|
+
"""
|
|
7111
|
+
return engine.findall("./WIRELESS_CLIENT/CURRENT_PROFILE/WIRELESS_PROFILE")
|
|
7112
|
+
|
|
7113
|
+
|
|
7114
|
+
def _keep_wireless_clients_within_reach_of_their_access_point(root: ET.Element) -> list[str]:
|
|
7115
|
+
"""A radio link is made by distance, and the layout was deciding distance blind.
|
|
7116
|
+
|
|
7117
|
+
The layout places hosts in rows under the switch they hang off. A wireless
|
|
7118
|
+
client hangs off nothing, so it landed wherever the row logic left it --
|
|
7119
|
+
measured on the generated lab, 420 units below a router whose radio reaches
|
|
7120
|
+
250. Everything else about the lab was by then correct: same SSID, same
|
|
7121
|
+
security, same key, the client a DHCP client and the router serving DHCP.
|
|
7122
|
+
Packet Tracer still reported `ip 0.0.0.0`, no lease, 0/4, and the port as
|
|
7123
|
+
`up` and `linked`, which is what made it look fine.
|
|
7124
|
+
|
|
7125
|
+
Moving that one laptop to 128 units away changed the reading immediately:
|
|
7126
|
+
leased `192.168.10.101` out of the router's pool, radio negotiated up from
|
|
7127
|
+
24 Mbps to 300, and 4/4 to the gateway. The second laptop the same, and
|
|
7128
|
+
then 4/4 between them.
|
|
7129
|
+
|
|
7130
|
+
So this is the last of the pair: the wired topology decides the rows, the
|
|
7131
|
+
radio's coverage decides whether the link exists, and nothing compared
|
|
7132
|
+
them. Clients already in range are left where they are -- the pass has
|
|
7133
|
+
nothing to add there, and moving them would make it depend on its own
|
|
7134
|
+
output.
|
|
7135
|
+
"""
|
|
7136
|
+
positions: dict[int, tuple[float, float]] = {}
|
|
7137
|
+
|
|
7138
|
+
def _place(device: ET.Element, x: float, y: float) -> None:
|
|
7139
|
+
logical = device.find("./WORKSPACE/LOGICAL")
|
|
7140
|
+
if logical is None:
|
|
7141
|
+
workspace = device.find("./WORKSPACE")
|
|
7142
|
+
if workspace is None:
|
|
7143
|
+
workspace = ET.SubElement(device, "WORKSPACE")
|
|
7144
|
+
logical = ET.SubElement(workspace, "LOGICAL")
|
|
7145
|
+
for tag, value in (("X", x), ("Y", y)):
|
|
7146
|
+
node = logical.find(tag)
|
|
7147
|
+
if node is None:
|
|
7148
|
+
node = ET.SubElement(logical, tag)
|
|
7149
|
+
node.text = f"{value:.1f}"
|
|
7150
|
+
|
|
7151
|
+
def _at(device: ET.Element) -> tuple[float, float] | None:
|
|
7152
|
+
logical = device.find("./WORKSPACE/LOGICAL")
|
|
7153
|
+
if logical is None:
|
|
7154
|
+
return None
|
|
7155
|
+
try:
|
|
7156
|
+
return float(logical.findtext("X") or ""), float(logical.findtext("Y") or "")
|
|
7157
|
+
except ValueError:
|
|
7158
|
+
return None
|
|
7159
|
+
|
|
7160
|
+
cabled: set[int] = set()
|
|
7161
|
+
for cable in root.findall(".//LINKS/LINK/CABLE"):
|
|
7162
|
+
for tag in ("FROM", "TO"):
|
|
7163
|
+
reference = (cable.findtext(tag) or "").strip()
|
|
7164
|
+
if reference:
|
|
7165
|
+
cabled.add(hash(reference))
|
|
7166
|
+
|
|
7167
|
+
access_points: list[tuple[ET.Element, str, float]] = []
|
|
7168
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7169
|
+
common = device.find("./ENGINE/WIRELESS_SERVER/WIRELESS_COMMON")
|
|
7170
|
+
if common is None:
|
|
7171
|
+
continue
|
|
7172
|
+
ssid = (common.findtext("SSID") or "").strip()
|
|
7173
|
+
reach = 0.0
|
|
7174
|
+
for port in device.findall(".//PORT"):
|
|
7175
|
+
if not (port.findtext("TYPE") or "").startswith("eAccessPointWireless"):
|
|
7176
|
+
continue
|
|
7177
|
+
try:
|
|
7178
|
+
reach = max(reach, float(port.findtext("COVERAGERANGE") or 0))
|
|
7179
|
+
except ValueError:
|
|
7180
|
+
continue
|
|
7181
|
+
if ssid and reach > 0:
|
|
7182
|
+
access_points.append((device, ssid, reach))
|
|
7183
|
+
if not access_points:
|
|
7184
|
+
return []
|
|
7185
|
+
|
|
7186
|
+
moved: list[str] = []
|
|
7187
|
+
for access_point, ssid, reach in access_points:
|
|
7188
|
+
here = _at(access_point)
|
|
7189
|
+
if here is None:
|
|
7190
|
+
continue
|
|
7191
|
+
clients = []
|
|
7192
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7193
|
+
if device is access_point:
|
|
7194
|
+
continue
|
|
7195
|
+
common = device.find("./ENGINE/WIRELESS_CLIENT/WIRELESS_COMMON")
|
|
7196
|
+
if common is None or (common.findtext("SSID") or "").strip() != ssid:
|
|
7197
|
+
continue
|
|
7198
|
+
reference = (device.findtext("./ENGINE/SAVE_REF_ID") or "").strip()
|
|
7199
|
+
if reference and hash(reference) in cabled:
|
|
7200
|
+
# A cabled client is on the wire, and the row it sits in is
|
|
7201
|
+
# about that cable, not about the radio.
|
|
7202
|
+
continue
|
|
7203
|
+
clients.append(device)
|
|
7204
|
+
if not clients:
|
|
7205
|
+
continue
|
|
7206
|
+
|
|
7207
|
+
# Half the reach is comfortably inside it and leaves the icons apart.
|
|
7208
|
+
radius = reach / 2
|
|
7209
|
+
for index, device in enumerate(clients):
|
|
7210
|
+
there = _at(device)
|
|
7211
|
+
if there is not None:
|
|
7212
|
+
span = ((there[0] - here[0]) ** 2 + (there[1] - here[1]) ** 2) ** 0.5
|
|
7213
|
+
if span <= radius:
|
|
7214
|
+
continue
|
|
7215
|
+
angle = 2 * math.pi * index / max(len(clients), 1)
|
|
7216
|
+
x = here[0] + radius * math.cos(angle)
|
|
7217
|
+
y = here[1] + radius * math.sin(angle)
|
|
7218
|
+
_place(device, max(x, float(LOGICAL_ICON_SPACING)), max(y, float(LOGICAL_ICON_SPACING)))
|
|
7219
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
7220
|
+
moved.append(f"{name} moved inside the {int(reach)}-unit reach of {(access_point.findtext('./ENGINE/NAME') or '').strip()}")
|
|
7221
|
+
return moved
|
|
7222
|
+
|
|
7223
|
+
|
|
7224
|
+
def _the_only_access_point(root: ET.Element) -> tuple[ET.Element, ET.Element] | None:
|
|
7225
|
+
"""The one device broadcasting, and its wireless settings.
|
|
7226
|
+
|
|
7227
|
+
Two networks on the air is more than these passes can attribute, and
|
|
7228
|
+
guessing which client belongs to which would be the same mistake they
|
|
7229
|
+
exist to fix.
|
|
7230
|
+
"""
|
|
7231
|
+
found: list[tuple[ET.Element, ET.Element]] = []
|
|
7232
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7233
|
+
common = device.find("./ENGINE/WIRELESS_SERVER/WIRELESS_COMMON")
|
|
7234
|
+
if common is not None and (common.findtext("SSID") or "").strip():
|
|
7235
|
+
found.append((device, common))
|
|
7236
|
+
return found[0] if len(found) == 1 else None
|
|
7237
|
+
|
|
7238
|
+
|
|
7239
|
+
def _let_the_home_router_address_its_own_clients(root: ET.Element) -> list[str]:
|
|
7240
|
+
"""A wireless client of a home router takes a lease; it does not hold a static address.
|
|
7241
|
+
|
|
7242
|
+
Measured on the donor this lab is pruned from, which works: `Laptop3` sits
|
|
7243
|
+
on the Linksys's network as a DHCP client, is leased `192.168.0.100` out of
|
|
7244
|
+
the router's own pool, and pings the router 4/4. Meanwhile the generated
|
|
7245
|
+
lab wrote a static `192.168.10.20` onto its laptop's port and profile, and
|
|
7246
|
+
Packet Tracer reported the radio associated, `dhcp_client: true`, `ip
|
|
7247
|
+
0.0.0.0`, 0/4 -- it re-asserts DHCP on this kind of client whatever the
|
|
7248
|
+
file says, which is why forcing the static address never took.
|
|
7249
|
+
|
|
7250
|
+
So the router's pool is the authority for its own clients, and this runs
|
|
7251
|
+
after `_align_home_router_lan_with_its_clients` has moved that pool onto
|
|
7252
|
+
the network the plan chose. The planned addresses are what tell it where to
|
|
7253
|
+
move; once it has, they have done their job.
|
|
7254
|
+
|
|
7255
|
+
Only a router actually serving DHCP counts. One with its server off has
|
|
7256
|
+
nothing to lease, and a client switched to DHCP there would end up with no
|
|
7257
|
+
address at all.
|
|
7258
|
+
"""
|
|
7259
|
+
access_point = _the_only_access_point(root)
|
|
7260
|
+
if access_point is None:
|
|
7261
|
+
return []
|
|
7262
|
+
broadcaster, common = access_point
|
|
7263
|
+
if (broadcaster.findtext("./ENGINE/DHCP_SERVER/ENABLED") or "").strip() != "1":
|
|
7264
|
+
return []
|
|
7265
|
+
if _normalize_device_type(broadcaster.findtext("./ENGINE/TYPE") or "") not in WIRELESS_ROUTER_KINDS:
|
|
7266
|
+
return []
|
|
7267
|
+
ssid = (common.findtext("SSID") or "").strip()
|
|
7268
|
+
|
|
7269
|
+
changed: list[str] = []
|
|
7270
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7271
|
+
if device is broadcaster:
|
|
7272
|
+
continue
|
|
7273
|
+
client = device.find("./ENGINE/WIRELESS_CLIENT/WIRELESS_COMMON")
|
|
7274
|
+
if client is None or (client.findtext("SSID") or "").strip() != ssid:
|
|
7275
|
+
continue
|
|
7276
|
+
moved = False
|
|
7277
|
+
for port in device.findall(".//PORT"):
|
|
7278
|
+
if not (port.findtext("TYPE") or "").startswith("eHostWireless"):
|
|
7279
|
+
continue
|
|
7280
|
+
if (port.findtext("PORT_DHCP_ENABLE") or "").strip().lower() == "true":
|
|
7281
|
+
continue
|
|
7282
|
+
_ensure_text(port, "PORT_DHCP_ENABLE", "true")
|
|
7283
|
+
_ensure_text(port, "IP", "")
|
|
7284
|
+
_ensure_text(port, "SUBNET", "")
|
|
7285
|
+
_ensure_text(port, "PORT_GATEWAY", "")
|
|
7286
|
+
moved = True
|
|
7287
|
+
if moved:
|
|
7288
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
7289
|
+
changed.append(f"{name} now takes its address from {(broadcaster.findtext('./ENGINE/NAME') or '').strip()}, which is what serves {ssid}")
|
|
7290
|
+
return changed
|
|
7291
|
+
|
|
7292
|
+
|
|
7293
|
+
def _match_wireless_security_to_the_access_point(root: ET.Element) -> list[str]:
|
|
7294
|
+
"""A client set to WEP cannot join a WPA2 network, whatever its SSID says.
|
|
7295
|
+
|
|
7296
|
+
`set_wireless_ssid` writes the access point's authentication, encryption
|
|
7297
|
+
and key. `associate_wireless_client` wrote the client's SSID and nothing
|
|
7298
|
+
else -- so the security was decided once, on the access point, and the
|
|
7299
|
+
client kept whatever the donor left it with. Measured on the lab built from
|
|
7300
|
+
"ssid EvSebeke wpa2 sifre Gizli123":
|
|
7301
|
+
|
|
7302
|
+
WRT1 AUTHEN_TYPE 4 ENCRYPT_TYPE 4 WPA_PASSPHRASE Gizli123
|
|
7303
|
+
Laptop1 AUTHEN_TYPE 1 ENCRYPT_TYPE 1 key 1234567890 (WEP)
|
|
7304
|
+
second profile AUTHEN_TYPE 0 (open)
|
|
7305
|
+
|
|
7306
|
+
Three answers on one client, none of them the network's. The radio
|
|
7307
|
+
associated -- Packet Tracer reports the port up and linked -- and not one
|
|
7308
|
+
packet crossed it.
|
|
7309
|
+
|
|
7310
|
+
The client shape comes from three labs that work, `hr-guest`,
|
|
7311
|
+
`meraki_SA_wireless_wpa2_psk` and a saved coursework lab: the key goes in
|
|
7312
|
+
`WEP_PROCESS/KEY` and in each profile's `WEP_KEY` whatever the
|
|
7313
|
+
authentication is. Those field names are legacy; WPA2 uses them too.
|
|
7314
|
+
"""
|
|
7315
|
+
from pkt_editor import _profile_nodes
|
|
7316
|
+
|
|
7317
|
+
access_point = _the_only_access_point(root)
|
|
7318
|
+
if access_point is None:
|
|
7319
|
+
return []
|
|
7320
|
+
broadcaster, common = access_point
|
|
7321
|
+
ssid = (common.findtext("SSID") or "").strip()
|
|
7322
|
+
authen = (common.findtext("AUTHEN_TYPE") or "").strip()
|
|
7323
|
+
encrypt = (common.findtext("ENCRYPT_TYPE") or "").strip()
|
|
7324
|
+
if not authen:
|
|
7325
|
+
return []
|
|
7326
|
+
# `WEP_PROCESS/KEY` first: that is where a working access point keeps its
|
|
7327
|
+
# key whatever the security is, and the other two are older spellings that
|
|
7328
|
+
# some donors still carry.
|
|
7329
|
+
key = ""
|
|
7330
|
+
for path in ("WEP_PROCESS/KEY", "WPA_PASSPHRASE", "WEP_KEY"):
|
|
7331
|
+
value = (common.findtext(path) or "").strip()
|
|
7332
|
+
if value:
|
|
7333
|
+
key = value
|
|
7334
|
+
break
|
|
7335
|
+
|
|
7336
|
+
# A profile also records the kind of network it is joining, and a home
|
|
7337
|
+
# router will not take a client set to the narrower one. Measured on the
|
|
7338
|
+
# donor: its two clients on the Linksys, which ping it 4/4, carry
|
|
7339
|
+
# `NETWORK_TYPE` 7; its four clients on the access point carry 3. Ours
|
|
7340
|
+
# were pruned from the access-point side and kept the 3, so they never
|
|
7341
|
+
# associated to the router they were given -- Packet Tracer reported the
|
|
7342
|
+
# radio linked at 24 Mbps, no lease, 0/4. Across 152 wireless labs, 1422
|
|
7343
|
+
# of 1440 client profiles are on 7 whatever their authentication.
|
|
7344
|
+
#
|
|
7345
|
+
# Only for a home router: the access point's own clients work on 3, and
|
|
7346
|
+
# rewriting those would be changing something measured to be fine.
|
|
7347
|
+
network_type = (
|
|
7348
|
+
"7"
|
|
7349
|
+
if _normalize_device_type(broadcaster.findtext("./ENGINE/TYPE") or "") in WIRELESS_ROUTER_KINDS
|
|
7350
|
+
else ""
|
|
7351
|
+
)
|
|
7352
|
+
|
|
7353
|
+
changed: list[str] = []
|
|
7354
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7355
|
+
if device is broadcaster:
|
|
7356
|
+
continue
|
|
7357
|
+
engine = device.find("./ENGINE")
|
|
7358
|
+
if engine is None:
|
|
7359
|
+
continue
|
|
7360
|
+
client = engine.find("./WIRELESS_CLIENT/WIRELESS_COMMON")
|
|
7361
|
+
if client is None or (client.findtext("SSID") or "").strip() != ssid:
|
|
7362
|
+
continue
|
|
7363
|
+
before = {
|
|
7364
|
+
(client.findtext("AUTHEN_TYPE") or "").strip(),
|
|
7365
|
+
*[(profile.findtext("AUTHEN_TYPE") or "").strip() for profile in _live_profile_nodes(engine)],
|
|
7366
|
+
}
|
|
7367
|
+
_ensure_text(client, "AUTHEN_TYPE", authen)
|
|
7368
|
+
_ensure_text(client, "ENCRYPT_TYPE", encrypt)
|
|
7369
|
+
process = client.find("WEP_PROCESS")
|
|
7370
|
+
if process is None:
|
|
7371
|
+
process = ET.SubElement(client, "WEP_PROCESS")
|
|
7372
|
+
_ensure_text(process, "KEY", key)
|
|
7373
|
+
_ensure_text(process, "ENCRYPTION", encrypt)
|
|
7374
|
+
for profile in _live_profile_nodes(engine):
|
|
7375
|
+
_ensure_text(profile, "AUTHEN_TYPE", authen)
|
|
7376
|
+
_ensure_text(profile, "ENCRYPT_TYPE", encrypt)
|
|
7377
|
+
_ensure_text(profile, "WEP_KEY", key)
|
|
7378
|
+
if network_type and (profile.findtext("NETWORK_TYPE") or "").strip() != network_type:
|
|
7379
|
+
_ensure_text(profile, "NETWORK_TYPE", network_type)
|
|
7380
|
+
before = before | {"a network type the router does not take"}
|
|
7381
|
+
if before != {authen}:
|
|
7382
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
7383
|
+
changed.append(f"{name} now uses the security {ssid} is on, not {'/'.join(sorted(before - {authen}))}")
|
|
7384
|
+
return changed
|
|
7385
|
+
|
|
7386
|
+
|
|
7387
|
+
def _make_the_wireless_profile_agree_with_the_port(root: ET.Element) -> list[str]:
|
|
7388
|
+
"""A wireless host takes its addressing from its profile, not from its port.
|
|
7389
|
+
|
|
7390
|
+
Both record it. The port carried `192.168.10.20` with `PORT_DHCP_ENABLE`
|
|
7391
|
+
false; the profile carried `DHCP_ENABLED` 1 and no address at all. Packet
|
|
7392
|
+
Tracer obeys the profile, so the laptop associated, asked for a lease, and
|
|
7393
|
+
sat on `0.0.0.0` while the file said it was statically addressed -- and
|
|
7394
|
+
every check that read the port agreed the lab was fine.
|
|
7395
|
+
|
|
7396
|
+
Measured live: `Wireless0` up, linked, `dhcp_client: true`, `ip 0.0.0.0`,
|
|
7397
|
+
0/4 to a gateway that by then existed.
|
|
7398
|
+
|
|
7399
|
+
The port is the one the rest of the generator writes and reads, so the
|
|
7400
|
+
profile follows it.
|
|
7401
|
+
"""
|
|
7402
|
+
changed: list[str] = []
|
|
7403
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7404
|
+
engine = device.find("./ENGINE")
|
|
7405
|
+
if engine is None:
|
|
7406
|
+
continue
|
|
7407
|
+
profiles = _live_profile_nodes(engine)
|
|
7408
|
+
if not profiles:
|
|
7409
|
+
continue
|
|
7410
|
+
radio = next(
|
|
7411
|
+
(
|
|
7412
|
+
port
|
|
7413
|
+
for port in device.findall(".//PORT")
|
|
7414
|
+
if (port.findtext("TYPE") or "").startswith("eHostWireless")
|
|
7415
|
+
),
|
|
7416
|
+
None,
|
|
7417
|
+
)
|
|
7418
|
+
if radio is None:
|
|
7419
|
+
continue
|
|
7420
|
+
by_dhcp = (radio.findtext("PORT_DHCP_ENABLE") or "").strip().lower() == "true"
|
|
7421
|
+
address = (radio.findtext("IP") or "").strip()
|
|
7422
|
+
mask = (radio.findtext("SUBNET") or "").strip()
|
|
7423
|
+
gateway = (radio.findtext("PORT_GATEWAY") or "").strip()
|
|
7424
|
+
if not by_dhcp and not address:
|
|
7425
|
+
# Neither source says anything; inventing an answer here would be
|
|
7426
|
+
# the same mistake in a new place.
|
|
7427
|
+
continue
|
|
7428
|
+
wanted = {
|
|
7429
|
+
"DHCP_ENABLED": "1" if by_dhcp else "0",
|
|
7430
|
+
"IP_ADDRESS": "" if by_dhcp else address,
|
|
7431
|
+
"SUBNET_MASK": "" if by_dhcp else mask,
|
|
7432
|
+
"DEFAULT_GATEWAY": "" if by_dhcp else gateway,
|
|
7433
|
+
}
|
|
7434
|
+
touched = False
|
|
7435
|
+
for profile in profiles:
|
|
7436
|
+
for tag, value in wanted.items():
|
|
7437
|
+
if (profile.findtext(tag) or "").strip() != value:
|
|
7438
|
+
touched = True
|
|
7439
|
+
_ensure_text(profile, tag, value)
|
|
7440
|
+
if touched:
|
|
7441
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
7442
|
+
how = "DHCP" if by_dhcp else f"{address} statically"
|
|
7443
|
+
changed.append(f"{name} now uses {how} on its radio, which is what its port already said")
|
|
7444
|
+
return changed
|
|
7445
|
+
|
|
7446
|
+
|
|
7447
|
+
def _join_wireless_clients_to_the_network_that_exists(root: ET.Element) -> list[str]:
|
|
7448
|
+
"""A client cannot associate to a network name nothing is broadcasting.
|
|
7449
|
+
|
|
7450
|
+
Same shape as the addressing fault beside it, one layer down. Measured on
|
|
7451
|
+
`wireless_home`, built from "1 wireless router 2 laptop qur" -- no network
|
|
7452
|
+
name asked for, so nobody set one: the laptops kept the donor's
|
|
7453
|
+
`TestNetwork` and the router kept the donor's `Default`. Two residues from
|
|
7454
|
+
two different donors, and no association between them. `wireless_ssid`
|
|
7455
|
+
names a network in the prompt and both ends get it, which is why only the
|
|
7456
|
+
unnamed case was broken.
|
|
7457
|
+
|
|
7458
|
+
The router broadcasts and the clients join, so the clients move. When the
|
|
7459
|
+
lab has more than one network on the air there is nothing here to infer,
|
|
7460
|
+
and the pass stands aside.
|
|
7461
|
+
"""
|
|
7462
|
+
from pkt_editor import _wireless_common_nodes
|
|
7463
|
+
|
|
7464
|
+
broadcasters: dict[str, ET.Element] = {}
|
|
7465
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7466
|
+
ssid = (device.findtext("./ENGINE/WIRELESS_SERVER/WIRELESS_COMMON/SSID") or "").strip()
|
|
7467
|
+
if ssid:
|
|
7468
|
+
broadcasters[ssid] = device
|
|
7469
|
+
if len(broadcasters) != 1:
|
|
7470
|
+
return []
|
|
7471
|
+
ssid, broadcaster = next(iter(broadcasters.items()))
|
|
7472
|
+
|
|
7473
|
+
changed: list[str] = []
|
|
7474
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7475
|
+
if device is broadcaster:
|
|
7476
|
+
continue
|
|
7477
|
+
engine = device.find("./ENGINE")
|
|
7478
|
+
if engine is None or engine.find("./WIRELESS_CLIENT") is None:
|
|
7479
|
+
continue
|
|
7480
|
+
# `_wireless_common_nodes` walks from ENGINE, so the cellular radio --
|
|
7481
|
+
# nested under CELLULAR_CLIENT and always on `ptcellular` -- is out of
|
|
7482
|
+
# reach, which is what we want.
|
|
7483
|
+
nodes = [
|
|
7484
|
+
node
|
|
7485
|
+
for node in _wireless_common_nodes(engine)
|
|
7486
|
+
if engine.find("./WIRELESS_SERVER/WIRELESS_COMMON") is not node
|
|
7487
|
+
]
|
|
7488
|
+
profiles = _live_profile_nodes(engine)
|
|
7489
|
+
current = {
|
|
7490
|
+
(node.findtext("SSID") or "").strip()
|
|
7491
|
+
for node in [*nodes, *profiles]
|
|
7492
|
+
if (node.findtext("SSID") or "").strip()
|
|
7493
|
+
}
|
|
7494
|
+
if not current or current == {ssid}:
|
|
7495
|
+
continue
|
|
7496
|
+
for node in nodes:
|
|
7497
|
+
_ensure_text(node, "SSID", ssid)
|
|
7498
|
+
for profile in profiles:
|
|
7499
|
+
_ensure_text(profile, "NAME", ssid)
|
|
7500
|
+
_ensure_text(profile, "SSID", ssid)
|
|
7501
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
7502
|
+
changed.append(f"{name} now joins {ssid}, the only network on the air, instead of {'/'.join(sorted(current))}")
|
|
7503
|
+
return changed
|
|
7504
|
+
|
|
7505
|
+
|
|
7506
|
+
def _align_home_router_lan_with_its_clients(root: ET.Element) -> list[str]:
|
|
7507
|
+
"""A home router has to serve the network its own clients are addressed on.
|
|
7508
|
+
|
|
7509
|
+
Two places decide what that network is and neither reads the other. The
|
|
7510
|
+
addressing pass hands the laptops `192.168.10.20` and `.21` with a gateway
|
|
7511
|
+
of `192.168.10.1`; the home router keeps whatever the donor was configured
|
|
7512
|
+
with, `192.168.0.1/24` with a pool from `.100` to `.149`. Measured on
|
|
7513
|
+
`wireless_home` and `wireless_ssid`: the lab opens, the laptops associate,
|
|
7514
|
+
the coherence report says `gateway_answers_for_nobody` twice, and nothing
|
|
7515
|
+
can reach anything because the gateway they point at does not exist.
|
|
7516
|
+
|
|
7517
|
+
The router moves, not the hosts. Its LAN address is a setting; the hosts'
|
|
7518
|
+
addresses are the plan, and the rest of the lab is written against them.
|
|
7519
|
+
|
|
7520
|
+
Only static clients count. A DHCP client takes its address and its gateway
|
|
7521
|
+
from this router at runtime, so it can never disagree with it, and reading
|
|
7522
|
+
its stale address would make an aligned lab look broken.
|
|
7523
|
+
"""
|
|
7524
|
+
from lab_coherence import _interface_addresses
|
|
7525
|
+
|
|
7526
|
+
devices = root.findall(".//DEVICES/DEVICE")
|
|
7527
|
+
routers = [
|
|
7528
|
+
device
|
|
7529
|
+
for device in devices
|
|
7530
|
+
if _normalize_device_type(device.findtext("./ENGINE/TYPE") or "") in WIRELESS_ROUTER_KINDS
|
|
7531
|
+
]
|
|
7532
|
+
if not routers:
|
|
7533
|
+
return []
|
|
7534
|
+
|
|
7535
|
+
# An address some interface in the lab really answers for is not a gap,
|
|
7536
|
+
# whoever holds it.
|
|
7537
|
+
held: set[str] = set()
|
|
7538
|
+
for device in devices:
|
|
7539
|
+
for _port, address, _mask in _interface_addresses(device):
|
|
7540
|
+
held.add(address)
|
|
7541
|
+
for node in device.iter("LAN_IP_ADDRESS"):
|
|
7542
|
+
if (node.text or "").strip():
|
|
7543
|
+
held.add((node.text or "").strip())
|
|
7544
|
+
|
|
7545
|
+
wanted: dict[str, tuple[str, str]] = {}
|
|
7546
|
+
for device in devices:
|
|
7547
|
+
for port in device.findall(".//PORT"):
|
|
7548
|
+
if (port.findtext("PORT_DHCP_ENABLE") or "").strip().lower() == "true":
|
|
7549
|
+
continue
|
|
7550
|
+
gateway = (port.findtext("PORT_GATEWAY") or "").strip()
|
|
7551
|
+
address = (port.findtext("IP") or "").strip()
|
|
7552
|
+
mask = (port.findtext("SUBNET") or "").strip()
|
|
7553
|
+
if not gateway or gateway in held or not address or not mask:
|
|
7554
|
+
continue
|
|
7555
|
+
wanted.setdefault(gateway, (gateway, mask))
|
|
7556
|
+
|
|
7557
|
+
if not wanted:
|
|
7558
|
+
return []
|
|
7559
|
+
# More than one orphaned gateway is more than this pass can attribute, and
|
|
7560
|
+
# guessing which router owns which would be the same mistake again.
|
|
7561
|
+
if len(wanted) > 1 or len(routers) > 1:
|
|
7562
|
+
return []
|
|
7563
|
+
|
|
7564
|
+
gateway, mask = next(iter(wanted.values()))
|
|
7565
|
+
router = routers[0]
|
|
7566
|
+
name = (router.findtext("./ENGINE/NAME") or "").strip()
|
|
7567
|
+
current = (router.findtext("./ENGINE/LAN_IP_ADDRESS") or "").strip()
|
|
7568
|
+
if current == gateway:
|
|
7569
|
+
return []
|
|
7570
|
+
|
|
7571
|
+
network = _network_address(gateway, mask)
|
|
7572
|
+
if not network:
|
|
7573
|
+
return []
|
|
7574
|
+
first, last = _dhcp_pool_bounds(network, mask)
|
|
7575
|
+
first_offset, last_offset = _dhcp_pool_offsets(mask)
|
|
7576
|
+
present = {
|
|
7577
|
+
(port.findtext("MACADDRESS") or "").strip().lower()
|
|
7578
|
+
for device in devices
|
|
7579
|
+
for port in device.findall(".//PORT")
|
|
7580
|
+
if (port.findtext("MACADDRESS") or "").strip()
|
|
7581
|
+
}
|
|
7582
|
+
|
|
7583
|
+
# Written by path, not by tag. `ENGINE` carries a stray empty `START_IP`
|
|
7584
|
+
# beside the real one in `DHCP_SERVER/POOLS/POOL`, and a search by tag name
|
|
7585
|
+
# filled both -- putting a pool bound on a field the donor left blank on
|
|
7586
|
+
# purpose.
|
|
7587
|
+
engine = router.find("./ENGINE")
|
|
7588
|
+
if engine is None:
|
|
7589
|
+
return []
|
|
7590
|
+
for tag, value in (("LAN_IP_ADDRESS", gateway), ("LAN_SUBNET_MASK", mask)):
|
|
7591
|
+
node = engine.find(tag)
|
|
7592
|
+
if node is None:
|
|
7593
|
+
node = ET.SubElement(engine, tag)
|
|
7594
|
+
node.text = value
|
|
7595
|
+
for pool in engine.findall("./DHCP_SERVER/POOLS/POOL"):
|
|
7596
|
+
for tag, value in (
|
|
7597
|
+
("NETWORK", network),
|
|
7598
|
+
("MASK", mask),
|
|
7599
|
+
("DEFAULT_ROUTER", gateway),
|
|
7600
|
+
("START_IP", first),
|
|
7601
|
+
("END_IP", last),
|
|
7602
|
+
):
|
|
7603
|
+
node = pool.find(tag)
|
|
7604
|
+
if node is None:
|
|
7605
|
+
node = ET.SubElement(pool, tag)
|
|
7606
|
+
node.text = value
|
|
7607
|
+
# A lease records which client holds which address, and Packet Tracer
|
|
7608
|
+
# restores a client from it when the file opens. Deleting them -- which
|
|
7609
|
+
# this pass did at first, on the reasoning that an address on the old
|
|
7610
|
+
# network is stale -- is why a wireless client came up holding nothing
|
|
7611
|
+
# and reached nothing: with no lease to restore it would have had to
|
|
7612
|
+
# associate and ask again, which opening a file does not make it do.
|
|
7613
|
+
#
|
|
7614
|
+
# Measured: the donor's router carries five leases, one of them against
|
|
7615
|
+
# the very MAC our laptop has, and its client pings 4/4. Ours carried
|
|
7616
|
+
# none. The addresses were stale; the records were not, so they are
|
|
7617
|
+
# renumbered into the new pool instead of dropped.
|
|
7618
|
+
leases = pool.find("DHCP_POOL_LEASES")
|
|
7619
|
+
if leases is not None:
|
|
7620
|
+
for lease in list(leases):
|
|
7621
|
+
mac = (lease.findtext("MAC_ADDRESS") or "").strip().lower()
|
|
7622
|
+
if mac and mac not in present:
|
|
7623
|
+
# The client it was written for was pruned out of the lab.
|
|
7624
|
+
leases.remove(lease)
|
|
7625
|
+
for offset, lease in enumerate(list(leases)):
|
|
7626
|
+
held = lease.find("IP_ADDRESS")
|
|
7627
|
+
if held is None:
|
|
7628
|
+
held = ET.SubElement(lease, "IP_ADDRESS")
|
|
7629
|
+
held.text = _address_at(network, first_offset + offset, last_offset)
|
|
7630
|
+
return [f"{name} now serves {network} with its gateway on {gateway}, which its clients point at"]
|
|
7631
|
+
|
|
7632
|
+
|
|
7633
|
+
def _network_address(address: str, mask: str) -> str:
|
|
7634
|
+
try:
|
|
7635
|
+
octets = [int(part) for part in address.split(".")]
|
|
7636
|
+
bits = [int(part) for part in mask.split(".")]
|
|
7637
|
+
except ValueError:
|
|
7638
|
+
return ""
|
|
7639
|
+
if len(octets) != 4 or len(bits) != 4:
|
|
7640
|
+
return ""
|
|
7641
|
+
return ".".join(str(octet & bit) for octet, bit in zip(octets, bits))
|
|
7642
|
+
|
|
7643
|
+
|
|
7644
|
+
def _dhcp_pool_offsets(mask: str) -> tuple[int, int]:
|
|
7645
|
+
"""Where a home router's pool starts and stops inside its own network."""
|
|
7646
|
+
try:
|
|
7647
|
+
bits = [int(part) for part in mask.split(".")]
|
|
7648
|
+
except ValueError:
|
|
7649
|
+
return 1, 1
|
|
7650
|
+
if len(bits) != 4:
|
|
7651
|
+
return 1, 1
|
|
7652
|
+
size = 1
|
|
7653
|
+
for bit in bits:
|
|
7654
|
+
size *= 256 - bit
|
|
7655
|
+
return min(100, max(size - 2, 1)), min(149, max(size - 2, 1))
|
|
7656
|
+
|
|
7657
|
+
|
|
7658
|
+
def _address_at(network: str, offset: int, ceiling: int) -> str:
|
|
7659
|
+
"""The address `offset` into `network`, never past the pool's last one."""
|
|
7660
|
+
try:
|
|
7661
|
+
octets = [int(part) for part in network.split(".")]
|
|
7662
|
+
except ValueError:
|
|
7663
|
+
return network
|
|
7664
|
+
if len(octets) != 4:
|
|
7665
|
+
return network
|
|
7666
|
+
value = (octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3] + min(offset, ceiling)
|
|
7667
|
+
return ".".join(str((value >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
7668
|
+
|
|
7669
|
+
|
|
7670
|
+
def _dhcp_pool_bounds(network: str, mask: str) -> tuple[str, str]:
|
|
7671
|
+
"""The same span the donor used, `.100` to `.149`, kept inside the network.
|
|
7672
|
+
|
|
7673
|
+
A /24 is what a home router serves in practice; anything narrower gets a
|
|
7674
|
+
pool that fits rather than one that runs past the broadcast address.
|
|
7675
|
+
"""
|
|
7676
|
+
try:
|
|
7677
|
+
octets = [int(part) for part in network.split(".")]
|
|
7678
|
+
bits = [int(part) for part in mask.split(".")]
|
|
7679
|
+
except ValueError:
|
|
7680
|
+
return network, network
|
|
7681
|
+
size = 1
|
|
7682
|
+
for bit in bits:
|
|
7683
|
+
size *= 256 - bit
|
|
7684
|
+
first_offset = min(100, max(size - 2, 1))
|
|
7685
|
+
last_offset = min(149, max(size - 2, 1))
|
|
7686
|
+
|
|
7687
|
+
def _at(offset: int) -> str:
|
|
7688
|
+
value = (octets[0] << 24) + (octets[1] << 16) + (octets[2] << 8) + octets[3] + offset
|
|
7689
|
+
return ".".join(str((value >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
7690
|
+
|
|
7691
|
+
return _at(first_offset), _at(last_offset)
|
|
7692
|
+
|
|
7693
|
+
|
|
7694
|
+
def _move_static_hosts_onto_their_vlan_network(root: ET.Element) -> list[str]:
|
|
7695
|
+
"""A static host has to sit on the network its own VLAN is routed on.
|
|
7696
|
+
|
|
7697
|
+
Two passes decide a host's address: one hands out addresses, one decides
|
|
7698
|
+
what each VLAN's gateway is, and neither reads the other. Measured on the
|
|
7699
|
+
enterprise lab: Server6 sat in VLAN 10 holding `192.168.10.13` with a
|
|
7700
|
+
default gateway of `192.168.10.1`, while VLAN 10 is routed on
|
|
7701
|
+
`10.10.10.0/24`. Nineteen servers and printers were like that -- addressed,
|
|
7702
|
+
cabled, in the right VLAN, and pointing at a gateway no interface in the
|
|
7703
|
+
lab answers for.
|
|
7704
|
+
|
|
7705
|
+
The VLAN's gateway is shared infrastructure and the host is one device, so
|
|
7706
|
+
the host moves. Its host part is kept where it fits, which leaves the
|
|
7707
|
+
lab's addressing readable, and the gateway it is given is the HSRP virtual
|
|
7708
|
+
address when the VLAN has one -- the address the rest of the lab already
|
|
7709
|
+
points at.
|
|
7710
|
+
|
|
7711
|
+
DHCP clients are not touched: Packet Tracer replaces their address at
|
|
7712
|
+
runtime, so there is nothing here to correct.
|
|
7713
|
+
"""
|
|
7714
|
+
from lab_coherence import _interface_addresses
|
|
7715
|
+
|
|
7716
|
+
switch_types = {"Switch", "MultiLayerSwitch"}
|
|
7717
|
+
devices_by_ref = {
|
|
7718
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): device
|
|
7719
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
7720
|
+
}
|
|
4710
7721
|
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
7722
|
+
gateways: dict[int, tuple[str, str]] = {}
|
|
7723
|
+
held: set[str] = set()
|
|
7724
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7725
|
+
vlan = 0
|
|
7726
|
+
address = mask = virtual = ""
|
|
7727
|
+
for node in [*device.findall("./ENGINE/RUNNINGCONFIG/LINE"), None]:
|
|
7728
|
+
text = (node.text or "").strip() if node is not None else "interface END"
|
|
7729
|
+
if text.startswith("interface "):
|
|
7730
|
+
if vlan and address and vlan not in gateways:
|
|
7731
|
+
gateways[vlan] = (virtual or address, mask)
|
|
7732
|
+
vlan = 0
|
|
7733
|
+
address = mask = virtual = ""
|
|
7734
|
+
continue
|
|
7735
|
+
encapsulation = re.match(r"^encapsulation dot1Q (\d+)", text)
|
|
7736
|
+
if encapsulation:
|
|
7737
|
+
vlan = int(encapsulation.group(1))
|
|
7738
|
+
addressed = re.match(r"^ip address (\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$", text)
|
|
7739
|
+
if addressed:
|
|
7740
|
+
address, mask = addressed.group(1), addressed.group(2)
|
|
7741
|
+
held.add(address)
|
|
7742
|
+
standby = re.match(r"^standby \d+ ip (\d+\.\d+\.\d+\.\d+)$", text)
|
|
7743
|
+
if standby:
|
|
7744
|
+
virtual = standby.group(1)
|
|
7745
|
+
held.add(virtual)
|
|
7746
|
+
# No early return on an empty `gateways`: a lab with no VLANs still has a
|
|
7747
|
+
# router interface per segment, and its hosts still have to be on it. The
|
|
7748
|
+
# loop below already skips a host for which neither source knows an answer.
|
|
7749
|
+
port_vlan: dict[tuple[str, str], int] = {}
|
|
7750
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7751
|
+
if (device.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
7752
|
+
continue
|
|
7753
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
7754
|
+
port = ""
|
|
7755
|
+
for node in device.findall("./ENGINE/RUNNINGCONFIG/LINE"):
|
|
7756
|
+
text = (node.text or "").strip()
|
|
7757
|
+
if text.startswith("interface "):
|
|
7758
|
+
port = text.split(None, 1)[1]
|
|
7759
|
+
elif text.startswith("switchport access vlan ") and port:
|
|
7760
|
+
port_vlan[(name, port)] = int(text.rsplit(" ", 1)[1])
|
|
7761
|
+
|
|
7762
|
+
# Which router interface answers for each switch, when no VLAN says so.
|
|
7763
|
+
# Switches cabled to each other share one segment, so a router reached
|
|
7764
|
+
# through a neighbouring switch is still this host's way out.
|
|
7765
|
+
neighbours: dict[str, set[str]] = {}
|
|
7766
|
+
router_on_switch: dict[str, tuple[str, str]] = {}
|
|
7767
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
7768
|
+
cable = link.find("./CABLE")
|
|
7769
|
+
if cable is None:
|
|
7770
|
+
continue
|
|
7771
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
7772
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
7773
|
+
if len(ports) < 2:
|
|
7774
|
+
continue
|
|
7775
|
+
left, right = devices_by_ref.get(refs[0]), devices_by_ref.get(refs[1])
|
|
7776
|
+
if left is None or right is None:
|
|
7777
|
+
continue
|
|
7778
|
+
left_name = (left.findtext("./ENGINE/NAME") or "").strip()
|
|
7779
|
+
right_name = (right.findtext("./ENGINE/NAME") or "").strip()
|
|
7780
|
+
left_kind = (left.findtext("./ENGINE/TYPE") or "")
|
|
7781
|
+
right_kind = (right.findtext("./ENGINE/TYPE") or "")
|
|
7782
|
+
if left_kind in switch_types and right_kind in switch_types:
|
|
7783
|
+
neighbours.setdefault(left_name, set()).add(right_name)
|
|
7784
|
+
neighbours.setdefault(right_name, set()).add(left_name)
|
|
7785
|
+
for near, near_port, far, far_name in (
|
|
7786
|
+
(left, ports[0], right, right_name),
|
|
7787
|
+
(right, ports[1], left, left_name),
|
|
7788
|
+
):
|
|
7789
|
+
if (near.findtext("./ENGINE/TYPE") or "") != "Router":
|
|
7790
|
+
continue
|
|
7791
|
+
if (far.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
7792
|
+
continue
|
|
7793
|
+
for port, address, mask in _interface_addresses(near):
|
|
7794
|
+
if port == near_port and mask not in {"255.255.255.252", "255.255.255.254"}:
|
|
7795
|
+
router_on_switch[far_name] = (address, mask)
|
|
7796
|
+
break
|
|
4721
7797
|
|
|
7798
|
+
segment_gateway: dict[str, tuple[str, str]] = {}
|
|
7799
|
+
for start in list(neighbours) + list(router_on_switch):
|
|
7800
|
+
if start in segment_gateway:
|
|
7801
|
+
continue
|
|
7802
|
+
seen = {start}
|
|
7803
|
+
queue = [start]
|
|
7804
|
+
found: tuple[str, str] | None = None
|
|
7805
|
+
while queue:
|
|
7806
|
+
current = queue.pop()
|
|
7807
|
+
if current in router_on_switch:
|
|
7808
|
+
found = router_on_switch[current]
|
|
7809
|
+
break
|
|
7810
|
+
for neighbour in neighbours.get(current, ()):
|
|
7811
|
+
if neighbour not in seen:
|
|
7812
|
+
seen.add(neighbour)
|
|
7813
|
+
queue.append(neighbour)
|
|
7814
|
+
if found is not None:
|
|
7815
|
+
for name in seen:
|
|
7816
|
+
segment_gateway.setdefault(name, found)
|
|
7817
|
+
|
|
7818
|
+
taken = set(held)
|
|
7819
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
7820
|
+
for port in device.findall(".//PORT"):
|
|
7821
|
+
value = (port.findtext("IP") or "").strip()
|
|
7822
|
+
if value:
|
|
7823
|
+
taken.add(value)
|
|
4722
7824
|
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
7825
|
+
moved: list[str] = []
|
|
7826
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
7827
|
+
cable = link.find("./CABLE")
|
|
7828
|
+
if cable is None:
|
|
7829
|
+
continue
|
|
7830
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
7831
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
7832
|
+
if len(ports) < 2:
|
|
7833
|
+
continue
|
|
7834
|
+
left, right = devices_by_ref.get(refs[0]), devices_by_ref.get(refs[1])
|
|
7835
|
+
if left is None or right is None:
|
|
7836
|
+
continue
|
|
7837
|
+
for switch, switch_port, host in ((left, ports[0], right), (right, ports[1], left)):
|
|
7838
|
+
if (switch.findtext("./ENGINE/TYPE") or "") not in switch_types:
|
|
7839
|
+
continue
|
|
7840
|
+
# Only end devices. A router cabled to an access port is not a host
|
|
7841
|
+
# with a default gateway, and the first version of this rewrote
|
|
7842
|
+
# R1's WAN address from 200.10.0.2 to 10.10.30.4 because it read
|
|
7843
|
+
# the PORT node the same way.
|
|
7844
|
+
if (host.findtext("./ENGINE/TYPE") or "") not in HOST_DEVICE_TYPES:
|
|
7845
|
+
continue
|
|
7846
|
+
switch_name = (switch.findtext("./ENGINE/NAME") or "").strip()
|
|
7847
|
+
vlan = port_vlan.get((switch_name, switch_port))
|
|
7848
|
+
if vlan and vlan in gateways:
|
|
7849
|
+
gateway, mask = gateways[vlan]
|
|
7850
|
+
elif switch_name in segment_gateway:
|
|
7851
|
+
# Not `not vlan`: the donor's access ports carry a VLAN number
|
|
7852
|
+
# whether or not any router subinterface serves it, so keying
|
|
7853
|
+
# on "has no VLAN" skipped every host in a lab that had VLAN
|
|
7854
|
+
# tags and no router-on-a-stick. A VLAN nothing routes cannot
|
|
7855
|
+
# answer for a host; the segment's own router interface can.
|
|
7856
|
+
# A lab with no VLANs still has one router interface per
|
|
7857
|
+
# segment, and a host still has to be on it. Measured on
|
|
7858
|
+
# "2 router 2 switch 6 komputer": R1's LAN is 192.168.3.254,
|
|
7859
|
+
# PC4 was correctly on it, and the other five sat on
|
|
7860
|
+
# 192.168.1.x pointing at .254 and .1 -- three address plans
|
|
7861
|
+
# in a lab of six workstations.
|
|
7862
|
+
gateway, mask = segment_gateway[switch_name]
|
|
7863
|
+
else:
|
|
7864
|
+
continue
|
|
7865
|
+
current_gateway = (host.findtext(".//GATEWAY") or "").strip()
|
|
7866
|
+
if current_gateway in held:
|
|
7867
|
+
continue
|
|
7868
|
+
socket = next(
|
|
7869
|
+
(
|
|
7870
|
+
node
|
|
7871
|
+
for node in host.findall(".//PORT")
|
|
7872
|
+
if (node.findtext("IP") or "").strip()
|
|
7873
|
+
and (node.findtext("IP") or "").strip() != "0.0.0.0"
|
|
7874
|
+
and (node.findtext("PORT_DHCP_ENABLE") or "").strip().lower() != "true"
|
|
7875
|
+
),
|
|
7876
|
+
None,
|
|
7877
|
+
)
|
|
7878
|
+
if socket is None:
|
|
7879
|
+
continue
|
|
7880
|
+
was = (socket.findtext("IP") or "").strip()
|
|
7881
|
+
network = _network_from(gateway, mask)
|
|
7882
|
+
wanted = _network_from(was, mask)
|
|
7883
|
+
if network is None or (wanted is not None and wanted == network):
|
|
7884
|
+
continue
|
|
7885
|
+
candidate = _join_host_part(was, gateway, mask)
|
|
7886
|
+
while candidate in taken or candidate == gateway:
|
|
7887
|
+
candidate = _next_address(candidate)
|
|
7888
|
+
taken.add(candidate)
|
|
7889
|
+
_set_text(socket, "IP", candidate)
|
|
7890
|
+
_set_text(socket, "SUBNET", mask)
|
|
7891
|
+
node = host.find(".//GATEWAY")
|
|
7892
|
+
if node is None:
|
|
7893
|
+
node = ET.SubElement(host, "GATEWAY")
|
|
7894
|
+
node.text = gateway
|
|
7895
|
+
moved.append(
|
|
7896
|
+
f"{host.findtext('./ENGINE/NAME') or ''} {was} -> {candidate} "
|
|
7897
|
+
f"(VLAN {vlan}, gateway {gateway})"
|
|
7898
|
+
)
|
|
7899
|
+
return moved
|
|
4730
7900
|
|
|
4731
7901
|
|
|
4732
7902
|
def _align_dhcp_pools_with_interfaces(root: ET.Element) -> list[str]:
|
|
@@ -4769,30 +7939,45 @@ def _align_dhcp_pools_with_interfaces(root: ET.Element) -> list[str]:
|
|
|
4769
7939
|
if not interfaces:
|
|
4770
7940
|
continue
|
|
4771
7941
|
|
|
4772
|
-
|
|
7942
|
+
# A pool serves a LAN. A /30 is a router-to-router link and never has a
|
|
7943
|
+
# client on it, so it must not be offered as a home for a pool that has
|
|
7944
|
+
# nowhere else to go: measured, a workstation was handed 200.10.0.1/30
|
|
7945
|
+
# -- the ISP's own WAN address -- and two more got /30 masks on a /24
|
|
7946
|
+
# segment, because the search took the first free interface of any
|
|
7947
|
+
# shape.
|
|
7948
|
+
lan_interfaces = [
|
|
7949
|
+
(address, mask)
|
|
7950
|
+
for address, mask in interfaces
|
|
7951
|
+
if (_address_to_int(mask) or 0) <= _address_to_int("255.255.255.0")
|
|
7952
|
+
]
|
|
7953
|
+
|
|
7954
|
+
pools: list[tuple[str, ET.Element, ET.Element | None, list[ET.Element]]] = []
|
|
4773
7955
|
pool_name = ""
|
|
4774
7956
|
network_node: ET.Element | None = None
|
|
4775
7957
|
gateway_node: ET.Element | None = None
|
|
7958
|
+
block: list[ET.Element] = []
|
|
4776
7959
|
for line in lines:
|
|
4777
7960
|
text = (line.text or "").rstrip()
|
|
4778
7961
|
stripped = text.strip()
|
|
4779
7962
|
if not text.startswith((" ", "\t")):
|
|
4780
7963
|
if pool_name and network_node is not None:
|
|
4781
|
-
pools.append((pool_name, network_node, gateway_node))
|
|
7964
|
+
pools.append((pool_name, network_node, gateway_node, block))
|
|
4782
7965
|
pool_name = stripped[len("ip dhcp pool ") :] if stripped.startswith("ip dhcp pool ") else ""
|
|
4783
7966
|
network_node = None
|
|
4784
7967
|
gateway_node = None
|
|
7968
|
+
block = [line] if pool_name else []
|
|
4785
7969
|
continue
|
|
4786
7970
|
if not pool_name:
|
|
4787
7971
|
continue
|
|
7972
|
+
block.append(line)
|
|
4788
7973
|
if stripped.startswith("network "):
|
|
4789
7974
|
network_node = line
|
|
4790
7975
|
elif stripped.startswith("default-router "):
|
|
4791
7976
|
gateway_node = line
|
|
4792
7977
|
if pool_name and network_node is not None:
|
|
4793
|
-
pools.append((pool_name, network_node, gateway_node))
|
|
7978
|
+
pools.append((pool_name, network_node, gateway_node, block))
|
|
4794
7979
|
|
|
4795
|
-
for name, network_line, gateway_line in pools:
|
|
7980
|
+
for name, network_line, gateway_line, block in pools:
|
|
4796
7981
|
parts = (network_line.text or "").split()
|
|
4797
7982
|
if len(parts) != 3:
|
|
4798
7983
|
continue
|
|
@@ -4801,15 +7986,24 @@ def _align_dhcp_pools_with_interfaces(root: ET.Element) -> list[str]:
|
|
|
4801
7986
|
continue
|
|
4802
7987
|
served = {
|
|
4803
7988
|
(address, interface_mask)
|
|
4804
|
-
for other_name, other_network,
|
|
7989
|
+
for other_name, other_network, _gateway, _block in pools
|
|
4805
7990
|
if other_name != name
|
|
4806
|
-
for address, interface_mask in
|
|
7991
|
+
for address, interface_mask in lan_interfaces
|
|
4807
7992
|
if _same_subnet(address, (other_network.text or "").split()[1], interface_mask)
|
|
4808
7993
|
}
|
|
4809
|
-
replacement = next(
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
7994
|
+
replacement = next((pair for pair in lan_interfaces if pair not in served), None)
|
|
7995
|
+
if replacement is None:
|
|
7996
|
+
# More pools than LANs to serve. A pool with nowhere to live is
|
|
7997
|
+
# not harmless: left pointing at a link, it hands a workstation
|
|
7998
|
+
# the address of a router-to-router segment.
|
|
7999
|
+
for node in block:
|
|
8000
|
+
if node in list(config):
|
|
8001
|
+
config.remove(node)
|
|
8002
|
+
repaired.append(
|
|
8003
|
+
f"{device.findtext('./ENGINE/NAME') or ''}: pool {name} removed, "
|
|
8004
|
+
f"no LAN left for it to serve"
|
|
8005
|
+
)
|
|
8006
|
+
continue
|
|
4813
8007
|
address, interface_mask = replacement
|
|
4814
8008
|
address_value = _address_to_int(address)
|
|
4815
8009
|
mask_value = _address_to_int(interface_mask)
|
|
@@ -4981,6 +8175,45 @@ LOGICAL_ICON_SPACING = 110
|
|
|
4981
8175
|
PARKED_LOGICAL_X = 9000
|
|
4982
8176
|
|
|
4983
8177
|
|
|
8178
|
+
# How far apart two rows of switch blocks sit, and how far the hosts hang
|
|
8179
|
+
# below their switch. Taken from the spacing the annotation already draws its
|
|
8180
|
+
# frames with, so a frame still wraps its own block.
|
|
8181
|
+
BLOCK_ROW_PITCH = 420
|
|
8182
|
+
BLOCK_HOST_DROP = 200
|
|
8183
|
+
|
|
8184
|
+
|
|
8185
|
+
def _parked_names(root: ET.Element) -> set[str]:
|
|
8186
|
+
"""Devices deliberately set aside, which is not the same as "far to the right".
|
|
8187
|
+
|
|
8188
|
+
The parked column holds the donor's leftovers when
|
|
8189
|
+
`PACKET_TRACER_SPARE_STRATEGY=park` is in force: renamed `UNUSED-*` and
|
|
8190
|
+
moved offscreen so they are visibly not part of the lab. Four passes tested
|
|
8191
|
+
for that by comparing x against `PARKED_LOGICAL_X` and nothing else, which
|
|
8192
|
+
is the same rule written four times and it was wrong in every copy. A lab
|
|
8193
|
+
whose blocks had been laid end to end ran past the threshold halfway
|
|
8194
|
+
through, and from there the layout pass stopped tidying, the compaction
|
|
8195
|
+
stopped pulling in, the overlap pass stopped separating and the annotation
|
|
8196
|
+
stopped drawing frames -- measured as SW13 and its eight hosts stranded at
|
|
8197
|
+
x 15,510, and nine frames for seventeen blocks.
|
|
8198
|
+
|
|
8199
|
+
A cabled device is never parked, whatever its coordinates. One function
|
|
8200
|
+
answers that now, so the four passes cannot drift apart again.
|
|
8201
|
+
"""
|
|
8202
|
+
wired = {name for pair in _link_device_pairs(root) for name in pair}
|
|
8203
|
+
parked: set[str] = set()
|
|
8204
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
8205
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
8206
|
+
if not name or name in wired:
|
|
8207
|
+
continue
|
|
8208
|
+
try:
|
|
8209
|
+
x = float((device.findtext("./WORKSPACE/LOGICAL/X") or "").strip())
|
|
8210
|
+
except ValueError:
|
|
8211
|
+
continue
|
|
8212
|
+
if x >= PARKED_LOGICAL_X:
|
|
8213
|
+
parked.add(name)
|
|
8214
|
+
return parked
|
|
8215
|
+
|
|
8216
|
+
|
|
4984
8217
|
def _group_hosts_under_their_switch(root: ET.Element) -> list[str]:
|
|
4985
8218
|
"""Lay each switch's hosts out beneath it, one block per switch.
|
|
4986
8219
|
|
|
@@ -4998,9 +8231,18 @@ def _group_hosts_under_their_switch(root: ET.Element) -> list[str]:
|
|
|
4998
8231
|
Routers, the core, and anything not cabled to an access switch are left
|
|
4999
8232
|
where they are: they sit above this row and the reader expects them there.
|
|
5000
8233
|
"""
|
|
8234
|
+
# An agent that has already drawn the topology -- deciding where each
|
|
8235
|
+
# department sits and which switch faces which -- knows more about the
|
|
8236
|
+
# intended shape than a packing rule does. `PACKET_TRACER_LAYOUT=keep`
|
|
8237
|
+
# leaves every coordinate exactly as the blueprint gave it.
|
|
8238
|
+
if (os.getenv("PACKET_TRACER_LAYOUT") or "").strip().lower() == "keep":
|
|
8239
|
+
return []
|
|
8240
|
+
|
|
5001
8241
|
kinds: dict[str, str] = {}
|
|
5002
8242
|
nodes: dict[str, tuple[ET.Element, ET.Element]] = {}
|
|
5003
8243
|
position: dict[str, tuple[float, float]] = {}
|
|
8244
|
+
parked = _parked_names(root)
|
|
8245
|
+
wired = {name for pair in _link_device_pairs(root) for name in pair}
|
|
5004
8246
|
for device in root.findall(".//DEVICES/DEVICE"):
|
|
5005
8247
|
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
5006
8248
|
x_node = device.find("./WORKSPACE/LOGICAL/X")
|
|
@@ -5012,7 +8254,7 @@ def _group_hosts_under_their_switch(root: ET.Element) -> list[str]:
|
|
|
5012
8254
|
y = float((y_node.text or "").strip())
|
|
5013
8255
|
except ValueError:
|
|
5014
8256
|
continue
|
|
5015
|
-
if
|
|
8257
|
+
if name in parked:
|
|
5016
8258
|
continue
|
|
5017
8259
|
kinds[name] = (device.findtext("./ENGINE/TYPE") or "").strip()
|
|
5018
8260
|
nodes[name] = (x_node, y_node)
|
|
@@ -5033,15 +8275,52 @@ def _group_hosts_under_their_switch(root: ET.Element) -> list[str]:
|
|
|
5033
8275
|
if len(blocks) < 2:
|
|
5034
8276
|
return []
|
|
5035
8277
|
|
|
5036
|
-
|
|
5037
|
-
|
|
8278
|
+
# Document order, not current x. Sorting by position made the pass depend
|
|
8279
|
+
# on its own output: run twice, the blocks pack differently, and a lab
|
|
8280
|
+
# regenerated from itself drifts. Measured at 2,530 units wide on the first
|
|
8281
|
+
# run and 2,715 on the second.
|
|
8282
|
+
appearance = {
|
|
8283
|
+
(device.findtext("./ENGINE/NAME") or "").strip(): index
|
|
8284
|
+
for index, device in enumerate(root.findall(".//DEVICES/DEVICE"))
|
|
8285
|
+
}
|
|
8286
|
+
ordered = sorted(blocks, key=lambda name: appearance.get(name, 0))
|
|
5038
8287
|
moved: list[str] = []
|
|
5039
|
-
cursor = min(position[name][0] for name in ordered) - LOGICAL_ICON_SPACING
|
|
5040
8288
|
|
|
8289
|
+
widths = {
|
|
8290
|
+
name: max(len(blocks[name]) - 1, 0) * LOGICAL_ICON_SPACING for name in ordered
|
|
8291
|
+
}
|
|
8292
|
+
# One row of blocks reads as a strip, not a diagram. Eighteen switches laid
|
|
8293
|
+
# end to end measured 15,840 units across against 4,020 tall -- a lab you
|
|
8294
|
+
# scroll sideways through for a minute and never see at once.
|
|
8295
|
+
#
|
|
8296
|
+
# How many blocks per row: the packing is greedy, so a budget taken from
|
|
8297
|
+
# the total width leaves a third of each row empty and the diagram comes
|
|
8298
|
+
# out as tall as the strip was wide -- 2,510 by 3,560 on the first attempt.
|
|
8299
|
+
# Counting in whole blocks instead balances the two spans: with `columns`
|
|
8300
|
+
# blocks across, the lab is columns x pitch wide and count/columns x
|
|
8301
|
+
# BLOCK_ROW_PITCH tall, and those are equal at the square root below.
|
|
8302
|
+
pitch_x = max(widths.values(), default=0) + LOGICAL_ICON_SPACING
|
|
8303
|
+
columns = max(1, round((len(ordered) * BLOCK_ROW_PITCH / pitch_x) ** 0.5))
|
|
8304
|
+
budget = columns * pitch_x
|
|
8305
|
+
# A fixed anchor, not one read back from the devices this pass just moved.
|
|
8306
|
+
# Each switch ends up centred over its hosts, so the minimum switch x is
|
|
8307
|
+
# larger after a run than before it; deriving the left margin from that
|
|
8308
|
+
# walked the whole diagram to the right on every regeneration while the
|
|
8309
|
+
# routers stayed put, and the lab grew wider each time -- 2,430 units, then
|
|
8310
|
+
# 2,616, then 2,946.
|
|
8311
|
+
left = float(LOGICAL_ICON_SPACING)
|
|
8312
|
+
switch_row = min(position[name][1] for name in ordered)
|
|
8313
|
+
|
|
8314
|
+
cursor = left
|
|
8315
|
+
row = 0
|
|
5041
8316
|
for switch_name in ordered:
|
|
8317
|
+
width = widths[switch_name]
|
|
8318
|
+
if cursor > left and cursor - left + width > budget:
|
|
8319
|
+
row += 1
|
|
8320
|
+
cursor = left
|
|
5042
8321
|
members = sorted(blocks[switch_name], key=lambda name: _name_sort_key(name))
|
|
5043
|
-
width = max(len(members) - 1, 0) * LOGICAL_ICON_SPACING
|
|
5044
8322
|
start = cursor + LOGICAL_ICON_SPACING
|
|
8323
|
+
host_row = switch_row + row * BLOCK_ROW_PITCH + BLOCK_HOST_DROP
|
|
5045
8324
|
for index, host in enumerate(members):
|
|
5046
8325
|
target = (start + index * LOGICAL_ICON_SPACING, host_row)
|
|
5047
8326
|
if position[host] == target:
|
|
@@ -5054,10 +8333,356 @@ def _group_hosts_under_their_switch(root: ET.Element) -> list[str]:
|
|
|
5054
8333
|
# The switch sits centred over the hosts it serves, so the block reads
|
|
5055
8334
|
# as one shape rather than a row with a label somewhere off to the side.
|
|
5056
8335
|
centre = start + width / 2
|
|
8336
|
+
top = switch_row + row * BLOCK_ROW_PITCH
|
|
5057
8337
|
x_node, y_node = nodes[switch_name]
|
|
5058
8338
|
x_node.text = str(int(centre))
|
|
5059
|
-
|
|
8339
|
+
y_node.text = str(int(top))
|
|
8340
|
+
# The node holds the rounded value, so remember that, not the float:
|
|
8341
|
+
# otherwise every later run sees a mismatch and moves it again.
|
|
8342
|
+
position[switch_name] = (float(int(centre)), float(int(top)))
|
|
5060
8343
|
cursor = start + width + LOGICAL_ICON_SPACING
|
|
8344
|
+
|
|
8345
|
+
# A switch with no hosts of its own -- the core, carrying only trunks --
|
|
8346
|
+
# is in no block, so nothing placed it. On the enterprise lab that left
|
|
8347
|
+
# SW18 alone at y 4,080, a screen and a half below the network it serves,
|
|
8348
|
+
# with every trunk drawn as a long diagonal across the diagram. It belongs
|
|
8349
|
+
# above the access rows, between them and the routers.
|
|
8350
|
+
core = [
|
|
8351
|
+
name
|
|
8352
|
+
for name, kind in kinds.items()
|
|
8353
|
+
if kind in switch_kinds and name not in blocks and name in wired
|
|
8354
|
+
]
|
|
8355
|
+
if core:
|
|
8356
|
+
spread = max(position[name][0] for name in ordered) + LOGICAL_ICON_SPACING
|
|
8357
|
+
base = min(position[name][0] for name in ordered)
|
|
8358
|
+
step = max((spread - base) / (len(core) + 1), LOGICAL_ICON_SPACING)
|
|
8359
|
+
for index, name in enumerate(sorted(core, key=lambda text: appearance.get(text, 0))):
|
|
8360
|
+
x_node, y_node = nodes[name]
|
|
8361
|
+
target = (base + step * (index + 1), switch_row - BLOCK_ROW_PITCH)
|
|
8362
|
+
if position[name] == target:
|
|
8363
|
+
continue
|
|
8364
|
+
x_node.text = str(int(target[0]))
|
|
8365
|
+
y_node.text = str(int(target[1]))
|
|
8366
|
+
position[name] = (float(int(target[0])), float(int(target[1])))
|
|
8367
|
+
moved.append(f"{name}: -> above the access rows")
|
|
8368
|
+
return moved
|
|
8369
|
+
|
|
8370
|
+
|
|
8371
|
+
# How far a cable-less leftover may sit outside the wired lab before it is
|
|
8372
|
+
# pulled in. Wide enough that a device merely sitting at the edge is left alone.
|
|
8373
|
+
STRAY_DEVICE_MARGIN = 400
|
|
8374
|
+
|
|
8375
|
+
|
|
8376
|
+
def _drop_cables_the_plan_did_not_ask_for(root: ET.Element, blueprint: dict[str, object]) -> list[str]:
|
|
8377
|
+
"""A host socket takes one cable, and the plan says which one.
|
|
8378
|
+
|
|
8379
|
+
Holding a surplus donor device back so it can answer to a planned name
|
|
8380
|
+
keeps its donor cabling too. The plan then lays its own cable to that name,
|
|
8381
|
+
and the device ends up on two switches at once -- Packet Tracer refuses the
|
|
8382
|
+
file, and the corpus caught four cases of it in one run:
|
|
8383
|
+
|
|
8384
|
+
port PC3 FastEthernet0 is used by both link 3 and link 8
|
|
8385
|
+
|
|
8386
|
+
The inherited cable is the one to drop: the plan chose which switch that
|
|
8387
|
+
host belongs to, and the donor's arrangement is an accident of which lab it
|
|
8388
|
+
came from. Where neither cable matches the plan the first is kept, because
|
|
8389
|
+
leaving both is the one outcome that does not open.
|
|
8390
|
+
"""
|
|
8391
|
+
from pkt_editor import _remove_link
|
|
8392
|
+
|
|
8393
|
+
planned: dict[str, set[str]] = {}
|
|
8394
|
+
for link in blueprint.get("links", []) or []:
|
|
8395
|
+
left = str((link.get("a") or {}).get("dev") or "")
|
|
8396
|
+
right = str((link.get("b") or {}).get("dev") or "")
|
|
8397
|
+
if left and right:
|
|
8398
|
+
planned.setdefault(left, set()).add(right)
|
|
8399
|
+
planned.setdefault(right, set()).add(left)
|
|
8400
|
+
|
|
8401
|
+
names = {
|
|
8402
|
+
(device.findtext("./ENGINE/SAVE_REF_ID") or "").strip(): (device.findtext("./ENGINE/NAME") or "").strip()
|
|
8403
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
8404
|
+
}
|
|
8405
|
+
on_port: dict[tuple[str, str], list[str]] = {}
|
|
8406
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
8407
|
+
cable = link.find("./CABLE")
|
|
8408
|
+
if cable is None:
|
|
8409
|
+
continue
|
|
8410
|
+
refs = [(cable.findtext(tag) or "").strip() for tag in ("FROM", "TO")]
|
|
8411
|
+
ports = [node.text or "" for node in cable.findall("PORT")]
|
|
8412
|
+
if len(ports) < 2:
|
|
8413
|
+
continue
|
|
8414
|
+
left, right = names.get(refs[0], ""), names.get(refs[1], "")
|
|
8415
|
+
if not left or not right:
|
|
8416
|
+
continue
|
|
8417
|
+
on_port.setdefault((left, ports[0]), []).append(right)
|
|
8418
|
+
on_port.setdefault((right, ports[1]), []).append(left)
|
|
8419
|
+
|
|
8420
|
+
dropped: list[str] = []
|
|
8421
|
+
for (device_name, port), partners in sorted(on_port.items()):
|
|
8422
|
+
if len(partners) < 2:
|
|
8423
|
+
continue
|
|
8424
|
+
wanted = planned.get(device_name, set())
|
|
8425
|
+
keep = next((partner for partner in partners if partner in wanted), partners[0])
|
|
8426
|
+
for partner in partners:
|
|
8427
|
+
if partner == keep:
|
|
8428
|
+
continue
|
|
8429
|
+
_remove_link(root, device_name, partner)
|
|
8430
|
+
dropped.append(f"{device_name}:{port} no longer also cabled to {partner}")
|
|
8431
|
+
return dropped
|
|
8432
|
+
|
|
8433
|
+
|
|
8434
|
+
def _adopt_planned_names(root: ET.Element, blueprint: dict[str, object]) -> list[str]:
|
|
8435
|
+
"""Give a device the plan's name when it is doing the plan's job.
|
|
8436
|
+
|
|
8437
|
+
Four corpus labs came out one device short of their blueprint, always the
|
|
8438
|
+
last switch. The device was never missing. `hosts_across_switches` planned
|
|
8439
|
+
`SW1, SW2, SW3` and the file held `SW1`, `SW2` and `MultiLayerSwitch1` --
|
|
8440
|
+
and that third switch is the core of the topology: the router connects to
|
|
8441
|
+
it, and it connects to the other two. It had simply kept the donor's name.
|
|
8442
|
+
|
|
8443
|
+
Whether the rename lands depends on the donor. Applying the same plan
|
|
8444
|
+
against the saved floor-switch lab by hand produced `SW3` correctly; the donor the
|
|
8445
|
+
corpus picked has its own `MultiLayerSwitch` devices, and one of them was
|
|
8446
|
+
reused without being renamed.
|
|
8447
|
+
|
|
8448
|
+
So rather than chase the donor-specific path, the name is adopted here: a
|
|
8449
|
+
device the plan did not name, of the kind the plan is missing, and already
|
|
8450
|
+
cabled into the lab, takes the missing name. Cabled matters -- an idle
|
|
8451
|
+
spare parked off to the side is not doing the job the plan described, and
|
|
8452
|
+
handing it the name would produce a lab whose `SW3` connects to nothing.
|
|
8453
|
+
"""
|
|
8454
|
+
planned_names: list[str] = []
|
|
8455
|
+
for device in blueprint.get("devices", []):
|
|
8456
|
+
name = str(device.get("name") or "").strip()
|
|
8457
|
+
if name:
|
|
8458
|
+
planned_names.append(name)
|
|
8459
|
+
if not planned_names:
|
|
8460
|
+
return []
|
|
8461
|
+
|
|
8462
|
+
by_name: dict[str, ET.Element] = {}
|
|
8463
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
8464
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
8465
|
+
if name:
|
|
8466
|
+
by_name[name] = device
|
|
8467
|
+
|
|
8468
|
+
missing = [name for name in planned_names if name not in by_name]
|
|
8469
|
+
if not missing:
|
|
8470
|
+
return []
|
|
8471
|
+
|
|
8472
|
+
cabled = {name for pair in _link_device_pairs(root) for name in pair}
|
|
8473
|
+
spare = [
|
|
8474
|
+
name
|
|
8475
|
+
for name in by_name
|
|
8476
|
+
if name not in planned_names and name in cabled
|
|
8477
|
+
]
|
|
8478
|
+
if not spare:
|
|
8479
|
+
return []
|
|
8480
|
+
|
|
8481
|
+
def family(kind: str) -> str:
|
|
8482
|
+
lowered = kind.lower()
|
|
8483
|
+
if "switch" in lowered:
|
|
8484
|
+
return "switch"
|
|
8485
|
+
if "router" in lowered:
|
|
8486
|
+
return "router"
|
|
8487
|
+
return lowered
|
|
8488
|
+
|
|
8489
|
+
adopted: list[str] = []
|
|
8490
|
+
for wanted in missing:
|
|
8491
|
+
wanted_family = family(_device_kind_of_blueprint(blueprint, wanted))
|
|
8492
|
+
if not wanted_family:
|
|
8493
|
+
continue
|
|
8494
|
+
match = next(
|
|
8495
|
+
(
|
|
8496
|
+
name
|
|
8497
|
+
for name in spare
|
|
8498
|
+
if family((by_name[name].findtext("./ENGINE/TYPE") or "").strip()) == wanted_family
|
|
8499
|
+
),
|
|
8500
|
+
None,
|
|
8501
|
+
)
|
|
8502
|
+
if match is None:
|
|
8503
|
+
continue
|
|
8504
|
+
spare.remove(match)
|
|
8505
|
+
device = by_name.pop(match)
|
|
8506
|
+
name_node = device.find("./ENGINE/NAME")
|
|
8507
|
+
if name_node is None:
|
|
8508
|
+
continue
|
|
8509
|
+
name_node.text = wanted
|
|
8510
|
+
by_name[wanted] = device
|
|
8511
|
+
_align_hostname_with_name(device, wanted)
|
|
8512
|
+
adopted.append(f"{match} answers to {wanted}, the name the plan gave its job")
|
|
8513
|
+
return adopted
|
|
8514
|
+
|
|
8515
|
+
|
|
8516
|
+
def _report_unwired_devices(root: ET.Element, blueprint: dict[str, object]) -> list[str]:
|
|
8517
|
+
"""Name any requested device that arrived with no cable on it.
|
|
8518
|
+
|
|
8519
|
+
`1 router 1 switch 3 komputer ve 1 firewall qur` produces a lab holding an
|
|
8520
|
+
ASA, and Packet Tracer opens it, and the ASA is connected to nothing. The
|
|
8521
|
+
same is true of a requested patch panel. The device count is right, the file
|
|
8522
|
+
is valid, and the thing the prompt asked for does not participate in the
|
|
8523
|
+
network.
|
|
8524
|
+
|
|
8525
|
+
The link synthesiser cables the kinds in `HOST_DEVICE_KINDS`, each with a
|
|
8526
|
+
port name measured off real donor cables rather than taken from the device
|
|
8527
|
+
palette. Extending it to these kinds needs the same evidence, and the
|
|
8528
|
+
measurement says it is not there yet: across 150 labs, ASA cables use
|
|
8529
|
+
`Ethernet0/0` on a 5505 while the palette reports `GigabitEthernet1/1` for
|
|
8530
|
+
the 5506-X, and patch panels, bridges, repeaters and wired end devices carry
|
|
8531
|
+
no cable at all in any of them. Guessing one constant per kind is how a
|
|
8532
|
+
hardcoded port name gets into the file, which is the defect this project
|
|
8533
|
+
spent a long time removing.
|
|
8534
|
+
|
|
8535
|
+
So the gap is reported rather than papered over. A lab whose firewall is
|
|
8536
|
+
unplugged should say so.
|
|
8537
|
+
"""
|
|
8538
|
+
requested = {
|
|
8539
|
+
str(device.get("name") or "").strip(): str(device.get("type") or "").strip()
|
|
8540
|
+
for device in blueprint.get("devices", [])
|
|
8541
|
+
if str(device.get("name") or "").strip()
|
|
8542
|
+
}
|
|
8543
|
+
if not requested:
|
|
8544
|
+
return []
|
|
8545
|
+
present = {
|
|
8546
|
+
(device.findtext("./ENGINE/NAME") or "").strip()
|
|
8547
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
8548
|
+
}
|
|
8549
|
+
cabled = {name for pair in _link_device_pairs(root) for name in pair}
|
|
8550
|
+
# A lab with no cables at all is a wireless scenario, not a wiring failure.
|
|
8551
|
+
if not cabled:
|
|
8552
|
+
return []
|
|
8553
|
+
stranded = sorted(
|
|
8554
|
+
name for name in requested if name in present and name not in cabled
|
|
8555
|
+
)
|
|
8556
|
+
if not stranded:
|
|
8557
|
+
return []
|
|
8558
|
+
described = ", ".join(f"{name} ({requested[name]})" if requested[name] else name for name in stranded[:6])
|
|
8559
|
+
if len(stranded) > 6:
|
|
8560
|
+
described += f", and {len(stranded) - 6} more"
|
|
8561
|
+
return [f"WARNING: {len(stranded)} requested device(s) have no cable: {described}"]
|
|
8562
|
+
|
|
8563
|
+
|
|
8564
|
+
def _report_coherence(root: ET.Element) -> None:
|
|
8565
|
+
"""Say what the finished lab contradicts about itself.
|
|
8566
|
+
|
|
8567
|
+
A lab whose halves disagree opens perfectly well and passes every static
|
|
8568
|
+
check -- that is how a printer in VLAN 200 with a 192.168.110 address, a
|
|
8569
|
+
gateway no interface answered for, and a subinterface declared five times
|
|
8570
|
+
all reached a delivered file. The report never repairs and never fails the
|
|
8571
|
+
build: a checker that fixes what it finds stops being able to tell you
|
|
8572
|
+
whether the thing it checks is working.
|
|
8573
|
+
"""
|
|
8574
|
+
try:
|
|
8575
|
+
from lab_coherence import check_lab_coherence, summarise
|
|
8576
|
+
|
|
8577
|
+
contradictions = check_lab_coherence(root)
|
|
8578
|
+
if not contradictions:
|
|
8579
|
+
return
|
|
8580
|
+
print(f"WARNING: {summarise(contradictions)}")
|
|
8581
|
+
for finding in contradictions[:5]:
|
|
8582
|
+
print(f" {finding}")
|
|
8583
|
+
if len(contradictions) > 5:
|
|
8584
|
+
print(f" ... and {len(contradictions) - 5} more; run --coherence-report for all")
|
|
8585
|
+
except Exception as exc: # reporting must never take a working build down
|
|
8586
|
+
print(f"WARNING: coherence check did not run ({exc})")
|
|
8587
|
+
|
|
8588
|
+
|
|
8589
|
+
def _report_undelivered_devices(root: ET.Element, blueprint: dict[str, object]) -> list[str]:
|
|
8590
|
+
"""Name any device the plan asked for that is not in the written file.
|
|
8591
|
+
|
|
8592
|
+
Generation reported success for `2 router serial WAN, 2 switch, 8 komputer,
|
|
8593
|
+
1 server`: the blueprint held thirteen devices and the file held three, with
|
|
8594
|
+
the eight PCs and the server simply absent. Nothing said so. Silence is the
|
|
8595
|
+
worst of the three outcomes here -- a refusal explains itself, a working lab
|
|
8596
|
+
needs no explanation, and a lab quietly missing most of what was asked for
|
|
8597
|
+
looks like the tool worked.
|
|
8598
|
+
|
|
8599
|
+
Auditing the corpus found four labs short by one device each, always a
|
|
8600
|
+
switch, and always one that kept its donor name instead of being renamed.
|
|
8601
|
+
That number moves with donor selection, so this reports rather than refuses:
|
|
8602
|
+
failing labs that open and mostly serve the prompt would cost more than it
|
|
8603
|
+
saves. The point is that the gap is now visible on every run.
|
|
8604
|
+
"""
|
|
8605
|
+
planned = {
|
|
8606
|
+
str(device.get("name")).strip()
|
|
8607
|
+
for device in blueprint.get("devices", [])
|
|
8608
|
+
if str(device.get("name") or "").strip()
|
|
8609
|
+
}
|
|
8610
|
+
if not planned:
|
|
8611
|
+
return []
|
|
8612
|
+
present = {
|
|
8613
|
+
(device.findtext("./ENGINE/NAME") or "").strip()
|
|
8614
|
+
for device in root.findall(".//DEVICES/DEVICE")
|
|
8615
|
+
}
|
|
8616
|
+
missing = sorted(planned - present)
|
|
8617
|
+
if not missing:
|
|
8618
|
+
return []
|
|
8619
|
+
shown = ", ".join(missing[:8])
|
|
8620
|
+
if len(missing) > 8:
|
|
8621
|
+
shown += f", and {len(missing) - 8} more"
|
|
8622
|
+
return [
|
|
8623
|
+
f"WARNING: {len(missing)} of {len(planned)} planned device(s) are not in the file: {shown}"
|
|
8624
|
+
]
|
|
8625
|
+
|
|
8626
|
+
|
|
8627
|
+
def _compact_stray_devices(root: ET.Element) -> list[str]:
|
|
8628
|
+
"""Pull cable-less donor leftovers back beside the lab that was asked for.
|
|
8629
|
+
|
|
8630
|
+
Every corpus lab measured between 2440 and 2550 units wide, including
|
|
8631
|
+
`minimal`, which is one router, one switch and three PCs. Those five sit in
|
|
8632
|
+
340 units; the width came from two `Power Distribution Device` nodes still
|
|
8633
|
+
at their donor coordinates, x=2620 and x=2730, roughly 2100 units to the
|
|
8634
|
+
right of anything cabled. Packet Tracer shows about 1500 units at the
|
|
8635
|
+
default zoom, so the lab opened on an empty patch of canvas with the real
|
|
8636
|
+
topology off to the left.
|
|
8637
|
+
|
|
8638
|
+
They are not pruned here. They came with the donor, nothing is wired to
|
|
8639
|
+
them, and removing devices is the kind of change that has broken donor
|
|
8640
|
+
coherence before. Moving them is enough: the canvas shrinks to the lab.
|
|
8641
|
+
|
|
8642
|
+
A lab with fewer than two cabled devices has no bounding box worth
|
|
8643
|
+
speaking of -- the wireless scenarios have no cables at all -- so those are
|
|
8644
|
+
left exactly as they are.
|
|
8645
|
+
"""
|
|
8646
|
+
parked = _parked_names(root)
|
|
8647
|
+
positions: dict[str, tuple[ET.Element, ET.Element, float, float]] = {}
|
|
8648
|
+
for device in root.findall(".//DEVICES/DEVICE"):
|
|
8649
|
+
name = (device.findtext("./ENGINE/NAME") or "").strip()
|
|
8650
|
+
x_node = device.find("./WORKSPACE/LOGICAL/X")
|
|
8651
|
+
y_node = device.find("./WORKSPACE/LOGICAL/Y")
|
|
8652
|
+
if not name or x_node is None or y_node is None:
|
|
8653
|
+
continue
|
|
8654
|
+
try:
|
|
8655
|
+
x = float((x_node.text or "").strip())
|
|
8656
|
+
y = float((y_node.text or "").strip())
|
|
8657
|
+
except ValueError:
|
|
8658
|
+
continue
|
|
8659
|
+
if name in parked:
|
|
8660
|
+
continue
|
|
8661
|
+
positions[name] = (x_node, y_node, x, y)
|
|
8662
|
+
|
|
8663
|
+
cabled = {name for pair in _link_device_pairs(root) for name in pair}
|
|
8664
|
+
anchored = [positions[name] for name in cabled if name in positions]
|
|
8665
|
+
if len(anchored) < 2:
|
|
8666
|
+
return []
|
|
8667
|
+
|
|
8668
|
+
right = max(entry[2] for entry in anchored)
|
|
8669
|
+
top = min(entry[3] for entry in anchored)
|
|
8670
|
+
bottom = max(entry[3] for entry in anchored)
|
|
8671
|
+
|
|
8672
|
+
strays = sorted(
|
|
8673
|
+
(name for name, entry in positions.items() if name not in cabled and entry[2] > right + STRAY_DEVICE_MARGIN),
|
|
8674
|
+
key=lambda name: positions[name][2],
|
|
8675
|
+
)
|
|
8676
|
+
moved: list[str] = []
|
|
8677
|
+
for index, name in enumerate(strays):
|
|
8678
|
+
x_node, y_node, _, _ = positions[name]
|
|
8679
|
+
x = int(right + 140 + (index % 2) * 120)
|
|
8680
|
+
y = int(top + (index // 2) * 110)
|
|
8681
|
+
if y > bottom:
|
|
8682
|
+
y = int(bottom)
|
|
8683
|
+
x_node.text = str(x)
|
|
8684
|
+
y_node.text = str(y)
|
|
8685
|
+
moved.append(f"{name}: pulled beside the lab at {x},{y}")
|
|
5061
8686
|
return moved
|
|
5062
8687
|
|
|
5063
8688
|
|
|
@@ -5086,6 +8711,7 @@ def _separate_overlapping_devices(root: ET.Element) -> list[str]:
|
|
|
5086
8711
|
for other_x, other_y in placed
|
|
5087
8712
|
)
|
|
5088
8713
|
|
|
8714
|
+
parked = _parked_names(root)
|
|
5089
8715
|
placed: list[tuple[float, float]] = []
|
|
5090
8716
|
moved: list[str] = []
|
|
5091
8717
|
for device in root.findall(".//DEVICES/DEVICE"):
|
|
@@ -5098,7 +8724,7 @@ def _separate_overlapping_devices(root: ET.Element) -> list[str]:
|
|
|
5098
8724
|
y = float((y_node.text or "").strip())
|
|
5099
8725
|
except ValueError:
|
|
5100
8726
|
continue
|
|
5101
|
-
if
|
|
8727
|
+
if (device.findtext("./ENGINE/NAME") or "").strip() in parked:
|
|
5102
8728
|
continue
|
|
5103
8729
|
if not too_close(x, y, placed):
|
|
5104
8730
|
placed.append((x, y))
|
|
@@ -5278,6 +8904,121 @@ def _match_link_port_families(root: ET.Element) -> list[str]:
|
|
|
5278
8904
|
return changed
|
|
5279
8905
|
|
|
5280
8906
|
|
|
8907
|
+
def _ordered_port_media(device: ET.Element) -> dict[str, list[str]]:
|
|
8908
|
+
"""This device's sockets, in Packet Tracer's own order, grouped by kind.
|
|
8909
|
+
|
|
8910
|
+
The PORT nodes carry a media type and no name, and the document order is
|
|
8911
|
+
the order Packet Tracer numbers them in: an IE-9320's twenty-eight nodes are
|
|
8912
|
+
`GigabitEthernet1/0/1` .. `1/0/28`, a 2960's twenty-six are
|
|
8913
|
+
`FastEthernet0/1` .. `0/24` followed by `GigabitEthernet0/1` .. `0/2`.
|
|
8914
|
+
Verified against the live device listing for both.
|
|
8915
|
+
|
|
8916
|
+
Zipping the nodes against `donor_interface_names` looks like the obvious
|
|
8917
|
+
way to get names and does not work: that returns 29 entries for a
|
|
8918
|
+
twenty-eight port switch, because a configuration also mentions interfaces
|
|
8919
|
+
the hardware does not have, and the pairing silently slips. Grouping by kind
|
|
8920
|
+
and counting within the kind survives that.
|
|
8921
|
+
"""
|
|
8922
|
+
grouped: dict[str, list[str]] = {}
|
|
8923
|
+
for port in device.findall(".//PORT"):
|
|
8924
|
+
media = (port.findtext("./TYPE") or "").strip()
|
|
8925
|
+
if "FastEthernet" in media:
|
|
8926
|
+
grouped.setdefault("FastEthernet", []).append(media)
|
|
8927
|
+
elif "GigabitEthernet" in media:
|
|
8928
|
+
grouped.setdefault("GigabitEthernet", []).append(media)
|
|
8929
|
+
return grouped
|
|
8930
|
+
|
|
8931
|
+
|
|
8932
|
+
def _port_is_fiber(device: ET.Element, port_name: str) -> bool:
|
|
8933
|
+
"""Whether that named socket takes fibre rather than copper."""
|
|
8934
|
+
name = (port_name or "").strip()
|
|
8935
|
+
for kind in ("GigabitEthernet", "FastEthernet"):
|
|
8936
|
+
if not name.startswith(kind):
|
|
8937
|
+
continue
|
|
8938
|
+
tail = name[len(kind):].strip()
|
|
8939
|
+
if "/" not in tail:
|
|
8940
|
+
return False
|
|
8941
|
+
try:
|
|
8942
|
+
index = int(tail.rsplit("/", 1)[-1])
|
|
8943
|
+
except ValueError:
|
|
8944
|
+
return False
|
|
8945
|
+
media = _ordered_port_media(device).get(kind, [])
|
|
8946
|
+
if 1 <= index <= len(media):
|
|
8947
|
+
return "Fiber" in media[index - 1]
|
|
8948
|
+
return False
|
|
8949
|
+
return False
|
|
8950
|
+
|
|
8951
|
+
|
|
8952
|
+
def _move_copper_cables_off_fibre_ports(root: ET.Element) -> list[str]:
|
|
8953
|
+
"""Keep a copper cable out of a socket that only takes fibre.
|
|
8954
|
+
|
|
8955
|
+
Packet Tracer does not refuse such a file. It opens it and silently drops
|
|
8956
|
+
the cable. Measured on a three-switch lab: sixteen links in the file,
|
|
8957
|
+
thirteen in the running topology, and the three missing ones all landed on
|
|
8958
|
+
`GigabitEthernet1/0/1` or `1/0/2` of an IE-9320 -- that switch's only two
|
|
8959
|
+
fibre ports. One of them was the router uplink, so nothing could reach the
|
|
8960
|
+
DHCP pool and every host fell back to an APIPA address. The open check
|
|
8961
|
+
reported `opened` throughout.
|
|
8962
|
+
|
|
8963
|
+
That is worth stating plainly: a lab opening is not the same as Packet
|
|
8964
|
+
Tracer having loaded the topology that was written.
|
|
8965
|
+
|
|
8966
|
+
The cable is moved rather than the media changed. A copper cable between
|
|
8967
|
+
two switches is what the topology asked for; the fibre socket is an
|
|
8968
|
+
accident of which port was free first.
|
|
8969
|
+
"""
|
|
8970
|
+
device_order = list(root.findall(".//DEVICES/DEVICE"))
|
|
8971
|
+
device_by_ref: dict[str, ET.Element] = {}
|
|
8972
|
+
for index, device in enumerate(device_order):
|
|
8973
|
+
ref = (device.findtext("./ENGINE/SAVE_REF_ID") or "").strip()
|
|
8974
|
+
if ref:
|
|
8975
|
+
device_by_ref[ref] = device
|
|
8976
|
+
device_by_ref.setdefault(str(index), device)
|
|
8977
|
+
|
|
8978
|
+
taken: set[tuple[str, str]] = set()
|
|
8979
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
8980
|
+
cable = link.find("./CABLE")
|
|
8981
|
+
if cable is None:
|
|
8982
|
+
continue
|
|
8983
|
+
refs = [(cable.findtext("FROM") or "").strip(), (cable.findtext("TO") or "").strip()]
|
|
8984
|
+
for ref, node in zip(refs, cable.findall("PORT")):
|
|
8985
|
+
taken.add((ref, (node.text or "").strip()))
|
|
8986
|
+
|
|
8987
|
+
moved: list[str] = []
|
|
8988
|
+
for link in root.findall(".//LINKS/LINK"):
|
|
8989
|
+
cable = link.find("./CABLE")
|
|
8990
|
+
if cable is None or (link.findtext("TYPE") or "").strip() != "eCopper":
|
|
8991
|
+
continue
|
|
8992
|
+
refs = [(cable.findtext("FROM") or "").strip(), (cable.findtext("TO") or "").strip()]
|
|
8993
|
+
nodes = cable.findall("PORT")
|
|
8994
|
+
if len(nodes) < 2:
|
|
8995
|
+
continue
|
|
8996
|
+
for ref, node in zip(refs, nodes):
|
|
8997
|
+
device = device_by_ref.get(ref)
|
|
8998
|
+
name = (node.text or "").strip()
|
|
8999
|
+
if device is None or not name or not _port_is_fiber(device, name):
|
|
9000
|
+
continue
|
|
9001
|
+
kind = "GigabitEthernet" if name.startswith("GigabitEthernet") else "FastEthernet"
|
|
9002
|
+
replacement = ""
|
|
9003
|
+
for candidate in donor_interface_names(device):
|
|
9004
|
+
# Same kind of interface, so a gigabit uplink stays gigabit.
|
|
9005
|
+
if not candidate.startswith(kind) or candidate == name:
|
|
9006
|
+
continue
|
|
9007
|
+
if (ref, candidate) in taken:
|
|
9008
|
+
continue
|
|
9009
|
+
if not port_exists(device, candidate) or _port_is_fiber(device, candidate):
|
|
9010
|
+
continue
|
|
9011
|
+
replacement = candidate
|
|
9012
|
+
break
|
|
9013
|
+
if not replacement:
|
|
9014
|
+
continue
|
|
9015
|
+
taken.discard((ref, name))
|
|
9016
|
+
taken.add((ref, replacement))
|
|
9017
|
+
node.text = replacement
|
|
9018
|
+
moved.append(f"{name} -> {replacement} (copper cable off a fibre socket)")
|
|
9019
|
+
return moved
|
|
9020
|
+
|
|
9021
|
+
|
|
5281
9022
|
def _reconcile_cable_media(root: ET.Element) -> list[str]:
|
|
5282
9023
|
"""Make each cable's family agree with the interfaces it ends on.
|
|
5283
9024
|
|
|
@@ -5336,7 +9077,7 @@ def _declare_serial_dce_ends(root: ET.Element) -> list[str]:
|
|
|
5336
9077
|
Adding `DCEDEV` and `DCEPORT` to that refused file opens it.
|
|
5337
9078
|
|
|
5338
9079
|
The `FROM` end is named as DCE, which is what the donors do: every serial
|
|
5339
|
-
link in
|
|
9080
|
+
link in the saved serial-WAN lab names its `FROM` device and port.
|
|
5340
9081
|
"""
|
|
5341
9082
|
changed: list[str] = []
|
|
5342
9083
|
for link in root.findall(".//LINKS/LINK"):
|
|
@@ -5513,6 +9254,7 @@ def _repair_invalid_link_ports(root: ET.Element) -> list[str]:
|
|
|
5513
9254
|
|
|
5514
9255
|
repairs: list[str] = []
|
|
5515
9256
|
seen: set[tuple[str, str]] = set()
|
|
9257
|
+
doomed: list[ET.Element] = []
|
|
5516
9258
|
for link in root.findall(".//LINKS/LINK"):
|
|
5517
9259
|
cable = link.find("./CABLE")
|
|
5518
9260
|
if cable is None:
|
|
@@ -5537,6 +9279,14 @@ def _repair_invalid_link_ports(root: ET.Element) -> list[str]:
|
|
|
5537
9279
|
reason = "interface does not exist"
|
|
5538
9280
|
name = device.findtext("./ENGINE/NAME") or ref
|
|
5539
9281
|
candidates = donor_interface_names(device)
|
|
9282
|
+
if not candidates:
|
|
9283
|
+
# A home router writes no interfaces into its configuration, so
|
|
9284
|
+
# the probe list below -- all of it slotted -- offered nothing
|
|
9285
|
+
# its sockets are called. Tightening `port_exists` to reject
|
|
9286
|
+
# `FastEthernet0/1` on a device whose ports are `Ethernet 1` ..
|
|
9287
|
+
# `4` would then have removed the cable instead of renaming it,
|
|
9288
|
+
# which is why the two changes belong together.
|
|
9289
|
+
candidates = wireless_router_port_names(device)
|
|
5540
9290
|
if not candidates:
|
|
5541
9291
|
# Some devices carry no running config to read interfaces from,
|
|
5542
9292
|
# and a repair with nothing to offer leaves the fault in place:
|
|
@@ -5570,6 +9320,19 @@ def _repair_invalid_link_ports(root: ET.Element) -> list[str]:
|
|
|
5570
9320
|
None,
|
|
5571
9321
|
)
|
|
5572
9322
|
if replacement is None:
|
|
9323
|
+
if reason == "interface does not exist":
|
|
9324
|
+
# Reporting it and leaving it is the one outcome that
|
|
9325
|
+
# cannot work: this pass's own subject is that an invalid
|
|
9326
|
+
# interface name stops Packet Tracer opening the file at
|
|
9327
|
+
# all. Measured on "1 wireless router 2 laptop qur": the
|
|
9328
|
+
# donor's laptops carry `eHostWirelessN` and `eBluetooth`
|
|
9329
|
+
# and no copper socket, the plan cabled them on
|
|
9330
|
+
# `FastEthernet0`, no relocation existed, and the lab was
|
|
9331
|
+
# refused. Removing the cable is also what the lab means --
|
|
9332
|
+
# a laptop with only a wireless card joins over the air.
|
|
9333
|
+
doomed.append(link)
|
|
9334
|
+
repairs.append(f"{name}: cable on {port} removed -- the device has no such socket")
|
|
9335
|
+
break
|
|
5573
9336
|
repairs.append(f"{name}: {port} unusable ({reason}) and no free interface was available")
|
|
5574
9337
|
seen.add((ref, port))
|
|
5575
9338
|
continue
|
|
@@ -5584,6 +9347,12 @@ def _repair_invalid_link_ports(root: ET.Element) -> list[str]:
|
|
|
5584
9347
|
seen.add((ref, replacement))
|
|
5585
9348
|
port_node.text = replacement
|
|
5586
9349
|
repairs.append(f"{name}: {port} -> {replacement} ({reason})")
|
|
9350
|
+
|
|
9351
|
+
links_parent = root.find(".//LINKS")
|
|
9352
|
+
if links_parent is not None:
|
|
9353
|
+
for link in doomed:
|
|
9354
|
+
if link in list(links_parent):
|
|
9355
|
+
links_parent.remove(link)
|
|
5587
9356
|
return repairs
|
|
5588
9357
|
|
|
5589
9358
|
|
|
@@ -5729,9 +9498,9 @@ def _align_donor_groups_to_targets(
|
|
|
5729
9498
|
refusal: the donor "does not contain that device-to-device link", when it did
|
|
5730
9499
|
contain it, on different switches.
|
|
5731
9500
|
|
|
5732
|
-
|
|
5733
|
-
SW2 ->
|
|
5734
|
-
ordering gives SW1/SW2/SW3 ->
|
|
9501
|
+
The saved floor-switch lab is Router-Floor3-Floor2-Floor1. Name order gave
|
|
9502
|
+
SW2 -> Floor 1, so `SW1 <-> SW2` mapped to a pair with no link. Distance
|
|
9503
|
+
ordering gives SW1/SW2/SW3 -> Floor 3/2/1, which follows the real chain.
|
|
5735
9504
|
"""
|
|
5736
9505
|
if len(donor_groups) < 2 or not target_groups:
|
|
5737
9506
|
return donor_groups
|
|
@@ -5766,6 +9535,14 @@ def _align_donor_groups_to_targets(
|
|
|
5766
9535
|
),
|
|
5767
9536
|
)
|
|
5768
9537
|
]
|
|
9538
|
+
# Preferring the switch model the plan named was tried here, between hop
|
|
9539
|
+
# distance and name, so it could only choose between switches the same
|
|
9540
|
+
# distance from the router. It did what it was meant to -- `voice_devices`
|
|
9541
|
+
# came out on the `2960-24TT` the plan asked for instead of an `IE-9320` --
|
|
9542
|
+
# and Packet Tracer then refused that lab, taking the corpus from 32 open
|
|
9543
|
+
# to 31. The whole gain across the corpus was one switch, 10 matching to
|
|
9544
|
+
# 11 of 53. A case that opens outranks a case that merely carries the right
|
|
9545
|
+
# model, so it is not here.
|
|
5769
9546
|
ranked_donors = sorted(
|
|
5770
9547
|
donor_groups,
|
|
5771
9548
|
key=lambda group: (
|
|
@@ -5815,6 +9592,17 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
5815
9592
|
donor_groups = _collect_donor_groups(donor_root)
|
|
5816
9593
|
target_groups = _target_groups_from_blueprint(plan, blueprint)
|
|
5817
9594
|
adapted_plan = copy.deepcopy(plan)
|
|
9595
|
+
# The donor-shaping operations are rebuilt from scratch below, so the
|
|
9596
|
+
# list is cleared -- but it also holds what the *user* asked for, and
|
|
9597
|
+
# `cli R1: ...` was being thrown away with it. The parser produced the
|
|
9598
|
+
# operation correctly and the plan reaching the file contained none, so
|
|
9599
|
+
# arbitrary IOS never arrived. Held aside here and appended after the
|
|
9600
|
+
# renames, since it addresses devices by their final name.
|
|
9601
|
+
carried_operations = [
|
|
9602
|
+
operation
|
|
9603
|
+
for operation in adapted_plan.edit_operations
|
|
9604
|
+
if operation.get("op") == "apply_cli"
|
|
9605
|
+
]
|
|
5818
9606
|
adapted_plan.edit_operations = []
|
|
5819
9607
|
donor_devices = inventory_devices(donor_root)
|
|
5820
9608
|
donor_links = inventory_links(donor_root)
|
|
@@ -5852,6 +9640,14 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
5852
9640
|
for member in seed_members
|
|
5853
9641
|
if _device_kind(member) == kind
|
|
5854
9642
|
]
|
|
9643
|
+
# Truncated on purpose. A seed group with three PCs cannot fill
|
|
9644
|
+
# a target that wants eight, and cloning the same member twice
|
|
9645
|
+
# to make up the difference was measured and reverted: it took
|
|
9646
|
+
# the 100-PC lab from 140 devices to 65 and from 13 undelivered
|
|
9647
|
+
# devices to 88. Whatever consumes these names downstream
|
|
9648
|
+
# cannot have one source duplicated twice in a batch, so the
|
|
9649
|
+
# shortfall belongs in the donor -- a group with more hosts --
|
|
9650
|
+
# rather than here.
|
|
5855
9651
|
seed_hosts.extend(matching[:count])
|
|
5856
9652
|
# Emitted after the rename/prune pass, not here. The verified
|
|
5857
9653
|
# experiment duplicated a group *after* all other mutations, from a
|
|
@@ -5876,7 +9672,11 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
5876
9672
|
# which was read before duplication. Register them so the rename and
|
|
5877
9673
|
# link-reuse logic downstream sees the copies as donor-provided —
|
|
5878
9674
|
# otherwise it asks to *create* a host link, which is refused.
|
|
5879
|
-
|
|
9675
|
+
# `enumerate`, not `.index`: a seed member cloned twice appears
|
|
9676
|
+
# twice in `seed_hosts`, and looking the name up returns the first
|
|
9677
|
+
# position both times -- so the second clone would be registered
|
|
9678
|
+
# under the first one's name and arrive with no cable.
|
|
9679
|
+
for position, host in enumerate(seed_hosts):
|
|
5880
9680
|
original = next(
|
|
5881
9681
|
(
|
|
5882
9682
|
link
|
|
@@ -5889,7 +9689,7 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
5889
9689
|
continue
|
|
5890
9690
|
cloned = dict(original)
|
|
5891
9691
|
cloned["from"] = duplicate_name
|
|
5892
|
-
cloned["to"] = f"{duplicate_name}-H{
|
|
9692
|
+
cloned["to"] = f"{duplicate_name}-H{position + 1}"
|
|
5893
9693
|
donor_links.append(cloned)
|
|
5894
9694
|
members_by_type: dict[str, list[dict[str, object]]] = {}
|
|
5895
9695
|
for member in cloned_members:
|
|
@@ -6261,6 +10061,26 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
6261
10061
|
int(target.get("y", 0)),
|
|
6262
10062
|
)
|
|
6263
10063
|
|
|
10064
|
+
# A surplus donor device is only surplus once every planned device of its
|
|
10065
|
+
# kind has been filled. Measured on "2 switch, 1 router, 6 PC": the donor
|
|
10066
|
+
# holds eight PCs across three switches, the plan keeps two switches, and
|
|
10067
|
+
# PC4 and PC5 went out with the switch they hung off -- while PC7 and PC8,
|
|
10068
|
+
# cabled to a switch that survived, were deleted as spares. Six were asked
|
|
10069
|
+
# for and four arrived, out of eight that were there.
|
|
10070
|
+
#
|
|
10071
|
+
# `_adopt_planned_names` already gives a missing planned name to a cabled
|
|
10072
|
+
# device that has one to spare. It ran after these were deleted, so it had
|
|
10073
|
+
# nothing to work with. Keeping them is all it needs.
|
|
10074
|
+
planned_kinds = {
|
|
10075
|
+
str(device.get("name") or ""): _device_kind(device)
|
|
10076
|
+
for device in blueprint.get("devices", [])
|
|
10077
|
+
}
|
|
10078
|
+
claimed = set(rename_map.values()) | set(kept_devices)
|
|
10079
|
+
shortfall: dict[str, int] = {}
|
|
10080
|
+
for planned_name, planned_kind in planned_kinds.items():
|
|
10081
|
+
if planned_name and planned_name not in claimed:
|
|
10082
|
+
shortfall[planned_kind] = shortfall.get(planned_kind, 0) + 1
|
|
10083
|
+
|
|
6264
10084
|
spare_name_counts: dict[tuple[str | None, str], int] = {}
|
|
6265
10085
|
for device_type, candidates in spare_candidates_by_type.items():
|
|
6266
10086
|
for candidate in candidates:
|
|
@@ -6269,6 +10089,21 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
6269
10089
|
count_key = (str(group_name) if group_name is not None else None, device_type)
|
|
6270
10090
|
spare_name_counts[count_key] = spare_name_counts.get(count_key, 0) + 1
|
|
6271
10091
|
spare_index = spare_name_counts[count_key]
|
|
10092
|
+
if shortfall.get(device_type, 0) > 0:
|
|
10093
|
+
# Held back for the planned name it is about to be given, but
|
|
10094
|
+
# renamed first: leaving the donor's name on it collided with a
|
|
10095
|
+
# planned name already handed to another device, and the lab
|
|
10096
|
+
# came out with two PC1s and two cables on one port.
|
|
10097
|
+
shortfall[device_type] -= 1
|
|
10098
|
+
holding = f"SPARE-{device_type.upper()}{spare_index}"
|
|
10099
|
+
donor_name = str(donor_member["name"])
|
|
10100
|
+
kept_devices.add(donor_name)
|
|
10101
|
+
rename_map[donor_name] = holding
|
|
10102
|
+
if donor_name != holding:
|
|
10103
|
+
adapted_plan.edit_operations.append(
|
|
10104
|
+
{"op": "rename_device", "device": donor_name, "new_name": holding}
|
|
10105
|
+
)
|
|
10106
|
+
continue
|
|
6272
10107
|
spare_name = (
|
|
6273
10108
|
f"{group_name}-SPARE-{device_type.upper()}{spare_index}"
|
|
6274
10109
|
if group_name
|
|
@@ -6664,6 +10499,9 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
6664
10499
|
adapted_plan.edit_operations.append({"op": "prune_device", "device": final_name})
|
|
6665
10500
|
already_pruned.add(final_name)
|
|
6666
10501
|
|
|
10502
|
+
# Every switch port a clone has been given, so a port planned for one is
|
|
10503
|
+
# not handed to another.
|
|
10504
|
+
clone_ports_taken: set[tuple[str, str]] = set()
|
|
6667
10505
|
for clone in pending_host_clones:
|
|
6668
10506
|
source_final = rename_map.get(str(clone["source"]), str(clone["source"]))
|
|
6669
10507
|
if source_final in parked_set:
|
|
@@ -6699,15 +10537,21 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
6699
10537
|
planned_link = link
|
|
6700
10538
|
break
|
|
6701
10539
|
|
|
6702
|
-
if
|
|
10540
|
+
# A planned port is only good if no earlier clone has taken it. Two
|
|
10541
|
+
# clones can be planned onto the same interface, and trusting the plan
|
|
10542
|
+
# without checking put three pairs of hosts on one port once the donor
|
|
10543
|
+
# grew enough groups for a hundred-host lab to reach this path.
|
|
10544
|
+
if planned_port and (switch_name, planned_port) not in clone_ports_taken:
|
|
6703
10545
|
switch_port = planned_port
|
|
6704
10546
|
else:
|
|
10547
|
+
planned_link = None
|
|
6705
10548
|
used_ports = {
|
|
6706
10549
|
str(link[end]["port"])
|
|
6707
10550
|
for link in blueprint.get("links", [])
|
|
6708
10551
|
for end in ("a", "b")
|
|
6709
10552
|
if str(link[end]["dev"]) == switch_name
|
|
6710
10553
|
}
|
|
10554
|
+
used_ports |= {port for switch, port in clone_ports_taken if switch == switch_name}
|
|
6711
10555
|
switch_port = next(
|
|
6712
10556
|
(
|
|
6713
10557
|
candidate
|
|
@@ -6726,6 +10570,7 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
6726
10570
|
if gap not in adapted_plan.blocking_gaps:
|
|
6727
10571
|
adapted_plan.blocking_gaps.append(gap)
|
|
6728
10572
|
continue
|
|
10573
|
+
clone_ports_taken.add((switch_name, switch_port))
|
|
6729
10574
|
adapted_plan.edit_operations.append(
|
|
6730
10575
|
{
|
|
6731
10576
|
"op": "duplicate_host",
|
|
@@ -6772,6 +10617,9 @@ def _build_donor_prune_plan_for_donor(plan: IntentPlan, blueprint: dict[str, obj
|
|
|
6772
10617
|
mutation_groups=mutation_groups,
|
|
6773
10618
|
layout_strategy="donor_park_clean",
|
|
6774
10619
|
)
|
|
10620
|
+
# Last, so every rename the plan makes has already happened and the
|
|
10621
|
+
# device the user named exists under that name.
|
|
10622
|
+
adapted_plan.edit_operations.extend(carried_operations)
|
|
6775
10623
|
return adapted_plan, archetype_plan
|
|
6776
10624
|
|
|
6777
10625
|
|
|
@@ -6843,7 +10691,7 @@ def _build_donor_prune_plan(
|
|
|
6843
10691
|
# one pays for this pass.
|
|
6844
10692
|
#
|
|
6845
10693
|
# This pass was held out for a while: the donor it reaches on this machine
|
|
6846
|
-
# is
|
|
10694
|
+
# is the saved serial-WAN lab, and Packet Tracer refused the lab built
|
|
6847
10695
|
# from it -- an unwired device costs one device, a refused file costs the
|
|
6848
10696
|
# whole lab. That refusal is fixed now. It was a serial cable with no DCE
|
|
6849
10697
|
# end declared, plus two port names taken from an assumed switch model.
|
|
@@ -7576,6 +11424,7 @@ def generate_from_blueprint(blueprint_path: Path, output_path: Path, xml_out_pat
|
|
|
7576
11424
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
7577
11425
|
output_path.write_bytes(pkt_bytes)
|
|
7578
11426
|
print(f"PKT file created: {output_path}")
|
|
11427
|
+
_report_coherence(root)
|
|
7579
11428
|
print(f"XML bytes: {len(xml_bytes)}")
|
|
7580
11429
|
print(f"PKT bytes: {len(pkt_bytes)}")
|
|
7581
11430
|
|
|
@@ -7657,6 +11506,15 @@ def generate_from_prompt(
|
|
|
7657
11506
|
blueprint_out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
7658
11507
|
blueprint_out_path.write_text(json.dumps(blueprint_plan, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
7659
11508
|
raise PlanningError("Scenario is not generate-ready in safe-open mode; generation was skipped.", prepared_plan)
|
|
11509
|
+
# What the prompt asked things to be called, kept before donor adaptation
|
|
11510
|
+
# gets to rewrite it. The chosen donor substitutes its own device names into
|
|
11511
|
+
# the blueprint -- `SW3` became `MultiLayerSwitch1` -- and the file then
|
|
11512
|
+
# honours the rewritten plan, so a lab asked for `SW3` ships without one.
|
|
11513
|
+
# Both checks below measure against the request, not against what the donor
|
|
11514
|
+
# turned it into.
|
|
11515
|
+
requested_devices = {
|
|
11516
|
+
"devices": [dict(device) for device in blueprint.get("devices", [])]
|
|
11517
|
+
}
|
|
7660
11518
|
try:
|
|
7661
11519
|
adapted_plan, donor_archetype = _build_donor_prune_plan(prepared_plan, blueprint, resolved_donor_roots)
|
|
7662
11520
|
except PlanningError as exc:
|
|
@@ -7686,6 +11544,10 @@ def generate_from_prompt(
|
|
|
7686
11544
|
port_repairs = _repair_invalid_link_ports(root)
|
|
7687
11545
|
mac_repairs = _assign_unique_macs(root)
|
|
7688
11546
|
_match_link_port_families(root)
|
|
11547
|
+
# After the families agree and before the addresses are handed out: a
|
|
11548
|
+
# copper cable in a fibre socket is dropped by Packet Tracer on load,
|
|
11549
|
+
# silently, in a file that still opens.
|
|
11550
|
+
_move_copper_cables_off_fibre_ports(root)
|
|
7689
11551
|
_assign_unique_interface_addresses(root)
|
|
7690
11552
|
_assign_unique_switch_management_ips(root)
|
|
7691
11553
|
media_notes = _reconcile_cable_media(root)
|
|
@@ -7693,18 +11555,110 @@ def generate_from_prompt(
|
|
|
7693
11555
|
# serial: a cable demoted to copper must lose its clocking end, and one
|
|
7694
11556
|
# promoted to serial must gain one.
|
|
7695
11557
|
_declare_serial_dce_ends(root)
|
|
11558
|
+
# First of the configuration repairs: a block for hardware the device does
|
|
11559
|
+
# not have carries the donor's whole address plan into every pass that
|
|
11560
|
+
# reads the router's networks.
|
|
11561
|
+
_retarget_config_to_real_interface_names(root)
|
|
11562
|
+
absent_notes = _drop_config_for_absent_interfaces(root)
|
|
7696
11563
|
trunk_notes = _trunk_uplinks_in_file(root)
|
|
11564
|
+
trunk_notes += absent_notes
|
|
11565
|
+
# Before the access-VLAN pass, which would otherwise strip the tagging:
|
|
11566
|
+
# a switch port facing router subinterfaces has to be a trunk. The
|
|
11567
|
+
# subinterfaces move to the cabled port first, or there is nothing there
|
|
11568
|
+
# for the trunk to carry.
|
|
11569
|
+
trunk_notes += _move_subinterfaces_to_the_cabled_port(root)
|
|
11570
|
+
trunk_notes += _trunk_router_on_a_stick(root)
|
|
7697
11571
|
vlan_notes = _align_router_access_vlan(root)
|
|
11572
|
+
# After the router's own port is settled: a host whose address belongs to
|
|
11573
|
+
# one VLAN and whose port sits in another cannot reach its own subnet.
|
|
11574
|
+
vlan_notes += _align_host_vlans_to_addresses(root)
|
|
7698
11575
|
gateway_repairs = _align_router_gateway(root)
|
|
11576
|
+
# Last, because `_align_router_gateway` writes the gateway onto the
|
|
11577
|
+
# physical cabled interface -- correct for an access link, wrong for a
|
|
11578
|
+
# trunk, where the address has to sit on the subinterface for its VLAN.
|
|
11579
|
+
trunk_notes += _move_subinterfaces_to_the_cabled_port(root)
|
|
11580
|
+
# A learned sticky MAC belongs to the donor's device, not to the one now
|
|
11581
|
+
# plugged in, and `restrict` drops every frame that does not match it.
|
|
11582
|
+
trunk_notes += _drop_inherited_sticky_macs(root)
|
|
11583
|
+
# Both ends of every trunk must name the same native VLAN, or spanning
|
|
11584
|
+
# tree blocks the port and the cable carries nothing.
|
|
11585
|
+
trunk_notes += _match_trunk_native_vlans(root)
|
|
11586
|
+
# After every trunk is settled: port security on a trunk cuts the switch
|
|
11587
|
+
# behind it off entirely.
|
|
11588
|
+
trunk_notes += _drop_port_security_from_trunks(root)
|
|
11589
|
+
# After the trunks are settled: a channel-group naming ports the cable
|
|
11590
|
+
# never joined takes the switch behind it off the network.
|
|
11591
|
+
trunk_notes += _align_etherchannels_with_cabling(root)
|
|
7699
11592
|
_stamp_target_version(root)
|
|
7700
11593
|
unexpected_workspace_issues = _unexpected_workspace_issues(donor_root, root)
|
|
7701
11594
|
if unexpected_workspace_issues:
|
|
7702
11595
|
raise ValueError("; ".join(unexpected_workspace_issues))
|
|
7703
11596
|
validate_donor_coherence(donor_root, root)
|
|
7704
11597
|
_align_dhcp_pools_with_interfaces(root)
|
|
11598
|
+
# After the pools point at real networks: a pool with no client is not
|
|
11599
|
+
# DHCP, and the segmented path never emitted the client half.
|
|
11600
|
+
# Before the clients are switched over: a VLAN with hosts and no gateway
|
|
11601
|
+
# cannot serve any of them.
|
|
11602
|
+
# A port with no VLAN sits in VLAN 1, which the plan never gives a
|
|
11603
|
+
# gateway, so the host on it is isolated whatever else is right.
|
|
11604
|
+
_place_hosts_in_a_vlan(root)
|
|
11605
|
+
_serve_every_populated_vlan(root)
|
|
11606
|
+
# Again, now that new VLANs exist: the router-facing trunk lists the
|
|
11607
|
+
# VLANs it may carry, and it was written before those VLANs were
|
|
11608
|
+
# created -- so their hosts had a gateway the trunk would not pass.
|
|
11609
|
+
_trunk_router_on_a_stick(root)
|
|
11610
|
+
# Again, now that the VLAN gateways exist. The first call ran before
|
|
11611
|
+
# `_serve_every_populated_vlan` created them, so it judged the plan's pools
|
|
11612
|
+
# against interfaces that were not there yet: a pool named VLAN20 carrying
|
|
11613
|
+
# the default 192.168.1.0 survived, and the VLAN-20 pass then added a
|
|
11614
|
+
# second pool of the same name for 192.168.20.0. IOS merges pools by name
|
|
11615
|
+
# and keeps the last, so VLAN 20 served a network nothing routes.
|
|
11616
|
+
_align_dhcp_pools_with_interfaces(root)
|
|
11617
|
+
_drop_duplicate_dhcp_pools(root)
|
|
11618
|
+
_merge_repeated_interface_blocks(root)
|
|
11619
|
+
_separate_config_blocks(root)
|
|
11620
|
+
_add_missing_vlans_to_the_database(root)
|
|
11621
|
+
_drop_vlan_subinterfaces_off_router_links(root)
|
|
11622
|
+
_move_static_hosts_onto_their_vlan_network(root)
|
|
11623
|
+
# A home router keeps the donor's LAN network unless someone moves it onto
|
|
11624
|
+
# the one its own clients are addressed for, and its clients keep another
|
|
11625
|
+
# donor's network name unless someone puts them on the air it broadcasts.
|
|
11626
|
+
_join_wireless_clients_to_the_network_that_exists(root)
|
|
11627
|
+
_align_home_router_lan_with_its_clients(root)
|
|
11628
|
+
_match_wireless_security_to_the_access_point(root)
|
|
11629
|
+
# After the LAN move, which needs the planned addresses to know where to
|
|
11630
|
+
# put the pool. Once it has, the pool addresses the clients.
|
|
11631
|
+
_let_the_home_router_address_its_own_clients(root)
|
|
11632
|
+
# Last, so the profile copies whatever the passes above settled on.
|
|
11633
|
+
_make_the_wireless_profile_agree_with_the_port(root)
|
|
11634
|
+
_put_workstations_on_dhcp(root)
|
|
11635
|
+
# Snooping without a trusted uplink eats every offer the router sends.
|
|
11636
|
+
_trust_uplinks_for_dhcp_snooping(root)
|
|
11637
|
+
# Last: the standby gateway takes the address the hosts already use,
|
|
11638
|
+
# so nothing written before it has to change.
|
|
11639
|
+
_add_hsrp_gateway_redundancy(root)
|
|
11640
|
+
# A router with no path to another router carries none of its routes.
|
|
11641
|
+
_drop_stale_point_to_point_addresses(root)
|
|
11642
|
+
_mesh_routers_with_point_to_point_links(root)
|
|
7705
11643
|
_group_hosts_under_their_switch(root)
|
|
7706
11644
|
_separate_overlapping_devices(root)
|
|
11645
|
+
# After the separation pass, so a leftover nudged sideways is still pulled in.
|
|
11646
|
+
_compact_stray_devices(root)
|
|
11647
|
+
# Last of the layout: a radio link is made by distance, so a wireless
|
|
11648
|
+
# client has to end up inside its access point's reach whatever the rows
|
|
11649
|
+
# above decided.
|
|
11650
|
+
_keep_wireless_clients_within_reach_of_their_access_point(root)
|
|
7707
11651
|
_save_running_config_to_startup(root)
|
|
11652
|
+
# Before the annotation and the serialisation: the annotation names the
|
|
11653
|
+
# devices, and a rename after `serialize_pkt_xml` would change nothing
|
|
11654
|
+
# in the file that was written.
|
|
11655
|
+
for _note in _adopt_planned_names(root, requested_devices):
|
|
11656
|
+
print(_note)
|
|
11657
|
+
# After the rename, not before: the cleanup asks the plan which switch a
|
|
11658
|
+
# device belongs to, and until adoption the device still answers to a
|
|
11659
|
+
# holding name the plan never mentions.
|
|
11660
|
+
for _note in _drop_cables_the_plan_did_not_ask_for(root, requested_devices):
|
|
11661
|
+
pass
|
|
7708
11662
|
_annotate_generated_lab(root, blueprint, prepared_plan)
|
|
7709
11663
|
prune_unused_images(root)
|
|
7710
11664
|
xml_bytes = serialize_pkt_xml(root)
|
|
@@ -7714,6 +11668,11 @@ def generate_from_prompt(
|
|
|
7714
11668
|
pkt_bytes = encode_pkt_modern(xml_bytes)
|
|
7715
11669
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
7716
11670
|
output_path.write_bytes(pkt_bytes)
|
|
11671
|
+
for note in _report_undelivered_devices(root, requested_devices):
|
|
11672
|
+
print(note)
|
|
11673
|
+
for note in _report_unwired_devices(root, requested_devices):
|
|
11674
|
+
print(note)
|
|
11675
|
+
_report_coherence(root)
|
|
7717
11676
|
print(f"Selected donor: {donor_archetype.compat_donor}")
|
|
7718
11677
|
compat_donor, compat_donor_version = _compat_donor_details()
|
|
7719
11678
|
if compat_donor is not None:
|
|
@@ -7872,7 +11831,7 @@ def edit_from_prompt(
|
|
|
7872
11831
|
plan,
|
|
7873
11832
|
)
|
|
7874
11833
|
|
|
7875
|
-
edit_pkt_file(pkt_path, plan, output_path, xml_out_path)
|
|
11834
|
+
edit_pkt_file(pkt_path, plan, output_path, xml_out_path, repair=_align_etherchannels_with_cabling)
|
|
7876
11835
|
print(f"Edited PKT file created: {output_path}")
|
|
7877
11836
|
|
|
7878
11837
|
|
|
@@ -8613,6 +12572,12 @@ def main() -> None:
|
|
|
8613
12572
|
parser.add_argument("--compare-scenarios", action="append", help="Compare multiple prompts and print a scenario acceptance matrix")
|
|
8614
12573
|
parser.add_argument("--parity-report", help="Print prompt-scoped capability parity JSON")
|
|
8615
12574
|
parser.add_argument("--validate-open", help="Launch Packet Tracer with the given .pkt file")
|
|
12575
|
+
parser.add_argument(
|
|
12576
|
+
"--coherence-report",
|
|
12577
|
+
help="Report the contradictions a .pkt states about itself: addresses held twice, "
|
|
12578
|
+
"gateways no interface answers for, pools no interface serves, bundles the cable "
|
|
12579
|
+
"does not join, interfaces declared more than once",
|
|
12580
|
+
)
|
|
8616
12581
|
parser.add_argument("--validate-open-debug", help="Build staged donor compatibility debug report for a prompt")
|
|
8617
12582
|
parser.add_argument("--compat-donor", help="Explicit Packet Tracer 9.0 donor .pkt path for strict compatibility mode")
|
|
8618
12583
|
parser.add_argument("--reference-root", action="append", help="Optional local folder of imported external sample .pkt files")
|
|
@@ -8692,6 +12657,17 @@ def main() -> None:
|
|
|
8692
12657
|
inventory_out=Path(args.inventory_out) if args.inventory_out else None,
|
|
8693
12658
|
)
|
|
8694
12659
|
return
|
|
12660
|
+
if args.coherence_report:
|
|
12661
|
+
from lab_coherence import check_lab_coherence, summarise
|
|
12662
|
+
|
|
12663
|
+
root = decode_pkt_to_root(Path(args.coherence_report))
|
|
12664
|
+
findings = check_lab_coherence(root)
|
|
12665
|
+
print(summarise(findings))
|
|
12666
|
+
for finding in findings:
|
|
12667
|
+
print(f" {finding}")
|
|
12668
|
+
# A lab that contradicts itself opens perfectly well, so the exit code
|
|
12669
|
+
# is the only place the answer can be acted on.
|
|
12670
|
+
raise SystemExit(1 if findings else 0)
|
|
8695
12671
|
if args.edit:
|
|
8696
12672
|
if not args.prompt:
|
|
8697
12673
|
parser.error("--edit requires --prompt")
|