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.
@@ -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
- `Senan_Haciyev_tapsiriq.pkt` was measured wiring `Serial0/0/0 <-> Serial0/0/0`
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
- if model.startswith("2901"):
1813
- return f"GigabitEthernet0/{index - 1}"
1814
- if model.startswith("ISR"):
1815
- return f"GigabitEthernet0/0/{index - 1}"
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, next_index),
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
- return []
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 x >= PARKED_LOGICAL_X:
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 _align_router_gateway(root: ET.Element) -> list[str]:
4626
- """Put the hosts' gateway address on the router interface they reach it by.
4900
+ def _add_hsrp_gateway_redundancy(root: ET.Element) -> list[str]:
4901
+ """Give the VLAN gateways a standby router.
4627
4902
 
4628
- Host addressing is decided while planning, from the router interface the
4629
- plan plugs into the switch. Port reconciliation then moves that cable: the
4630
- plan had GigabitEthernet0/0/0, carrying 192.168.1.1, and the finished lab
4631
- ran the cable from GigabitEthernet0/0/2, carrying 192.168.3.1. Hosts were
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
- Adjusting the hosts to follow the cable was the first attempt and it did
4636
- nothing, because at planning time the two still agreed. The cabling is what
4637
- changes last, so the address has to follow it: whichever interface ends up
4638
- facing the switch is given the gateway the hosts were told to use.
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
- kinds = {
4645
- ref: (device.findtext("./ENGINE/TYPE") or "") for ref, device in devices.items()
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
- gateways = [
4649
- (port.findtext("PORT_GATEWAY") or "").strip()
4650
- for device in devices.values()
4651
- if (device.findtext("./ENGINE/TYPE") or "") in {"Pc", "PC"}
4652
- for port in device.findall("./ENGINE/MODULE/SLOT/MODULE/PORT")
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
- gateway = next((value for value in gateways if value and value != "0.0.0.0"), "")
4655
- if not gateway:
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("FROM") or "").strip(), (cable.findtext("TO") or "").strip()]
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
- for router_index, switch_index in ((0, 1), (1, 0)):
4665
- if kinds.get(refs[router_index]) != "Router":
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 kinds.get(refs[switch_index]) not in {"Switch", "MultiLayerSwitch"}:
5137
+ if (switch.findtext("./ENGINE/TYPE") or "") not in switch_types:
4668
5138
  continue
4669
- router = devices[refs[router_index]]
4670
- config = router.find("./ENGINE/RUNNINGCONFIG")
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
- # Another interface may already hold an address in this subnet --
4674
- # the one the plan originally chose. Two interfaces on one router
4675
- # cannot share a subnet, so the stale one gives up its address.
4676
- prefix = gateway.rsplit(".", 1)[0]
4677
- lines = [(line.text or "") for line in config.findall("LINE")]
4678
- cleared: list[str] = []
4679
- for index, line in enumerate(lines):
4680
- match = re.match(r"interface ((?:Gigabit|Fast)Ethernet\S*)\s*$", line.strip())
4681
- if not match or match.group(1) == ports[router_index]:
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
- cursor = index + 1
4684
- while cursor < len(lines) and lines[cursor].startswith(" "):
4685
- address = re.match(
4686
- r"ip address (\d+\.\d+\.\d+\.\d+) ", lines[cursor].strip()
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
- if address and address.group(1).rsplit(".", 1)[0] == prefix:
4689
- cleared.append(match.group(1))
4690
- break
4691
- cursor += 1
4692
- for interface in cleared:
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
- _set_config_block(
4696
- config,
4697
- f"interface {ports[router_index]}",
4698
- [f" ip address {gateway} 255.255.255.0", " no shutdown"],
4699
- )
4700
- name = router.findtext("./ENGINE/NAME") or ""
4701
- note = f"{name}: {ports[router_index]} set to {gateway} (gateway the hosts use)"
4702
- if cleared:
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
- _SVI_ADDRESS_PATTERN = re.compile(r"^ip address (\d+\.\d+\.\d+\.\d+) (\d+\.\d+\.\d+\.\d+)$")
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
- def _address_to_int(address: str) -> int | None:
4712
- parts = address.split(".")
4713
- if len(parts) != 4:
4714
- return None
4715
- value = 0
4716
- for part in parts:
4717
- if not part.isdigit() or not 0 <= int(part) <= 255:
4718
- return None
4719
- value = (value << 8) | int(part)
4720
- return value
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
- def _same_subnet(left: str, right: str, mask: str) -> bool:
4724
- left_value = _address_to_int(left)
4725
- right_value = _address_to_int(right)
4726
- mask_value = _address_to_int(mask)
4727
- if left_value is None or right_value is None or mask_value is None:
4728
- return False
4729
- return left_value & mask_value == right_value & mask_value
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
- pools: list[tuple[str, ET.Element, ET.Element | None]] = []
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, _ in pools
7989
+ for other_name, other_network, _gateway, _block in pools
4805
7990
  if other_name != name
4806
- for address, interface_mask in interfaces
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
- (pair for pair in interfaces if pair not in served),
4811
- interfaces[0],
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 x >= PARKED_LOGICAL_X:
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
- ordered = sorted(blocks, key=lambda name: position[name][0])
5037
- host_row = max(position[host][1] for members in blocks.values() for host in members)
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
- position[switch_name] = (centre, position[switch_name][1])
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 x >= PARKED_LOGICAL_X:
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 `Senan_Haciyev_tapsiriq.pkt` names its `FROM` device and port.
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
- `Senan_K231.pkt` is Router-Mertebe3-Mertebe2-Mertebe1. Name order gave
5733
- SW2 -> Mertebe 1, so `SW1 <-> SW2` mapped to a pair with no link. Distance
5734
- ordering gives SW1/SW2/SW3 -> Mertebe 3/2/1, which follows the real chain.
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
- for host in seed_hosts:
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{seed_hosts.index(host) + 1}"
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 planned_port:
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 `Senan_Haciyev_tapsiriq.pkt`, and Packet Tracer refused the lab built
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")