bytesim 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bytesim/__init__.py +14 -0
- bytesim/__main__.py +3 -0
- bytesim/cli.py +45 -0
- bytesim/constants.py +48 -0
- bytesim/demos.py +74 -0
- bytesim/model/__init__.py +5 -0
- bytesim/model/addressing.py +160 -0
- bytesim/model/base.py +57 -0
- bytesim/model/cloning.py +249 -0
- bytesim/model/equivalence.py +86 -0
- bytesim/model/groups.py +133 -0
- bytesim/model/ha.py +93 -0
- bytesim/model/layer2.py +106 -0
- bytesim/model/layout.py +203 -0
- bytesim/model/naming.py +114 -0
- bytesim/model/network.py +138 -0
- bytesim/model/routing.py +117 -0
- bytesim/model/stp.py +315 -0
- bytesim/model/types.py +107 -0
- bytesim/py.typed +0 -0
- bytesim/scripting.py +108 -0
- bytesim/synthesis.py +644 -0
- bytesim/topology.py +517 -0
- bytesim/tui/__init__.py +4 -0
- bytesim/tui/app.py +161 -0
- bytesim/tui/cables.py +58 -0
- bytesim/tui/canvas.py +489 -0
- bytesim/tui/command_input.py +29 -0
- bytesim/tui/commands/__init__.py +1 -0
- bytesim/tui/commands/addressing.py +235 -0
- bytesim/tui/commands/base.py +43 -0
- bytesim/tui/commands/groups.py +215 -0
- bytesim/tui/commands/history.py +134 -0
- bytesim/tui/commands/interpreter.py +85 -0
- bytesim/tui/commands/naming.py +68 -0
- bytesim/tui/commands/registry.py +28 -0
- bytesim/tui/commands/scripting.py +54 -0
- bytesim/tui/commands/switching.py +192 -0
- bytesim/tui/commands/topology.py +255 -0
- bytesim/tui/commands/traffic.py +312 -0
- bytesim/tui/help.py +50 -0
- bytesim/tui/inspector.py +115 -0
- bytesim/tui/theme.py +12 -0
- bytesim-0.1.0.dist-info/METADATA +178 -0
- bytesim-0.1.0.dist-info/RECORD +48 -0
- bytesim-0.1.0.dist-info/WHEEL +4 -0
- bytesim-0.1.0.dist-info/entry_points.txt +2 -0
- bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
bytesim/model/naming.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Renumbering devices in traversal order (BFS or DFS over the cabling)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
|
|
6
|
+
from ..constants import KINDS, RANK
|
|
7
|
+
from .base import NetworkBase
|
|
8
|
+
from .types import NetError
|
|
9
|
+
|
|
10
|
+
ORDERS = ("bfs", "dfs")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NamingMixin(NetworkBase):
|
|
14
|
+
def default_root(self) -> str | None:
|
|
15
|
+
"""Where a traversal starts unless told otherwise: the first gateway,
|
|
16
|
+
else the first router, switch, then computer."""
|
|
17
|
+
devs = self.sorted_devices()
|
|
18
|
+
return devs[0].id if devs else None
|
|
19
|
+
|
|
20
|
+
def _neighbours(self, dev_id: str) -> list[str]:
|
|
21
|
+
"""Devices cabled to dev_id, in the order of dev_id's own ports.
|
|
22
|
+
|
|
23
|
+
Naming follows the physical cabling, so every cable counts (failed or
|
|
24
|
+
control cables too) and computers are walked through like anything else.
|
|
25
|
+
"""
|
|
26
|
+
ends = sorted((l.port_of(dev_id), l.other(dev_id)[0]) for l in self.links if l.touches(dev_id))
|
|
27
|
+
return [other for _, other in ends]
|
|
28
|
+
|
|
29
|
+
def traversal(self, order: str, root: str | None = None) -> list[str]:
|
|
30
|
+
"""Every device id, in the order a BFS or DFS from root first reaches it.
|
|
31
|
+
|
|
32
|
+
Neighbours are taken in port order. Devices the root can't reach are
|
|
33
|
+
picked up by starting again from the next one in the old (kind, number)
|
|
34
|
+
order, so the result always covers the whole network.
|
|
35
|
+
"""
|
|
36
|
+
if order not in ORDERS:
|
|
37
|
+
raise NetError(f"order must be one of: {', '.join(ORDERS)}")
|
|
38
|
+
root = self.get(root).id if root else self.default_root()
|
|
39
|
+
if root is None:
|
|
40
|
+
return []
|
|
41
|
+
starts = [root] + [d.id for d in self.sorted_devices() if d.id != root]
|
|
42
|
+
seen: set[str] = set()
|
|
43
|
+
out: list[str] = []
|
|
44
|
+
for start in starts:
|
|
45
|
+
if start in seen:
|
|
46
|
+
continue
|
|
47
|
+
if order == "bfs":
|
|
48
|
+
seen.add(start)
|
|
49
|
+
q = deque([start])
|
|
50
|
+
while q:
|
|
51
|
+
u = q.popleft()
|
|
52
|
+
out.append(u)
|
|
53
|
+
for v in self._neighbours(u):
|
|
54
|
+
if v not in seen:
|
|
55
|
+
seen.add(v)
|
|
56
|
+
q.append(v)
|
|
57
|
+
else:
|
|
58
|
+
# Iterative pre-order DFS: visit, then go deep through the first
|
|
59
|
+
# unvisited neighbour before trying the next port.
|
|
60
|
+
seen.add(start)
|
|
61
|
+
out.append(start)
|
|
62
|
+
stack = [iter(self._neighbours(start))]
|
|
63
|
+
while stack:
|
|
64
|
+
v = next(stack[-1], None)
|
|
65
|
+
if v is None:
|
|
66
|
+
stack.pop()
|
|
67
|
+
elif v not in seen:
|
|
68
|
+
seen.add(v)
|
|
69
|
+
out.append(v)
|
|
70
|
+
stack.append(iter(self._neighbours(v)))
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def renumber(self, order: str, root: str | None = None) -> dict[str, str]:
|
|
74
|
+
"""Renumber each kind 1..n in traversal order. Returns {old id: new id}
|
|
75
|
+
for every device whose id changed.
|
|
76
|
+
|
|
77
|
+
Cables, groups, addresses and DHCP settings follow their device. Switch
|
|
78
|
+
MAC tables are cleared, since MACs are derived from device ids. Counters
|
|
79
|
+
are reset so the next new device of each kind gets n+1.
|
|
80
|
+
"""
|
|
81
|
+
visit = self.traversal(order, root)
|
|
82
|
+
counts = {k: 0 for k in KINDS}
|
|
83
|
+
new_num: dict[str, int] = {}
|
|
84
|
+
for did in visit:
|
|
85
|
+
dev = self.devices[did]
|
|
86
|
+
counts[dev.kind] += 1
|
|
87
|
+
new_num[did] = counts[dev.kind]
|
|
88
|
+
mapping = {did: f"{self.devices[did].kind}{n}" for did, n in new_num.items()}
|
|
89
|
+
changed = {old: new for old, new in mapping.items() if old != new}
|
|
90
|
+
self.counters = counts
|
|
91
|
+
if not changed:
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
def ren(did: str) -> str:
|
|
95
|
+
return mapping.get(did, did)
|
|
96
|
+
|
|
97
|
+
for did, n in new_num.items():
|
|
98
|
+
self.devices[did].num = n
|
|
99
|
+
self.devices = {d.id: d for d in sorted(self.devices.values(), key=lambda d: (RANK[d.kind], d.num))}
|
|
100
|
+
for l in self.links:
|
|
101
|
+
l.a, l.b = ren(l.a), ren(l.b)
|
|
102
|
+
self.groups = {g: [ren(m) if m in mapping else m for m in ms] for g, ms in self.groups.items()}
|
|
103
|
+
self.ifaces = {f"{ren(k.split(':')[0])}:{k.split(':')[1]}": v for k, v in self.ifaces.items()}
|
|
104
|
+
self.dhcp_off = {ren(d) for d in self.dhcp_off}
|
|
105
|
+
self.stp_off = {ren(d) for d in self.stp_off}
|
|
106
|
+
self.stp_priority = {ren(d): v for d, v in self.stp_priority.items()}
|
|
107
|
+
self.stp_blocked = {(ren(d), p) for d, p in self.stp_blocked}
|
|
108
|
+
self.stp_roots = {ren(d): ren(r) for d, r in self.stp_roots.items()}
|
|
109
|
+
self.stp_root_ports = {ren(d): p for d, p in self.stp_root_ports.items()}
|
|
110
|
+
self.storming = {ren(d) for d in self.storming}
|
|
111
|
+
self.mac_flaps = {ren(d): f for d, f in self.mac_flaps.items()}
|
|
112
|
+
self.mac_tables = {}
|
|
113
|
+
self.notes = []
|
|
114
|
+
return changed
|
bytesim/model/network.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The Network: every mixin combined, plus device/cable editing and JSON persistence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
|
|
7
|
+
from ..constants import KINDS, MAX_PORTS
|
|
8
|
+
from .addressing import AddressingMixin
|
|
9
|
+
from .cloning import CloningMixin
|
|
10
|
+
from .groups import GroupsMixin
|
|
11
|
+
from .ha import HAMixin
|
|
12
|
+
from .layer2 import Layer2Mixin
|
|
13
|
+
from .layout import LayoutMixin
|
|
14
|
+
from .naming import NamingMixin
|
|
15
|
+
from .routing import RoutingMixin
|
|
16
|
+
from .stp import SpanningTreeMixin
|
|
17
|
+
from .types import Device, Iface, Link, NetError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Network(GroupsMixin, Layer2Mixin, AddressingMixin, RoutingMixin, HAMixin, LayoutMixin,
|
|
21
|
+
NamingMixin, SpanningTreeMixin, CloningMixin):
|
|
22
|
+
"""A simulated network: devices, cables, groups, addresses and HA clusters.
|
|
23
|
+
|
|
24
|
+
Pure model, no UI: everything here can be driven from Python or tests.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def add(self, kind: str, nports: int | None = None) -> Device:
|
|
28
|
+
nports = KINDS[kind][1] if nports is None else nports
|
|
29
|
+
if not 1 <= nports <= MAX_PORTS:
|
|
30
|
+
raise NetError(f"port count must be 1..{MAX_PORTS}")
|
|
31
|
+
self.counters[kind] += 1
|
|
32
|
+
dev = Device(kind, self.counters[kind], nports)
|
|
33
|
+
self.devices[dev.id] = dev
|
|
34
|
+
return dev
|
|
35
|
+
|
|
36
|
+
def remove(self, dev_id: str) -> int:
|
|
37
|
+
dev = self.get(dev_id)
|
|
38
|
+
before = len(self.links)
|
|
39
|
+
self.links = [l for l in self.links if not l.touches(dev.id)]
|
|
40
|
+
del self.devices[dev.id]
|
|
41
|
+
self._drop_from_groups([dev.id])
|
|
42
|
+
for key in [k for k in self.ifaces if k.split(":")[0] == dev.id]:
|
|
43
|
+
del self.ifaces[key]
|
|
44
|
+
self.dhcp_off.discard(dev.id)
|
|
45
|
+
self.stp_off.discard(dev.id)
|
|
46
|
+
self.stp_priority.pop(dev.id, None)
|
|
47
|
+
self.storming.discard(dev.id)
|
|
48
|
+
self.mac_flaps.pop(dev.id, None)
|
|
49
|
+
self.mac_tables = {}
|
|
50
|
+
return before - len(self.links)
|
|
51
|
+
|
|
52
|
+
def resize(self, dev_id: str, nports: int) -> None:
|
|
53
|
+
dev = self.get(dev_id)
|
|
54
|
+
if not 1 <= nports <= MAX_PORTS:
|
|
55
|
+
raise NetError(f"port count must be 1..{MAX_PORTS}")
|
|
56
|
+
used = [l.port_of(dev.id) for l in self.links if l.touches(dev.id)]
|
|
57
|
+
if used and max(used) > nports:
|
|
58
|
+
raise NetError(f"{dev.id} port {max(used)} is in use; unlink it first")
|
|
59
|
+
dev.nports = nports
|
|
60
|
+
for p in range(nports + 1, MAX_PORTS + 1):
|
|
61
|
+
self.ifaces.pop(f"{dev.id}:{p}", None)
|
|
62
|
+
|
|
63
|
+
def connect(self, a: str, pa: int | None, b: str, pb: int | None, role: str = "data") -> Link:
|
|
64
|
+
da, db = self.get(a), self.get(b)
|
|
65
|
+
if da is db:
|
|
66
|
+
raise NetError("can't cable a device to itself")
|
|
67
|
+
ports = []
|
|
68
|
+
for dev, p in ((da, pa), (db, pb)):
|
|
69
|
+
if p is None:
|
|
70
|
+
p = self.free_port(dev)
|
|
71
|
+
if p is None:
|
|
72
|
+
raise NetError(f"{dev.id} has no free ports (try `ports {dev.id} {dev.nports + 1}`)")
|
|
73
|
+
elif not 1 <= p <= dev.nports:
|
|
74
|
+
raise NetError(f"{dev.id} has no port {p} (it has {dev.nports})")
|
|
75
|
+
elif (l := self.link_on(dev.id, p)) is not None:
|
|
76
|
+
o, op = l.other(dev.id)
|
|
77
|
+
raise NetError(f"{dev.id}:{dev.port_name(p)} already cabled to {o}")
|
|
78
|
+
ports.append(p)
|
|
79
|
+
link = Link(da.id, ports[0], db.id, ports[1], role)
|
|
80
|
+
self.links.append(link)
|
|
81
|
+
return link
|
|
82
|
+
|
|
83
|
+
def disconnect(self, a: str, pa: int | None, b: str | None) -> int:
|
|
84
|
+
da = self.get(a)
|
|
85
|
+
db = self.get(b) if b else None
|
|
86
|
+
|
|
87
|
+
def match(l: Link) -> bool:
|
|
88
|
+
if not l.touches(da.id):
|
|
89
|
+
return False
|
|
90
|
+
if pa is not None and l.port_of(da.id) != pa:
|
|
91
|
+
return False
|
|
92
|
+
return db is None or l.other(da.id)[0] == db.id
|
|
93
|
+
|
|
94
|
+
before = len(self.links)
|
|
95
|
+
self.links = [l for l in self.links if not match(l)]
|
|
96
|
+
if len(self.links) != before:
|
|
97
|
+
self.mac_tables = {} # topology changed: switches forget what they learned
|
|
98
|
+
return before - len(self.links)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---- persistence ---------------------------------------------------
|
|
102
|
+
def to_json(self) -> str:
|
|
103
|
+
return json.dumps({
|
|
104
|
+
"counters": self.counters,
|
|
105
|
+
"devices": [asdict(d) for d in self.devices.values()],
|
|
106
|
+
"links": [asdict(l) for l in self.links],
|
|
107
|
+
"groups": self.groups,
|
|
108
|
+
"ifaces": {k: asdict(v) for k, v in self.ifaces.items()},
|
|
109
|
+
"dhcp_off": sorted(self.dhcp_off),
|
|
110
|
+
"clusters": self.clusters,
|
|
111
|
+
"stp_off": sorted(self.stp_off, key=lambda d: (d[0], int(d[1:]))),
|
|
112
|
+
"stp_priority": self.stp_priority,
|
|
113
|
+
}, indent=2)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_json(cls, raw: str) -> "Network":
|
|
117
|
+
data = json.loads(raw)
|
|
118
|
+
net = cls()
|
|
119
|
+
net.counters.update(data["counters"])
|
|
120
|
+
for d in data["devices"]:
|
|
121
|
+
dev = Device(**d)
|
|
122
|
+
net.devices[dev.id] = dev
|
|
123
|
+
net.links = [Link(**l) for l in data["links"]]
|
|
124
|
+
names = set(data.get("groups", {}))
|
|
125
|
+
net.groups = {g: [d for d in ids if d in net.devices or d in names]
|
|
126
|
+
for g, ids in data.get("groups", {}).items()}
|
|
127
|
+
net._drop_from_groups([])
|
|
128
|
+
for k, v in data.get("ifaces", {}).items():
|
|
129
|
+
if k.split(":")[0] in net.devices:
|
|
130
|
+
try:
|
|
131
|
+
net.ifaces[k] = Iface(**v)
|
|
132
|
+
except TypeError:
|
|
133
|
+
pass # address from an older format: re-learned on load
|
|
134
|
+
net.dhcp_off = set(data.get("dhcp_off", [])) & set(net.devices)
|
|
135
|
+
net.clusters = {c: p for c, p in data.get("clusters", {}).items() if c in net.groups}
|
|
136
|
+
net.stp_off = set(data.get("stp_off", [])) & set(net.devices)
|
|
137
|
+
net.stp_priority = {d: int(v) for d, v in data.get("stp_priority", {}).items() if d in net.devices}
|
|
138
|
+
return net
|
bytesim/model/routing.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Forwarding: hosts deliver locally or via their gateway; routers find the cheapest path."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import heapq
|
|
5
|
+
|
|
6
|
+
from ..constants import VIP_HOST
|
|
7
|
+
from .base import NetworkBase
|
|
8
|
+
from .types import Leg, NetError, fmt
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RoutingMixin(NetworkBase):
|
|
12
|
+
def route(self, src: str, dst: tuple[str, int] | None) -> list[Leg]:
|
|
13
|
+
"""Path from a device to an interface (or to the internet if dst is None).
|
|
14
|
+
|
|
15
|
+
Each leg is one layer-2 hop between addressed interfaces. A host delivers
|
|
16
|
+
directly on its own network, otherwise via its gateway; routers know every
|
|
17
|
+
network of every router they share a network with (a toy IGP).
|
|
18
|
+
"""
|
|
19
|
+
dev = self.devices[src]
|
|
20
|
+
if dev.kind == "s":
|
|
21
|
+
raise NetError(f"{src} is a switch: layer 2 only, it has no address to send from")
|
|
22
|
+
if not dev.up:
|
|
23
|
+
raise NetError(f"{src} is down")
|
|
24
|
+
if dst and dst[0] == src:
|
|
25
|
+
return []
|
|
26
|
+
dst_addr = self.addr_of(dst) if dst else None
|
|
27
|
+
if dev.kind in "rg":
|
|
28
|
+
return self._router_route(src, dst, dst_addr)
|
|
29
|
+
nics = self.addressed(src)
|
|
30
|
+
if not nics:
|
|
31
|
+
raise NetError(f"{src} has no address (try: dhcp {src})")
|
|
32
|
+
if dst:
|
|
33
|
+
for p, ifc in nics:
|
|
34
|
+
if ifc.net == dst_addr[0] and dst in self.segment(src, p):
|
|
35
|
+
return [((src, p), dst, self.l2path((src, p), dst))]
|
|
36
|
+
problems = []
|
|
37
|
+
for p, ifc in nics:
|
|
38
|
+
if ifc.gw is None:
|
|
39
|
+
continue
|
|
40
|
+
seg = self.segment(src, p)
|
|
41
|
+
if ifc.gw == VIP_HOST:
|
|
42
|
+
owners = self.vip_owners(ifc.net)
|
|
43
|
+
here = [o for o in owners if o in seg]
|
|
44
|
+
if len(owners) > 1:
|
|
45
|
+
self.notes.append(f"⚠ virtual gateway {fmt((ifc.net, VIP_HOST))} claimed by "
|
|
46
|
+
f"{' and '.join(o[0] for o in owners)} (ARP conflict: "
|
|
47
|
+
f"{src} happens to reach {here[0][0] if here else 'neither'})")
|
|
48
|
+
elif here:
|
|
49
|
+
self.notes.append(f"gateway {fmt((ifc.net, VIP_HOST))} is virtual, "
|
|
50
|
+
f"owned by {here[0][0]} (active)")
|
|
51
|
+
gw = here[0] if here else None
|
|
52
|
+
else:
|
|
53
|
+
gw = next((e for e in seg if self.addr_of(e) == (ifc.net, ifc.gw)), None)
|
|
54
|
+
if gw is None:
|
|
55
|
+
problems.append(f"gateway {fmt((ifc.net, ifc.gw))} unreachable from {src}:{dev.port_name(p)}")
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
return [((src, p), gw, self.l2path((src, p), gw))] + self._router_route(gw[0], dst, dst_addr)
|
|
59
|
+
except NetError as e:
|
|
60
|
+
problems.append(str(e))
|
|
61
|
+
if problems:
|
|
62
|
+
raise NetError("; ".join(problems))
|
|
63
|
+
where = f"network {dst_addr[0]}" if dst_addr else "the internet"
|
|
64
|
+
raise NetError(f"{where} isn't local to {src} and {src} has no gateway")
|
|
65
|
+
|
|
66
|
+
def _router_route(self, start: str, dst: tuple[str, int] | None,
|
|
67
|
+
dst_addr: tuple[int, int] | None) -> list[Leg]:
|
|
68
|
+
"""Cheapest path in cable hops from a router to dst (or to any gateway).
|
|
69
|
+
|
|
70
|
+
Routers are nodes; two routers are adjacent when they share a network.
|
|
71
|
+
Counting cables rather than routers lets a direct fabric link win over
|
|
72
|
+
a detour back through a switched network."""
|
|
73
|
+
best: dict[str, int] = {start: 0}
|
|
74
|
+
prev: dict[str, tuple[str, Leg] | None] = {start: None}
|
|
75
|
+
heap: list[tuple[int, int, str, Leg | None]] = [(0, 0, start, None)]
|
|
76
|
+
tick = 0
|
|
77
|
+
done: set[str] = set()
|
|
78
|
+
|
|
79
|
+
def legs_to(u: str) -> list[Leg]:
|
|
80
|
+
out = []
|
|
81
|
+
while prev[u] is not None:
|
|
82
|
+
parent, leg = prev[u]
|
|
83
|
+
out.append(leg)
|
|
84
|
+
u = parent
|
|
85
|
+
return out[::-1]
|
|
86
|
+
|
|
87
|
+
while heap:
|
|
88
|
+
cost, _, u, final = heapq.heappop(heap)
|
|
89
|
+
if final is not None: # popped the destination itself
|
|
90
|
+
return legs_to(u) + [final]
|
|
91
|
+
if u in done:
|
|
92
|
+
continue
|
|
93
|
+
done.add(u)
|
|
94
|
+
if dst is None and self.devices[u].kind == "g":
|
|
95
|
+
return legs_to(u)
|
|
96
|
+
if dst is not None and dst[0] == u:
|
|
97
|
+
return legs_to(u)
|
|
98
|
+
for p, ifc in self.addressed(u):
|
|
99
|
+
seg = self.segment(u, p)
|
|
100
|
+
if dst is not None and dst in seg and ifc.net == dst_addr[0]:
|
|
101
|
+
path = self.l2path((u, p), dst)
|
|
102
|
+
tick += 1
|
|
103
|
+
heapq.heappush(heap, (cost + len(path) - 1, tick, u, ((u, p), dst, path)))
|
|
104
|
+
for e in seg:
|
|
105
|
+
a = self.addr_of(e)
|
|
106
|
+
if not (self.is_router(e[0]) and a and a[0] == ifc.net) or e[0] in done:
|
|
107
|
+
continue
|
|
108
|
+
path = self.l2path((u, p), e)
|
|
109
|
+
c = cost + len(path) - 1
|
|
110
|
+
if c < best.get(e[0], 10**9):
|
|
111
|
+
best[e[0]] = c
|
|
112
|
+
prev[e[0]] = (u, ((u, p), e, path))
|
|
113
|
+
tick += 1
|
|
114
|
+
heapq.heappush(heap, (c, tick, e[0], None))
|
|
115
|
+
if dst is None:
|
|
116
|
+
raise NetError(f"no route from {start} to a gateway (internet)")
|
|
117
|
+
raise NetError(f"no route to network {dst_addr[0]} from {start}")
|
bytesim/model/stp.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Switching loops: spanning tree (STP), loop detection, flooding, broadcast storms.
|
|
2
|
+
|
|
3
|
+
Switches run STP unless it is switched off for them. STP picks a root bridge
|
|
4
|
+
in every switched area, keeps the shortest path tree to it, and blocks one end
|
|
5
|
+
of every other switch-to-switch cable, so no loop forwards frames. A loop with
|
|
6
|
+
no STP switch able to block it stays live: the next broadcast into it circles
|
|
7
|
+
forever and multiplies. That is a broadcast storm, and it saturates every
|
|
8
|
+
cable it touches until the loop is broken.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import Counter, deque
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from ..constants import RANK
|
|
16
|
+
from .base import NetworkBase
|
|
17
|
+
from .types import Link
|
|
18
|
+
|
|
19
|
+
DEFAULT_PRIORITY = 32768
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Flood:
|
|
24
|
+
"""What happened to one broadcast frame, generation by generation."""
|
|
25
|
+
source: tuple[str, int]
|
|
26
|
+
generations: list[dict[tuple[int, str], int]] = field(default_factory=list) # (link, arriving at) -> copies
|
|
27
|
+
delivered: Counter = field(default_factory=Counter) # (device, port) -> copies received
|
|
28
|
+
storm: bool = False
|
|
29
|
+
storm_switches: set[str] = field(default_factory=set)
|
|
30
|
+
storm_links: set[int] = field(default_factory=set)
|
|
31
|
+
flaps: dict[str, set[int]] = field(default_factory=dict) # switch -> ports the source MAC arrived on
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def totals(self) -> list[int]:
|
|
35
|
+
return [sum(g.values()) for g in self.generations]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SpanningTreeMixin(NetworkBase):
|
|
39
|
+
# ---- configuration -------------------------------------------------
|
|
40
|
+
def stp_enabled(self, switch: str) -> bool:
|
|
41
|
+
return switch not in self.stp_off
|
|
42
|
+
|
|
43
|
+
def bridge_id(self, switch: str) -> tuple[int, int, int]:
|
|
44
|
+
"""Lower wins the root election: priority, then kind and number."""
|
|
45
|
+
d = self.devices[switch]
|
|
46
|
+
return self.stp_priority.get(switch, DEFAULT_PRIORITY), RANK[d.kind], d.num
|
|
47
|
+
|
|
48
|
+
# ---- the switch-to-switch graph -------------------------------------
|
|
49
|
+
def _carries(self, l: Link, honor_stp: bool) -> bool:
|
|
50
|
+
"""Could this cable forward frames, storms aside? (STP blocks optional.)"""
|
|
51
|
+
if not (l.up and not l.control and self.devices[l.a].up and self.devices[l.b].up):
|
|
52
|
+
return False
|
|
53
|
+
return not (honor_stp and ((l.a, l.pa) in self.stp_blocked or (l.b, l.pb) in self.stp_blocked))
|
|
54
|
+
|
|
55
|
+
def _trunks(self, honor_stp: bool) -> list[int]:
|
|
56
|
+
"""Indices of cables joining two switches that can forward frames."""
|
|
57
|
+
return [i for i, l in enumerate(self.links)
|
|
58
|
+
if self.devices[l.a].kind == "s" and self.devices[l.b].kind == "s"
|
|
59
|
+
and self._carries(l, honor_stp)]
|
|
60
|
+
|
|
61
|
+
def _areas(self, trunks: list[int]) -> list[set[str]]:
|
|
62
|
+
"""Switches grouped into areas joined by the given trunks."""
|
|
63
|
+
adj: dict[str, set[str]] = {d.id: set() for d in self.devices.values() if d.kind == "s"}
|
|
64
|
+
for i in trunks:
|
|
65
|
+
l = self.links[i]
|
|
66
|
+
adj[l.a].add(l.b)
|
|
67
|
+
adj[l.b].add(l.a)
|
|
68
|
+
out, seen = [], set()
|
|
69
|
+
for s in sorted(adj, key=lambda s: self.bridge_id(s)):
|
|
70
|
+
if s in seen:
|
|
71
|
+
continue
|
|
72
|
+
comp, q = {s}, deque([s])
|
|
73
|
+
while q:
|
|
74
|
+
for v in adj[q.popleft()]:
|
|
75
|
+
if v not in comp:
|
|
76
|
+
comp.add(v)
|
|
77
|
+
q.append(v)
|
|
78
|
+
seen |= comp
|
|
79
|
+
out.append(comp)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
def loop_links(self, honor_stp: bool = True) -> set[int]:
|
|
83
|
+
"""Trunks that lie on a loop (any cable that isn't a bridge in the graph theory sense)."""
|
|
84
|
+
trunks = self._trunks(honor_stp)
|
|
85
|
+
adj: dict[str, list[tuple[str, int]]] = {}
|
|
86
|
+
for i in trunks:
|
|
87
|
+
l = self.links[i]
|
|
88
|
+
adj.setdefault(l.a, []).append((l.b, i))
|
|
89
|
+
adj.setdefault(l.b, []).append((l.a, i))
|
|
90
|
+
disc: dict[str, int] = {}
|
|
91
|
+
low: dict[str, int] = {}
|
|
92
|
+
bridges: set[int] = set()
|
|
93
|
+
timer = [0]
|
|
94
|
+
|
|
95
|
+
def dfs(u: str, via: int) -> None: # Tarjan's bridge finding (multigraph-safe)
|
|
96
|
+
disc[u] = low[u] = timer[0]
|
|
97
|
+
timer[0] += 1
|
|
98
|
+
for v, i in adj.get(u, []):
|
|
99
|
+
if i == via:
|
|
100
|
+
continue
|
|
101
|
+
if v in disc:
|
|
102
|
+
low[u] = min(low[u], disc[v])
|
|
103
|
+
else:
|
|
104
|
+
dfs(v, i)
|
|
105
|
+
low[u] = min(low[u], low[v])
|
|
106
|
+
if low[v] > disc[u]:
|
|
107
|
+
bridges.add(i)
|
|
108
|
+
|
|
109
|
+
for s in adj:
|
|
110
|
+
if s not in disc:
|
|
111
|
+
dfs(s, -1)
|
|
112
|
+
return set(trunks) - bridges
|
|
113
|
+
|
|
114
|
+
# ---- spanning tree ---------------------------------------------------
|
|
115
|
+
def stp_compute(self) -> tuple[set[tuple[str, int]], dict[str, str], dict[str, int]]:
|
|
116
|
+
"""Blocked ports, each switch's root bridge, and each switch's root port.
|
|
117
|
+
|
|
118
|
+
Per switched area, the root is the STP switch with the lowest bridge id.
|
|
119
|
+
Switches without STP pass STP frames straight through (as real ones do),
|
|
120
|
+
so they can't block anything but don't hide loops either. Cables with
|
|
121
|
+
no STP end always forward; every other cable is added nearest-the-root
|
|
122
|
+
first, and one that would close a loop is blocked at its end farther
|
|
123
|
+
from the root (or its only STP end). What's left is loop-free except
|
|
124
|
+
for loops made entirely of non-STP switches, which nothing can break.
|
|
125
|
+
"""
|
|
126
|
+
trunks = self._trunks(honor_stp=False)
|
|
127
|
+
blocked: set[tuple[str, int]] = set()
|
|
128
|
+
roots: dict[str, str] = {}
|
|
129
|
+
root_ports: dict[str, int] = {}
|
|
130
|
+
for area in self._areas(trunks):
|
|
131
|
+
bridges = [s for s in area if self.stp_enabled(s)]
|
|
132
|
+
if not bridges:
|
|
133
|
+
continue
|
|
134
|
+
root = min(bridges, key=self.bridge_id)
|
|
135
|
+
for s in area:
|
|
136
|
+
roots[s] = root
|
|
137
|
+
here = [i for i in trunks if self.links[i].a in area]
|
|
138
|
+
cost = self._hops_from(root, here)
|
|
139
|
+
|
|
140
|
+
parent = {s: s for s in area} # union-find over what forwards
|
|
141
|
+
|
|
142
|
+
def find(s: str) -> str:
|
|
143
|
+
while parent[s] != s:
|
|
144
|
+
parent[s] = parent[parent[s]]
|
|
145
|
+
s = parent[s]
|
|
146
|
+
return s
|
|
147
|
+
|
|
148
|
+
def rank(i: int) -> tuple:
|
|
149
|
+
l = self.links[i]
|
|
150
|
+
near, far = sorted([(l.a, l.pa), (l.b, l.pb)],
|
|
151
|
+
key=lambda e: (cost[e[0]], self.bridge_id(e[0])))
|
|
152
|
+
return (cost[far[0]], cost[near[0]], self.bridge_id(near[0]), near[1])
|
|
153
|
+
|
|
154
|
+
forced = [i for i in here if not (self.stp_enabled(self.links[i].a)
|
|
155
|
+
or self.stp_enabled(self.links[i].b))]
|
|
156
|
+
for i in forced + sorted((i for i in here if i not in forced), key=rank):
|
|
157
|
+
l = self.links[i]
|
|
158
|
+
ra, rb = find(l.a), find(l.b)
|
|
159
|
+
if ra != rb:
|
|
160
|
+
parent[ra] = rb
|
|
161
|
+
continue
|
|
162
|
+
if i in forced:
|
|
163
|
+
continue # an unbreakable loop: no STP end
|
|
164
|
+
ends = sorted([(l.a, l.pa), (l.b, l.pb)],
|
|
165
|
+
key=lambda e: (cost[e[0]], self.bridge_id(e[0])), reverse=True)
|
|
166
|
+
blocked.add(next(e for e in ends if self.stp_enabled(e[0])))
|
|
167
|
+
# Root port: the forwarding cable that leads one hop closer to the root.
|
|
168
|
+
fwd = [i for i in here if (self.links[i].a, self.links[i].pa) not in blocked
|
|
169
|
+
and (self.links[i].b, self.links[i].pb) not in blocked]
|
|
170
|
+
dist = self._hops_from(root, fwd)
|
|
171
|
+
for s in area:
|
|
172
|
+
if s == root or not self.stp_enabled(s) or s not in dist:
|
|
173
|
+
continue
|
|
174
|
+
best = min(((self.bridge_id(self.links[i].other(s)[0]), self.links[i].port_of(s))
|
|
175
|
+
for i in fwd if self.links[i].touches(s)
|
|
176
|
+
and dist.get(self.links[i].other(s)[0], 10**9) < dist[s]), default=None)
|
|
177
|
+
if best:
|
|
178
|
+
root_ports[s] = best[1]
|
|
179
|
+
return blocked, roots, root_ports
|
|
180
|
+
|
|
181
|
+
def _hops_from(self, start: str, trunks: list[int]) -> dict[str, int]:
|
|
182
|
+
dist = {start: 0}
|
|
183
|
+
q = deque([start])
|
|
184
|
+
while q:
|
|
185
|
+
u = q.popleft()
|
|
186
|
+
for i in trunks:
|
|
187
|
+
l = self.links[i]
|
|
188
|
+
if l.touches(u) and (v := l.other(u)[0]) not in dist:
|
|
189
|
+
dist[v] = dist[u] + 1
|
|
190
|
+
q.append(v)
|
|
191
|
+
return dist
|
|
192
|
+
|
|
193
|
+
def port_role(self, switch: str, port: int) -> str:
|
|
194
|
+
"""root / designated / blocking / edge, or '' (unplugged, or STP off)."""
|
|
195
|
+
li = self.link_index(switch, port)
|
|
196
|
+
if li is None:
|
|
197
|
+
return ""
|
|
198
|
+
if (switch, port) in self.stp_blocked:
|
|
199
|
+
return "blocking"
|
|
200
|
+
if self.devices[self.links[li].other(switch)[0]].kind != "s":
|
|
201
|
+
return "edge"
|
|
202
|
+
if not self.stp_enabled(switch) or switch not in self.stp_roots:
|
|
203
|
+
return ""
|
|
204
|
+
return "root" if self.stp_root_ports.get(switch) == port else "designated"
|
|
205
|
+
|
|
206
|
+
def stp_update(self) -> list[str]:
|
|
207
|
+
"""Re-run STP and end storms whose loop is gone. Returns notes on what changed."""
|
|
208
|
+
notes = []
|
|
209
|
+
before = self.stp_blocked
|
|
210
|
+
self.stp_blocked, self.stp_roots, self.stp_root_ports = self.stp_compute()
|
|
211
|
+
for sw, p in sorted(before - self.stp_blocked):
|
|
212
|
+
li = self.link_index(sw, p) if sw in self.devices else None
|
|
213
|
+
if li is not None and self._carries(self.links[li], honor_stp=False):
|
|
214
|
+
notes.append(f"STP {sw}:p{p} blocking → forwarding")
|
|
215
|
+
for sw, p in sorted(self.stp_blocked - before):
|
|
216
|
+
notes.append(f"STP {sw}:p{p} → blocking")
|
|
217
|
+
if self.storming:
|
|
218
|
+
# A storm lives as long as its area still has a forwarding loop.
|
|
219
|
+
looped = {d for i in self.loop_links(honor_stp=True)
|
|
220
|
+
for d in (self.links[i].a, self.links[i].b)}
|
|
221
|
+
still: set[str] = set()
|
|
222
|
+
for area in self._areas(self._trunks(honor_stp=True)):
|
|
223
|
+
if area & self.storming and area & looped:
|
|
224
|
+
still |= area & self.storming
|
|
225
|
+
ended = self.storming - still
|
|
226
|
+
if ended:
|
|
227
|
+
self.storming = still
|
|
228
|
+
for s in ended:
|
|
229
|
+
self.mac_flaps.pop(s, None)
|
|
230
|
+
notes.append(f"broadcast storm over: {' '.join(sorted(ended, key=self.bridge_id))} "
|
|
231
|
+
f"(loop broken)")
|
|
232
|
+
return notes
|
|
233
|
+
|
|
234
|
+
# ---- flooding ----------------------------------------------------------
|
|
235
|
+
def flood(self, src: tuple[str, int]) -> Flood:
|
|
236
|
+
"""Send one broadcast frame out of an interface and follow every copy.
|
|
237
|
+
|
|
238
|
+
Switches copy it out of every forwarding port except the one it came in
|
|
239
|
+
on; anything else just receives it. Copies are counted exactly per
|
|
240
|
+
(cable, direction), so even an exponential storm stays cheap to model.
|
|
241
|
+
A loop-free area empties within as many hops as there are cables; if
|
|
242
|
+
frames are still alive after that, they are circling: a storm.
|
|
243
|
+
"""
|
|
244
|
+
out = Flood(source=src)
|
|
245
|
+
li0 = self.link_index(*src)
|
|
246
|
+
if li0 is None:
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
def ok(l: Link) -> bool:
|
|
250
|
+
return (self._carries(l, honor_stp=True)
|
|
251
|
+
and l.a not in self.storming and l.b not in self.storming)
|
|
252
|
+
|
|
253
|
+
if not ok(self.links[li0]):
|
|
254
|
+
return out
|
|
255
|
+
state: Counter = Counter({(li0, self.links[li0].other(src[0])[0]): 1})
|
|
256
|
+
for _ in range(len(self.links) + 2):
|
|
257
|
+
out.generations.append(dict(state))
|
|
258
|
+
nxt: Counter = Counter()
|
|
259
|
+
for (li, dev), n in state.items():
|
|
260
|
+
port = self.links[li].port_of(dev)
|
|
261
|
+
if self.devices[dev].kind != "s":
|
|
262
|
+
out.delivered[(dev, port)] += n
|
|
263
|
+
continue
|
|
264
|
+
out.flaps.setdefault(dev, set()).add(port)
|
|
265
|
+
for lj, l in enumerate(self.links):
|
|
266
|
+
if lj != li and l.touches(dev) and ok(l):
|
|
267
|
+
nxt[(lj, l.other(dev)[0])] += n
|
|
268
|
+
if not nxt:
|
|
269
|
+
break
|
|
270
|
+
state = nxt
|
|
271
|
+
else:
|
|
272
|
+
out.storm = True
|
|
273
|
+
# Everything the circling frames touched over the last lap (a loop can't
|
|
274
|
+
# be longer than the number of cables), not just where they are now.
|
|
275
|
+
lap = out.generations[-(len(self.links) + 1):] + [dict(state)]
|
|
276
|
+
out.storm_links = {li for gen in lap for li, _ in gen}
|
|
277
|
+
out.storm_switches = {d for li in out.storm_links for d in (self.links[li].a, self.links[li].b)
|
|
278
|
+
if self.devices[d].kind == "s"}
|
|
279
|
+
out.flaps = {s: ps for s, ps in out.flaps.items() if len(ps) > 1}
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
def start_storm(self, flood: Flood) -> None:
|
|
283
|
+
"""Mark a storming area: its cables carry nothing but the storm from now on."""
|
|
284
|
+
mac = self.devices[flood.source[0]].mac(flood.source[1])
|
|
285
|
+
area = set()
|
|
286
|
+
for a in self._areas(self._trunks(honor_stp=True)):
|
|
287
|
+
if a & flood.storm_switches:
|
|
288
|
+
area |= a
|
|
289
|
+
self.storming |= area
|
|
290
|
+
for s, ports in flood.flaps.items():
|
|
291
|
+
self.mac_flaps.setdefault(s, {})[mac] = ports
|
|
292
|
+
|
|
293
|
+
def learn_flood(self, flood: Flood) -> None:
|
|
294
|
+
"""Switches a (loop-free) broadcast passed through learn its sender's MAC."""
|
|
295
|
+
mac = self.devices[flood.source[0]].mac(flood.source[1])
|
|
296
|
+
for gen in flood.generations:
|
|
297
|
+
for li, dev in gen:
|
|
298
|
+
if self.devices[dev].kind == "s":
|
|
299
|
+
self.mac_tables.setdefault(dev, {})[mac] = self.links[li].port_of(dev)
|
|
300
|
+
|
|
301
|
+
def storm_near(self, dev_id: str) -> set[str]:
|
|
302
|
+
"""Storming switches in the switched area(s) this device is plugged into."""
|
|
303
|
+
seen, q, hit = {dev_id}, deque([dev_id]), set()
|
|
304
|
+
while q:
|
|
305
|
+
u = q.popleft()
|
|
306
|
+
for l in self.links:
|
|
307
|
+
if l.touches(u) and self._carries(l, honor_stp=True):
|
|
308
|
+
v = l.other(u)[0]
|
|
309
|
+
if v in seen or self.devices[v].kind != "s":
|
|
310
|
+
continue
|
|
311
|
+
seen.add(v)
|
|
312
|
+
q.append(v)
|
|
313
|
+
if v in self.storming:
|
|
314
|
+
hit.add(v)
|
|
315
|
+
return hit
|