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/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ByteSim: a text-mode network simulator.
|
|
2
|
+
|
|
3
|
+
The model (``bytesim.model``) can be used on its own::
|
|
4
|
+
|
|
5
|
+
from bytesim import Network
|
|
6
|
+
net = Network()
|
|
7
|
+
net.add("r"); net.add("c")
|
|
8
|
+
net.connect("r1", None, "c1", None)
|
|
9
|
+
"""
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .model import Device, Iface, Link, NetError, Network # noqa: E402
|
|
13
|
+
|
|
14
|
+
__all__ = ["Device", "Iface", "Link", "NetError", "Network", "__version__"]
|
bytesim/__main__.py
ADDED
bytesim/cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Command-line entry point: ``bytesim [FILE] [--demo NAME] [--run CMD ...]``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
p = argparse.ArgumentParser(
|
|
13
|
+
prog="bytesim",
|
|
14
|
+
description="A text-mode network simulator: gateways, routers, switches and computers, "
|
|
15
|
+
"cabled together and simulated in your terminal.")
|
|
16
|
+
p.add_argument("file", nargs="?", type=Path,
|
|
17
|
+
help="a saved network (.json) or a topology (.yaml) to open")
|
|
18
|
+
p.add_argument("--demo", choices=["basic", "ha", "loop"], help="start with a sample network")
|
|
19
|
+
p.add_argument("--run", "-c", metavar="CMD", action="append", default=[],
|
|
20
|
+
help="a console command to run at startup (repeatable)")
|
|
21
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
22
|
+
return p
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> None:
|
|
26
|
+
args = build_parser().parse_args(argv)
|
|
27
|
+
from .model import NetError, Network
|
|
28
|
+
from .topology import load_topology, looks_like_topology
|
|
29
|
+
from .tui import ByteSimApp
|
|
30
|
+
|
|
31
|
+
network, topology = None, False
|
|
32
|
+
if args.file is not None:
|
|
33
|
+
try:
|
|
34
|
+
text = args.file.read_text()
|
|
35
|
+
topology = looks_like_topology(text)
|
|
36
|
+
network = load_topology(text) if topology else Network.from_json(text)
|
|
37
|
+
except (OSError, ValueError, KeyError, TypeError, NetError) as e:
|
|
38
|
+
sys.exit(f"bytesim: couldn't open {args.file}: {e}")
|
|
39
|
+
startup = ([f"demo {args.demo}"] if args.demo else []) + args.run
|
|
40
|
+
# A topology carries no positions, so it starts auto-arranged.
|
|
41
|
+
ByteSimApp(network=network, startup=startup, auto=True if topology else None).run()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
bytesim/constants.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Device kinds, sizes and the token grammar shared by the model and the UI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
KINDS = {
|
|
7
|
+
"g": ("gateway", 4, "magenta"),
|
|
8
|
+
"r": ("router", 4, "cyan"),
|
|
9
|
+
"s": ("switch", 8, "green"),
|
|
10
|
+
"c": ("computer", 1, "dodger_blue1"),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
RANK = {"g": 0, "r": 1, "s": 2, "c": 3}
|
|
14
|
+
|
|
15
|
+
ALIASES = {
|
|
16
|
+
"g": "g", "gw": "g", "gateway": "g",
|
|
17
|
+
"r": "r", "router": "r",
|
|
18
|
+
"s": "s", "sw": "s", "switch": "s",
|
|
19
|
+
"c": "c", "pc": "c", "computer": "c", "host": "c",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
MAX_PORTS = 64
|
|
23
|
+
|
|
24
|
+
BOX_H = 4 # top border, label, port row, bottom border
|
|
25
|
+
|
|
26
|
+
LAYER_GAP = 5 # empty rows between layers (room for link routing)
|
|
27
|
+
|
|
28
|
+
H_GAP = 4 # columns between neighbouring boxes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Auto-numbering: prefix(n) stands for n new names, each prefix + the lowest free number.
|
|
32
|
+
# s(2) → two new switches; fab(1) → one new group named fab<next>.
|
|
33
|
+
AUTO = re.compile(r"^([A-Za-z][\w-]*?)\((\d+)\)$")
|
|
34
|
+
REF = re.compile(r"^([grsc])(\d+)(?::(?:p|nic|eth)?(\d+))?$", re.I)
|
|
35
|
+
|
|
36
|
+
GROUP_NAME = re.compile(r"^[a-z][\w-]{0,15}$", re.I)
|
|
37
|
+
|
|
38
|
+
ADDR = re.compile(r"^\(?(\d+),(\d+)\)?$") # (network, host) e.g. (2,14) or 2,14
|
|
39
|
+
|
|
40
|
+
HOST_MAX = 255 # 8-bit host numbers: 1..254, 255 = broadcast
|
|
41
|
+
|
|
42
|
+
VIP_HOST = 254 # virtual gateway shared by an HA router cluster
|
|
43
|
+
|
|
44
|
+
POOL = range(10, VIP_HOST) # DHCP / ad-hoc hosts; routers take 1, 2, ...
|
|
45
|
+
|
|
46
|
+
ROLES = ("data", "control", "control-backup", "fabric")
|
|
47
|
+
|
|
48
|
+
POLICIES = ("none", "quorum", "witness")
|
bytesim/demos.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Ready-made networks. Each builder returns (network, hint for the console)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from .model import Network
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def basic() -> tuple[Network, str]:
|
|
10
|
+
"""A gateway, two routers, three switches and ten computers (two of them an ad-hoc pair)."""
|
|
11
|
+
net = Network()
|
|
12
|
+
net.add("g", 2)
|
|
13
|
+
for _ in range(2):
|
|
14
|
+
net.add("r", 4)
|
|
15
|
+
for _ in range(3):
|
|
16
|
+
net.add("s", 6)
|
|
17
|
+
for i in range(10):
|
|
18
|
+
net.add("c", 2 if i == 2 else 1)
|
|
19
|
+
for a, b in [("g1", "r1"), ("r1", "r2"), ("r1", "s1"), ("r1", "s2"), ("r2", "s3"),
|
|
20
|
+
("s1", "c1"), ("s1", "c2"), ("s1", "c3"), ("s2", "c3"), ("s2", "c4"),
|
|
21
|
+
("s2", "c5"), ("s3", "c6"), ("s3", "c7"), ("s3", "c8"),
|
|
22
|
+
("c9", "c10")]:
|
|
23
|
+
net.connect(a, None, b, None)
|
|
24
|
+
net.group_add("core", ["r1", "r2"])
|
|
25
|
+
net.group_add("fab1", ["s1", "s2", "s3"])
|
|
26
|
+
return net, ("[green]demo network loaded[/] — try [cyan]ping c1 c8[/], [cyan]mcast c1 c4 c6 c8[/], "
|
|
27
|
+
"[cyan]dhcp c4[/], [cyan]ip[/], or [cyan]ping c9 c10[/] (an ad-hoc pair, no router)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ha() -> tuple[Network, str]:
|
|
31
|
+
"""Two fabrics joined by a clustered router pair with redundant control links."""
|
|
32
|
+
net = Network()
|
|
33
|
+
for _ in range(2):
|
|
34
|
+
net.add("r", 6)
|
|
35
|
+
for _ in range(4):
|
|
36
|
+
net.add("s", 6)
|
|
37
|
+
for _ in range(6):
|
|
38
|
+
net.add("c", 1)
|
|
39
|
+
for a, pa, b in [("s1", None, "s2"), ("s3", None, "s4"), ("r1", 1, "s1"), ("r2", 1, "s2"),
|
|
40
|
+
("r1", 2, "s3"), ("r2", 2, "s4"),
|
|
41
|
+
("s1", None, "c1"), ("s1", None, "c2"), ("s2", None, "c3"),
|
|
42
|
+
("s3", None, "c4"), ("s3", None, "c5"), ("s4", None, "c6")]:
|
|
43
|
+
net.connect(a, pa, b, None)
|
|
44
|
+
net.connect("r1", 5, "r2", 5, "control")
|
|
45
|
+
net.connect("r1", 6, "r2", 6, "control-backup")
|
|
46
|
+
net.connect("r1", 4, "r2", 4, "fabric")
|
|
47
|
+
net.group_add("fab1", ["s1", "s2"])
|
|
48
|
+
net.group_add("fab2", ["s3", "s4"])
|
|
49
|
+
net.group_add("rfab1", ["r1", "r2"])
|
|
50
|
+
net.clusters["rfab1"] = "none"
|
|
51
|
+
return net, ("[green]HA demo loaded[/]: r1+r2 form cluster rfab1 between fab1 and fab2.\n"
|
|
52
|
+
" try [cyan]ping c1 c6[/], then [cyan]fail r1:5[/], [cyan]fail r1:6[/] (split-brain), "
|
|
53
|
+
"[cyan]cluster rfab1 policy=witness[/], [cyan]fail r1[/], [cyan]fail r1:2[/], [cyan]restore all[/]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def loop() -> tuple[Network, str]:
|
|
57
|
+
"""Three switches cabled in a triangle with spanning tree switched off."""
|
|
58
|
+
net = Network()
|
|
59
|
+
net.add("r", 2)
|
|
60
|
+
for _ in range(3):
|
|
61
|
+
net.add("s", 6)
|
|
62
|
+
for _ in range(3):
|
|
63
|
+
net.add("c", 1)
|
|
64
|
+
for a, b in [("r1", "s1"), ("s1", "s2"), ("s2", "s3"), ("s3", "s1"),
|
|
65
|
+
("s1", "c1"), ("s2", "c2"), ("s3", "c3")]:
|
|
66
|
+
net.connect(a, None, b, None)
|
|
67
|
+
net.group_add("ring", ["s1", "s2", "s3"])
|
|
68
|
+
net.stp_off = {"s1", "s2", "s3"}
|
|
69
|
+
return net, ("[green]loop demo loaded[/]: s1–s2–s3 form a triangle and STP is off.\n"
|
|
70
|
+
" try [cyan]broadcast c1[/] (or [cyan]ping c1 c2[/]) to set off a storm, then "
|
|
71
|
+
"[cyan]stp on[/], [cyan]stp[/], [cyan]fail s1:2[/], [cyan]ping c1 c2[/]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
DEMOS: dict[str, Callable[[], tuple[Network, str]]] = {"basic": basic, "ha": ha, "loop": loop}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Addresses as (network, 8-bit host); router autoconfig, DHCP and ad-hoc."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..constants import HOST_MAX, POOL, VIP_HOST
|
|
5
|
+
from .base import NetworkBase
|
|
6
|
+
from .types import Iface, NetError, fmt
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AddressingMixin(NetworkBase):
|
|
10
|
+
def iface(self, dev_id: str, port: int) -> Iface:
|
|
11
|
+
key = f"{dev_id}:{port}"
|
|
12
|
+
if key not in self.ifaces:
|
|
13
|
+
self.ifaces[key] = Iface("dhcp" if self.devices[dev_id].kind == "c" else "auto")
|
|
14
|
+
return self.ifaces[key]
|
|
15
|
+
|
|
16
|
+
def addr_of(self, ifc: tuple[str, int]) -> tuple[int, int] | None:
|
|
17
|
+
i = self.ifaces.get(f"{ifc[0]}:{ifc[1]}")
|
|
18
|
+
return i.addr if i else None
|
|
19
|
+
|
|
20
|
+
def addressed(self, dev_id: str) -> list[tuple[int, Iface]]:
|
|
21
|
+
dev = self.devices[dev_id]
|
|
22
|
+
if dev.kind == "s":
|
|
23
|
+
return []
|
|
24
|
+
return [(p, self.iface(dev_id, p)) for p in range(1, dev.nports + 1)
|
|
25
|
+
if self.iface(dev_id, p).addr]
|
|
26
|
+
|
|
27
|
+
def hosts_on(self, net: int) -> set[int]:
|
|
28
|
+
return {i.host for i in self.ifaces.values() if i.net == net and i.host is not None}
|
|
29
|
+
|
|
30
|
+
def resolve(self, addr: tuple[int, int]) -> tuple[str, int] | None:
|
|
31
|
+
if addr[1] == VIP_HOST and (owners := self.vip_owners(addr[0])):
|
|
32
|
+
return owners[0]
|
|
33
|
+
for key, i in self.ifaces.items():
|
|
34
|
+
if i.addr == addr:
|
|
35
|
+
d, p = key.split(":")
|
|
36
|
+
return d, int(p)
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def local_net(self, nic: tuple[str, int]) -> int | None:
|
|
40
|
+
"""Network id already in use on this broadcast domain (routers' first)."""
|
|
41
|
+
seg = sorted(self.segment(*nic), key=lambda e: not self.is_router(e[0]))
|
|
42
|
+
return next((self.addr_of(e)[0] for e in seg if self.addr_of(e)), None)
|
|
43
|
+
|
|
44
|
+
def new_net(self) -> int:
|
|
45
|
+
used = {i.net for i in self.ifaces.values() if i.net is not None}
|
|
46
|
+
return next(n for n in range(1, 10**6) if n not in used)
|
|
47
|
+
|
|
48
|
+
def autoconf_routers(self) -> list[str]:
|
|
49
|
+
"""Give every cabled router/gateway port an address on its local network,
|
|
50
|
+
opening a new network id if the segment doesn't have one yet."""
|
|
51
|
+
notes = []
|
|
52
|
+
for dev in self.sorted_devices():
|
|
53
|
+
if dev.kind not in "rg":
|
|
54
|
+
continue
|
|
55
|
+
for p in range(1, dev.nports + 1):
|
|
56
|
+
ifc = self.iface(dev.id, p)
|
|
57
|
+
if ifc.mode != "auto":
|
|
58
|
+
continue
|
|
59
|
+
li = self.link_index(dev.id, p)
|
|
60
|
+
if li is None or self.links[li].control:
|
|
61
|
+
ifc.clear() # unplugged, or a control link: no data address
|
|
62
|
+
continue
|
|
63
|
+
if not self.usable(self.links[li]):
|
|
64
|
+
continue # failed: keep the config for when it comes back
|
|
65
|
+
local = self.local_net((dev.id, p))
|
|
66
|
+
if ifc.addr and (local is None or local == ifc.net):
|
|
67
|
+
continue
|
|
68
|
+
net = local if local is not None else self.new_net()
|
|
69
|
+
used = self.hosts_on(net)
|
|
70
|
+
ifc.net, ifc.gw, ifc.via = net, None, "auto"
|
|
71
|
+
ifc.host = next(h for h in range(1, HOST_MAX) if h not in used)
|
|
72
|
+
notes.append(f"{dev.id}:{dev.port_name(p)} → {fmt(ifc.addr)}")
|
|
73
|
+
return notes
|
|
74
|
+
|
|
75
|
+
def dhcp_server(self, client: tuple[str, int]) -> tuple[str, int] | None:
|
|
76
|
+
for e in self.segment(*client):
|
|
77
|
+
if self.is_router(e[0]) and e[0] not in self.dhcp_off and self.addr_of(e):
|
|
78
|
+
return e
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def _free_host(self, net: int, ifc: Iface) -> int:
|
|
82
|
+
used = self.hosts_on(net) - ({ifc.host} if ifc.net == net else set())
|
|
83
|
+
if ifc.net == net and ifc.host in POOL and ifc.host not in used:
|
|
84
|
+
return ifc.host # keep the number we already had on this network
|
|
85
|
+
host = next((h for h in POOL if h not in used), None)
|
|
86
|
+
if host is None:
|
|
87
|
+
raise NetError(f"network {net} is full (no free 8-bit host numbers)")
|
|
88
|
+
return host
|
|
89
|
+
|
|
90
|
+
def lease(self, client: tuple[str, int]) -> tuple[str, tuple[str, int] | None]:
|
|
91
|
+
"""DHCP for a client NIC (falls back to ad-hoc). Returns (outcome, server)."""
|
|
92
|
+
ifc = self.iface(*client)
|
|
93
|
+
server = self.dhcp_server(client)
|
|
94
|
+
if server is None:
|
|
95
|
+
self.adhoc(client)
|
|
96
|
+
return "adhoc", None
|
|
97
|
+
snet, _ = self.addr_of(server)
|
|
98
|
+
gw = self.expected_gw(server)
|
|
99
|
+
if ifc.via == "dhcp" and ifc.net == snet and ifc.gw == gw:
|
|
100
|
+
return "renew", server
|
|
101
|
+
ifc.host = self._free_host(snet, ifc)
|
|
102
|
+
ifc.net, ifc.gw, ifc.via = snet, gw, "dhcp"
|
|
103
|
+
return "lease", server
|
|
104
|
+
|
|
105
|
+
def expected_gw(self, server: tuple[str, int]) -> int:
|
|
106
|
+
"""Gateway a DHCP server hands out: its own host, or its cluster's virtual one."""
|
|
107
|
+
return VIP_HOST if self.cluster_of(server[0]) else self.addr_of(server)[1]
|
|
108
|
+
|
|
109
|
+
def adhoc(self, nic: tuple[str, int]) -> None:
|
|
110
|
+
"""Take an unused host number on the local network, no server involved."""
|
|
111
|
+
ifc = self.iface(*nic)
|
|
112
|
+
local = self.local_net(nic)
|
|
113
|
+
if ifc.via == "adhoc" and ifc.addr and local in (None, ifc.net):
|
|
114
|
+
return
|
|
115
|
+
net = local if local is not None else self.new_net()
|
|
116
|
+
ifc.host = self._free_host(net, ifc)
|
|
117
|
+
ifc.net, ifc.gw, ifc.via = net, None, "adhoc"
|
|
118
|
+
|
|
119
|
+
def reconcile(self, skip: frozenset[tuple[str, int]] = frozenset()) -> list[str]:
|
|
120
|
+
"""Bring every address in line with the current cabling. Returns what changed.
|
|
121
|
+
|
|
122
|
+
STP runs first (it decides which cables forward). Router ports
|
|
123
|
+
autoconfigure; unplugged client NICs release; DHCP clients
|
|
124
|
+
(re)lease when a server is reachable, keep a lease when none is, and fall
|
|
125
|
+
back to ad-hoc when they have nothing. ``skip`` leaves some NICs alone
|
|
126
|
+
(the UI animates their DHCP exchange itself).
|
|
127
|
+
"""
|
|
128
|
+
notes = self.stp_update() # type: ignore[attr-defined]
|
|
129
|
+
notes += [f"addr {n}" for n in self.autoconf_routers()]
|
|
130
|
+
for d in self.sorted_devices():
|
|
131
|
+
if d.kind != "c":
|
|
132
|
+
continue
|
|
133
|
+
for p in range(1, d.nports + 1):
|
|
134
|
+
nic, ifc = (d.id, p), self.iface(d.id, p)
|
|
135
|
+
name = f"{d.id}:{d.port_name(p)}"
|
|
136
|
+
if self.link_index(d.id, p) is None:
|
|
137
|
+
if ifc.mode in ("dhcp", "adhoc") and ifc.addr:
|
|
138
|
+
notes.append(f"{name} link down: released {fmt(ifc.addr)}")
|
|
139
|
+
ifc.clear()
|
|
140
|
+
continue
|
|
141
|
+
if nic in skip:
|
|
142
|
+
continue
|
|
143
|
+
old = ifc.addr
|
|
144
|
+
try:
|
|
145
|
+
if ifc.mode == "adhoc":
|
|
146
|
+
self.adhoc(nic)
|
|
147
|
+
elif ifc.mode == "dhcp":
|
|
148
|
+
server = self.dhcp_server(nic)
|
|
149
|
+
if server is None:
|
|
150
|
+
# Nobody to ask: keep a lease we hold, else go ad-hoc.
|
|
151
|
+
if not ifc.addr or ifc.via == "adhoc":
|
|
152
|
+
self.adhoc(nic)
|
|
153
|
+
elif (ifc.via != "dhcp" or ifc.net != self.addr_of(server)[0]
|
|
154
|
+
or ifc.gw != self.expected_gw(server)):
|
|
155
|
+
self.lease(nic)
|
|
156
|
+
except NetError as e:
|
|
157
|
+
notes.append(f"{name}: {e}")
|
|
158
|
+
if ifc.addr != old:
|
|
159
|
+
notes.append(f"{name} → {fmt(ifc.addr)} via {ifc.via}")
|
|
160
|
+
return notes
|
bytesim/model/base.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""State shared by every part of the network model, plus the basic lookups."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..constants import KINDS, RANK
|
|
5
|
+
from .types import Device, Iface, Link, NetError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NetworkBase:
|
|
9
|
+
"""Holds the network's state. The mixins in this package add behaviour on top."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.devices: dict[str, Device] = {}
|
|
13
|
+
self.links: list[Link] = []
|
|
14
|
+
self.counters = {k: 0 for k in KINDS}
|
|
15
|
+
self.groups: dict[str, list[str]] = {} # group -> member device ids / group names
|
|
16
|
+
self.ifaces: dict[str, Iface] = {} # "c1:1" -> Iface
|
|
17
|
+
self.dhcp_off: set[str] = set() # routers/gateways not serving DHCP
|
|
18
|
+
self.mac_tables: dict[str, dict[str, int]] = {} # switch -> {mac: port}
|
|
19
|
+
self.clusters: dict[str, str] = {} # HA cluster (a group of routers) -> split policy
|
|
20
|
+
self.notes: list[str] = [] # remarks from the last route() for the console
|
|
21
|
+
self.stp_off: set[str] = set() # switches with spanning tree switched off
|
|
22
|
+
self.stp_priority: dict[str, int] = {} # bridge priority (default 32768; lower = root)
|
|
23
|
+
self.stp_blocked: set[tuple[str, int]] = set() # (switch, port) held blocking by STP
|
|
24
|
+
self.stp_roots: dict[str, str] = {} # switch -> its area's root bridge
|
|
25
|
+
self.stp_root_ports: dict[str, int] = {} # switch -> port toward the root
|
|
26
|
+
self.storming: set[str] = set() # switches drowning in a broadcast storm
|
|
27
|
+
self.mac_flaps: dict[str, dict[str, set[int]]] = {} # switch -> mac -> ports it flapped between
|
|
28
|
+
|
|
29
|
+
def get(self, dev_id: str) -> Device:
|
|
30
|
+
try:
|
|
31
|
+
return self.devices[dev_id.lower()]
|
|
32
|
+
except KeyError:
|
|
33
|
+
raise NetError(f"no such device: {dev_id}") from None
|
|
34
|
+
|
|
35
|
+
def sorted_devices(self) -> list[Device]:
|
|
36
|
+
return sorted(self.devices.values(), key=lambda d: (RANK[d.kind], d.num))
|
|
37
|
+
|
|
38
|
+
def link_on(self, dev_id: str, port: int) -> Link | None:
|
|
39
|
+
for l in self.links:
|
|
40
|
+
if (l.a == dev_id and l.pa == port) or (l.b == dev_id and l.pb == port):
|
|
41
|
+
return l
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def link_index(self, dev_id: str, port: int) -> int | None:
|
|
45
|
+
for i, l in enumerate(self.links):
|
|
46
|
+
if (l.a == dev_id and l.pa == port) or (l.b == dev_id and l.pb == port):
|
|
47
|
+
return i
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def free_port(self, dev: Device) -> int | None:
|
|
51
|
+
for p in range(1, dev.nports + 1):
|
|
52
|
+
if self.link_on(dev.id, p) is None:
|
|
53
|
+
return p
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def is_router(self, dev_id: str) -> bool:
|
|
57
|
+
return self.devices[dev_id].kind in "rg"
|
bytesim/model/cloning.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Cloning a group: its devices, nested groups and internal cables."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ..constants import GROUP_NAME, RANK, REF
|
|
8
|
+
from .base import NetworkBase
|
|
9
|
+
from .types import Device, Iface, Link, NetError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CloneResult:
|
|
14
|
+
group: str # the new top-level group
|
|
15
|
+
devices: dict[str, str] # old device id -> new device id
|
|
16
|
+
groups: dict[str, str] # old group name -> new group name
|
|
17
|
+
cables: int # internal cables copied
|
|
18
|
+
skipped: int # cables to the outside, not copied
|
|
19
|
+
rewired: int = 0 # outside cables re-made onto new endpoints
|
|
20
|
+
moved: list[str] | None = None # "r3:p6→p7": ports that couldn't be kept
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CloningMixin(NetworkBase):
|
|
24
|
+
def next_group_name(self, base: str, reserved: frozenset[str] = frozenset()) -> str:
|
|
25
|
+
"""A free group name like base: fab1 → fab2 (or fab3…), core → core-2."""
|
|
26
|
+
m = re.match(r"^(.*?)(\d+)$", base)
|
|
27
|
+
stem, n, sep = (m[1], int(m[2]), "") if m else (base, 1, "-")
|
|
28
|
+
while True:
|
|
29
|
+
n += 1
|
|
30
|
+
name = f"{stem}{sep}{n}"[-16:]
|
|
31
|
+
if (name not in self.groups and name not in self.devices and name not in reserved
|
|
32
|
+
and not REF.match(name)):
|
|
33
|
+
return name
|
|
34
|
+
|
|
35
|
+
def endpoint_map(self, pairs: list[tuple[str, str]], group: str) -> dict[str, str]:
|
|
36
|
+
"""Old outside endpoint → new one, from (old, new) pairs of groups or devices.
|
|
37
|
+
|
|
38
|
+
Group members are matched by position, so the two sides need the same size.
|
|
39
|
+
Neither side may be inside the group being cloned."""
|
|
40
|
+
inside = set(self.devices_in(group)) # type: ignore[attr-defined]
|
|
41
|
+
|
|
42
|
+
def expand(ref: str) -> list[str]:
|
|
43
|
+
ref = ref.lower()
|
|
44
|
+
if ref in self.groups:
|
|
45
|
+
return self.devices_in(ref) # type: ignore[attr-defined]
|
|
46
|
+
if ref in self.devices:
|
|
47
|
+
return [ref]
|
|
48
|
+
raise NetError(f"{ref} is neither a group nor a device")
|
|
49
|
+
|
|
50
|
+
mapping: dict[str, str] = {}
|
|
51
|
+
for old, new in pairs:
|
|
52
|
+
a, b = expand(old), expand(new)
|
|
53
|
+
if len(a) != len(b):
|
|
54
|
+
raise NetError(f"{old} has {len(a)} devices but {new} has {len(b)}: they're matched one to one")
|
|
55
|
+
for x, y in zip(a, b):
|
|
56
|
+
if x in inside or y in inside:
|
|
57
|
+
raise NetError(f"{x if x in inside else y} is inside {group}: endpoints must be outside it")
|
|
58
|
+
if x in mapping:
|
|
59
|
+
raise NetError(f"{x} is listed twice")
|
|
60
|
+
if self.devices[x].kind != self.devices[y].kind:
|
|
61
|
+
raise NetError(f"{x} is a {self.devices[x].name} but {y} is a {self.devices[y].name}")
|
|
62
|
+
mapping[x] = y
|
|
63
|
+
return mapping
|
|
64
|
+
|
|
65
|
+
def clone_group(self, name: str, new_name: str | None = None, dx: int = 0, dy: int = 0,
|
|
66
|
+
rewire: list[tuple[str, str]] | None = None) -> CloneResult:
|
|
67
|
+
"""Copy a group: every device in it (nested groups too), every cable with
|
|
68
|
+
both ends inside, the group tree, cluster policies, STP and DHCP-server
|
|
69
|
+
settings. Cables to the outside are not copied, unless ``rewire`` says where
|
|
70
|
+
they go: pairs of (old endpoint, new endpoint), groups or devices, e.g.
|
|
71
|
+
[("rfab1", "rfab2"), ("rfab2", "rfab3")]. A cable from the group to a device
|
|
72
|
+
of the old endpoint is re-made from the copy to the matching device of the
|
|
73
|
+
new one (same port number there if it's free, else the first free one).
|
|
74
|
+
|
|
75
|
+
Copies start healthy (nothing failed) and without addresses: interfaces keep
|
|
76
|
+
their mode, except static ones, which would clash, so they become DHCP
|
|
77
|
+
(computers) or automatic (routers). The new group joins the original's
|
|
78
|
+
parent. dx/dy shift the copies' positions.
|
|
79
|
+
"""
|
|
80
|
+
if name not in self.groups:
|
|
81
|
+
raise NetError(f"no such group: {name}")
|
|
82
|
+
if new_name is not None:
|
|
83
|
+
if not GROUP_NAME.match(new_name) or REF.match(new_name):
|
|
84
|
+
raise NetError(f"bad group name {new_name!r}: start with a letter and must not look like a device id")
|
|
85
|
+
if new_name in self.groups or new_name in self.devices:
|
|
86
|
+
raise NetError(f"{new_name} already exists")
|
|
87
|
+
|
|
88
|
+
members = self.devices_in(name) # type: ignore[attr-defined]
|
|
89
|
+
inside = set(members)
|
|
90
|
+
ends = self.endpoint_map(rewire or [], name)
|
|
91
|
+
|
|
92
|
+
# Check room on every new endpoint before creating anything.
|
|
93
|
+
outside = [l for l in self.links if (l.a in inside) != (l.b in inside)]
|
|
94
|
+
need: dict[str, int] = {}
|
|
95
|
+
for l in outside:
|
|
96
|
+
far = l.b if l.a in inside else l.a
|
|
97
|
+
if far in ends:
|
|
98
|
+
need[ends[far]] = need.get(ends[far], 0) + 1
|
|
99
|
+
for dev, n in need.items():
|
|
100
|
+
free = sum(1 for p in range(1, self.devices[dev].nports + 1) if self.link_on(dev, p) is None)
|
|
101
|
+
if free < n:
|
|
102
|
+
d = self.devices[dev]
|
|
103
|
+
raise NetError(f"{dev} needs {n} free port{'s' if n != 1 else ''} for the copy but has {free} "
|
|
104
|
+
f"(try `ports {dev} {d.nports + n - free}`)")
|
|
105
|
+
|
|
106
|
+
# Devices, in a stable order so new numbers follow the old ones.
|
|
107
|
+
dev_map: dict[str, str] = {}
|
|
108
|
+
for old in sorted(members, key=lambda d: (RANK[d[0]], int(d[1:]))):
|
|
109
|
+
src: Device = self.devices[old]
|
|
110
|
+
new: Device = self.add(src.kind, src.nports) # type: ignore[attr-defined]
|
|
111
|
+
new.x, new.y = src.x + dx, src.y + dy
|
|
112
|
+
dev_map[old] = new.id
|
|
113
|
+
|
|
114
|
+
# Internal cables keep their ports and roles. Outside ones are re-made onto
|
|
115
|
+
# their new endpoint if one was given, else left behind.
|
|
116
|
+
cables = skipped = rewired = 0
|
|
117
|
+
moved: list[str] = []
|
|
118
|
+
for l in list(self.links):
|
|
119
|
+
if l.a in inside and l.b in inside:
|
|
120
|
+
self.links.append(Link(dev_map[l.a], l.pa, dev_map[l.b], l.pb, l.role))
|
|
121
|
+
cables += 1
|
|
122
|
+
elif l.a in inside or l.b in inside:
|
|
123
|
+
near, near_port = (l.a, l.pa) if l.a in inside else (l.b, l.pb)
|
|
124
|
+
far, far_port = (l.b, l.pb) if l.a in inside else (l.a, l.pa)
|
|
125
|
+
if far not in ends:
|
|
126
|
+
skipped += 1
|
|
127
|
+
continue
|
|
128
|
+
target = ends[far]
|
|
129
|
+
port = self._port_like(target, far_port)
|
|
130
|
+
if port != far_port:
|
|
131
|
+
moved.append(f"{target}:p{far_port}→p{port}")
|
|
132
|
+
self.links.append(Link(dev_map[near], near_port, target, port, l.role))
|
|
133
|
+
rewired += 1
|
|
134
|
+
|
|
135
|
+
# The group tree, depth first, so every nested group gets a fresh name.
|
|
136
|
+
group_map: dict[str, str] = {}
|
|
137
|
+
|
|
138
|
+
def copy(g: str, forced: str | None) -> str:
|
|
139
|
+
new_g = forced or self.next_group_name(g)
|
|
140
|
+
self.groups[new_g] = [] # reserve the name before naming children
|
|
141
|
+
group_map[g] = new_g
|
|
142
|
+
self.groups[new_g] = [copy(m, None) if m in self.groups else dev_map[m]
|
|
143
|
+
for m in list(self.groups[g])]
|
|
144
|
+
if g in self.clusters:
|
|
145
|
+
self.clusters[new_g] = self.clusters[g]
|
|
146
|
+
return new_g
|
|
147
|
+
|
|
148
|
+
top = copy(name, new_name)
|
|
149
|
+
parent = self.parent_of(name) # type: ignore[attr-defined]
|
|
150
|
+
if parent is not None:
|
|
151
|
+
siblings = self.groups[parent]
|
|
152
|
+
siblings.insert(siblings.index(name) + 1, top)
|
|
153
|
+
|
|
154
|
+
# Per-device settings travel with the copy; addresses don't.
|
|
155
|
+
for old, new in dev_map.items():
|
|
156
|
+
if old in self.stp_off:
|
|
157
|
+
self.stp_off.add(new)
|
|
158
|
+
if old in self.stp_priority:
|
|
159
|
+
self.stp_priority[new] = self.stp_priority[old]
|
|
160
|
+
if old in self.dhcp_off:
|
|
161
|
+
self.dhcp_off.add(new)
|
|
162
|
+
for p in range(1, self.devices[old].nports + 1):
|
|
163
|
+
ifc = self.ifaces.get(f"{old}:{p}")
|
|
164
|
+
if ifc is None:
|
|
165
|
+
continue
|
|
166
|
+
mode = ifc.mode
|
|
167
|
+
if mode == "static":
|
|
168
|
+
mode = "dhcp" if self.devices[old].kind == "c" else "auto"
|
|
169
|
+
self.ifaces[f"{new}:{p}"] = Iface(mode)
|
|
170
|
+
return CloneResult(top, dev_map, group_map, cables, skipped, rewired, moved)
|
|
171
|
+
|
|
172
|
+
def _port_like(self, dev: str, port: int) -> int:
|
|
173
|
+
"""The same port number if it's free on dev, else dev's first free port."""
|
|
174
|
+
if port <= self.devices[dev].nports and self.link_on(dev, port) is None:
|
|
175
|
+
return port
|
|
176
|
+
free = self.free_port(self.devices[dev])
|
|
177
|
+
if free is None:
|
|
178
|
+
raise NetError(f"{dev} has no free ports")
|
|
179
|
+
return free
|
|
180
|
+
|
|
181
|
+
def copy_cabling(self, src: str, dst: str, pairs: list[tuple[str, str]]) -> CablingResult:
|
|
182
|
+
"""Give group dst the same outside cabling src has to some endpoints, but onto others.
|
|
183
|
+
|
|
184
|
+
``pairs`` are (old endpoint, new endpoint), groups or devices: for every cable
|
|
185
|
+
from a device of src to a device of an old endpoint, a cable is made from the
|
|
186
|
+
matching device of dst to the matching device of the new endpoint. Matching
|
|
187
|
+
is by position, so src and dst (and each endpoint pair) need the same size
|
|
188
|
+
and kinds. No devices are created and src's internal cables are not copied.
|
|
189
|
+
Cables dst already has are counted, so running it twice changes nothing.
|
|
190
|
+
Ports: the same number as the original where free, else the first free one.
|
|
191
|
+
Everything is checked before any cable is made.
|
|
192
|
+
"""
|
|
193
|
+
for g in (src, dst):
|
|
194
|
+
if g not in self.groups:
|
|
195
|
+
raise NetError(f"no such group: {g}")
|
|
196
|
+
a, b = self.devices_in(src), self.devices_in(dst) # type: ignore[attr-defined]
|
|
197
|
+
if len(a) != len(b):
|
|
198
|
+
raise NetError(f"{src} has {len(a)} devices but {dst} has {len(b)}: they're matched one to one")
|
|
199
|
+
match: dict[str, str] = {}
|
|
200
|
+
for x, y in zip(a, b):
|
|
201
|
+
if self.devices[x].kind != self.devices[y].kind:
|
|
202
|
+
raise NetError(f"{x} is a {self.devices[x].name} but {y} is a {self.devices[y].name}")
|
|
203
|
+
match[x] = y
|
|
204
|
+
ends = self.endpoint_map(pairs, src)
|
|
205
|
+
clash = set(ends.values()) & set(b)
|
|
206
|
+
if clash:
|
|
207
|
+
raise NetError(f"{sorted(clash)[0]} is inside {dst}: endpoints must be outside it")
|
|
208
|
+
|
|
209
|
+
# The pattern: how many cables (per role) join each src device to each old endpoint.
|
|
210
|
+
wanted: dict[tuple[str, str, str], list[tuple[int, int]]] = {}
|
|
211
|
+
for l in self.links:
|
|
212
|
+
for near, far in ((l.a, l.b), (l.b, l.a)):
|
|
213
|
+
if near in match and far in ends:
|
|
214
|
+
key = (match[near], ends[far], l.role)
|
|
215
|
+
wanted.setdefault(key, []).append((l.port_of(near), l.port_of(far)))
|
|
216
|
+
made: list[tuple[str, int, str, int, str]] = []
|
|
217
|
+
already = 0
|
|
218
|
+
for (x, y, role), ports in wanted.items():
|
|
219
|
+
have = sum(1 for l in self.links if l.role == role and {l.a, l.b} == {x, y})
|
|
220
|
+
already += min(have, len(ports))
|
|
221
|
+
for px, py in ports[have:]:
|
|
222
|
+
made.append((x, px, y, py, role))
|
|
223
|
+
|
|
224
|
+
need: dict[str, int] = {}
|
|
225
|
+
for x, _, y, _, _ in made:
|
|
226
|
+
need[x] = need.get(x, 0) + 1
|
|
227
|
+
need[y] = need.get(y, 0) + 1
|
|
228
|
+
for dev, n in need.items():
|
|
229
|
+
free = sum(1 for p in range(1, self.devices[dev].nports + 1) if self.link_on(dev, p) is None)
|
|
230
|
+
if free < n:
|
|
231
|
+
nports = self.devices[dev].nports
|
|
232
|
+
raise NetError(f"{dev} needs {n} free port{'s' if n != 1 else ''} but has {free} "
|
|
233
|
+
f"(try `ports {dev} {nports + n - free}`)")
|
|
234
|
+
|
|
235
|
+
moved: list[str] = []
|
|
236
|
+
for x, px, y, py, role in made:
|
|
237
|
+
qx, qy = self._port_like(x, px), self._port_like(y, py)
|
|
238
|
+
moved += [f"{d}:p{p}→p{q}" for d, p, q in ((x, px, qx), (y, py, qy)) if p != q]
|
|
239
|
+
self.links.append(Link(x, qx, y, qy, role))
|
|
240
|
+
if made:
|
|
241
|
+
self.mac_tables = {}
|
|
242
|
+
return CablingResult(len(made), already, moved)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class CablingResult:
|
|
247
|
+
made: int # cables created
|
|
248
|
+
already: int # cables the pattern wanted that already existed
|
|
249
|
+
moved: list[str] # "r3:p6→p7": ports that couldn't mirror the original
|