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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Building the network: devices, cables, roles, failures, files, modes."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ... import demos
|
|
7
|
+
from ...constants import ALIASES, KINDS, REF, ROLES
|
|
8
|
+
from ...model import Link, NetError, Network
|
|
9
|
+
from ...topology import dump_topology, load_topology, looks_like_topology
|
|
10
|
+
from ..help import HELP
|
|
11
|
+
from ..theme import ROLE_COLOR
|
|
12
|
+
from .base import CommandHost
|
|
13
|
+
from .registry import command
|
|
14
|
+
|
|
15
|
+
DEFAULT_FILE = "bytesim.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TopologyCommands(CommandHost):
|
|
19
|
+
@command("help", "?", "h")
|
|
20
|
+
def cmd_help(self, args: list[str], opts: dict[str, str]) -> None:
|
|
21
|
+
self.log_msg(HELP)
|
|
22
|
+
|
|
23
|
+
@command("add", "new", "+")
|
|
24
|
+
def cmd_add(self, args: list[str], opts: dict[str, str]) -> None:
|
|
25
|
+
net = self.net
|
|
26
|
+
if not args or args[0].lower() not in ALIASES:
|
|
27
|
+
raise NetError("usage: add <g|r|s|c> [ports] [count]")
|
|
28
|
+
kind = ALIASES[args[0].lower()]
|
|
29
|
+
try:
|
|
30
|
+
nports = int(args[1]) if len(args) > 1 else None
|
|
31
|
+
count = int(args[2]) if len(args) > 2 else 1
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise NetError("ports and count must be numbers") from None
|
|
34
|
+
if not 1 <= count <= 100:
|
|
35
|
+
raise NetError("count must be 1..100")
|
|
36
|
+
made = []
|
|
37
|
+
for _ in range(count):
|
|
38
|
+
d = net.add(kind, nports)
|
|
39
|
+
if not self.auto:
|
|
40
|
+
net.place_new(d)
|
|
41
|
+
made.append(d)
|
|
42
|
+
kindword = "NIC" if kind == "c" else "port"
|
|
43
|
+
n = made[0].nports
|
|
44
|
+
self.log_msg(f"[green]+[/] {KINDS[kind][0]} {' '.join(d.id for d in made)} "
|
|
45
|
+
f"({n} {kindword}{'s' if n != 1 else ''})")
|
|
46
|
+
self.changed()
|
|
47
|
+
self.canvas.select(made[-1].id)
|
|
48
|
+
|
|
49
|
+
@command("link", "connect", "ln", "cable")
|
|
50
|
+
def cmd_link(self, args: list[str], opts: dict[str, str]) -> None:
|
|
51
|
+
net = self.net
|
|
52
|
+
if len(args) != 2:
|
|
53
|
+
raise NetError("usage: link <id>[:port] <id>[:port] [role=...]")
|
|
54
|
+
role = opts.get("role", "data")
|
|
55
|
+
if role not in ROLES:
|
|
56
|
+
raise NetError(f"role must be one of: {', '.join(ROLES)}")
|
|
57
|
+
(a, pa), (b, pb) = self._ref(args[0]), self._ref(args[1])
|
|
58
|
+
l = net.connect(a, pa, b, pb, role)
|
|
59
|
+
da, db = net.devices[l.a], net.devices[l.b]
|
|
60
|
+
self.log_msg(f"[green]⇄[/] [{da.color}]{l.a}[/]:{da.port_name(l.pa)} ── "
|
|
61
|
+
f"[{db.color}]{l.b}[/]:{db.port_name(l.pb)}"
|
|
62
|
+
+ (f" [{ROLE_COLOR[role]}]{role}[/]" if role != "data" else ""))
|
|
63
|
+
# Plugging in a computer kicks off a (visible) DHCP exchange.
|
|
64
|
+
ends = [(d, p) for d, p in ((da, l.pa), (db, l.pb))
|
|
65
|
+
if d.kind == "c" and net.iface(d.id, p).mode == "dhcp" and not self.graph
|
|
66
|
+
and not self.replaying] # scripts re-address once, at the end
|
|
67
|
+
self.changed(skip=frozenset((d.id, p) for d, p in ends))
|
|
68
|
+
for d, p in ends: # by Device: auto-renumber may have renamed it
|
|
69
|
+
self._dhcp_exchange((d.id, p)) # type: ignore[attr-defined]
|
|
70
|
+
|
|
71
|
+
@command("unlink", "disconnect", "cut")
|
|
72
|
+
def cmd_unlink(self, args: list[str], opts: dict[str, str]) -> None:
|
|
73
|
+
if not 1 <= len(args) <= 2:
|
|
74
|
+
raise NetError("usage: unlink <id>[:port] [id]")
|
|
75
|
+
a, pa = self._ref(args[0])
|
|
76
|
+
b = self._ref(args[1])[0] if len(args) == 2 else None
|
|
77
|
+
n = self.net.disconnect(a, pa, b)
|
|
78
|
+
self.log_msg(f"[yellow]✂[/] removed {n} cable{'s' if n != 1 else ''}" if n
|
|
79
|
+
else "[dim]no matching cables[/]")
|
|
80
|
+
self.changed()
|
|
81
|
+
|
|
82
|
+
@command("del", "rm", "delete", "remove")
|
|
83
|
+
def cmd_del(self, args: list[str], opts: dict[str, str]) -> None:
|
|
84
|
+
if not args:
|
|
85
|
+
raise NetError("usage: del <id> [id ...]")
|
|
86
|
+
for tok in args:
|
|
87
|
+
did, _ = self._ref(tok)
|
|
88
|
+
n = self.net.remove(did)
|
|
89
|
+
self.log_msg(f"[red]−[/] {did} deleted ({n} cable{'s' if n != 1 else ''} removed)")
|
|
90
|
+
self.changed()
|
|
91
|
+
|
|
92
|
+
@command("ports", "nics", "resize")
|
|
93
|
+
def cmd_ports(self, args: list[str], opts: dict[str, str]) -> None:
|
|
94
|
+
if args and args[0].lower() == "copy": # ports copy src(old, …) dst(new, …): cabling only
|
|
95
|
+
self.copy_cabling(args[1:]) # type: ignore[attr-defined]
|
|
96
|
+
return
|
|
97
|
+
if len(args) != 2 or not args[1].isdigit():
|
|
98
|
+
raise NetError("usage: ports <id> <count>")
|
|
99
|
+
did, _ = self._ref(args[0])
|
|
100
|
+
self.net.resize(did, int(args[1]))
|
|
101
|
+
self.log_msg(f"{did} now has {args[1]} ports")
|
|
102
|
+
self.changed()
|
|
103
|
+
|
|
104
|
+
@command("role")
|
|
105
|
+
def cmd_role(self, args: list[str], opts: dict[str, str]) -> None:
|
|
106
|
+
role = args[-1].lower() if args else ""
|
|
107
|
+
if len(args) < 2 or role not in ROLES:
|
|
108
|
+
raise NetError(f"usage: role <id[:port]> [id] <{'|'.join(ROLES)}>")
|
|
109
|
+
for l in self._links_matching(args[:-1]):
|
|
110
|
+
l.role = role
|
|
111
|
+
self.log_msg(f"{self._link_text(l)} is now [{ROLE_COLOR[role]}]{role}[/]")
|
|
112
|
+
self.changed()
|
|
113
|
+
|
|
114
|
+
@command("fail", "down")
|
|
115
|
+
def cmd_fail(self, args: list[str], opts: dict[str, str]) -> None:
|
|
116
|
+
self._fail(args, up=False)
|
|
117
|
+
|
|
118
|
+
@command("restore", "up", "repair")
|
|
119
|
+
def cmd_restore(self, args: list[str], opts: dict[str, str]) -> None:
|
|
120
|
+
self._fail(args, up=True)
|
|
121
|
+
|
|
122
|
+
@command("mode")
|
|
123
|
+
def cmd_mode(self, args: list[str], opts: dict[str, str]) -> None:
|
|
124
|
+
if not args or args[0].lower() not in ("graph", "addr", "ip", "address"):
|
|
125
|
+
self.log_msg(f"mode: [b]{'graph' if self.graph else 'addr'}[/] (mode graph | mode addr)")
|
|
126
|
+
return
|
|
127
|
+
self.graph = args[0].lower() == "graph"
|
|
128
|
+
self.log_msg("[b]graph mode[/]: traffic follows cables only, addresses ignored" if self.graph
|
|
129
|
+
else "[b]address mode[/]: networks, gateways and DHCP apply")
|
|
130
|
+
self.changed()
|
|
131
|
+
|
|
132
|
+
@command("auto", "arrange", "layout")
|
|
133
|
+
def cmd_auto(self, args: list[str], opts: dict[str, str]) -> None:
|
|
134
|
+
self.arrange(announce=True) # type: ignore[attr-defined]
|
|
135
|
+
|
|
136
|
+
@command("list", "ls")
|
|
137
|
+
def cmd_list(self, args: list[str], opts: dict[str, str]) -> None:
|
|
138
|
+
net = self.net
|
|
139
|
+
for d in net.sorted_devices():
|
|
140
|
+
used = sum(1 for l in net.links if l.touches(d.id))
|
|
141
|
+
self.log_msg(f"[{d.color}]{d.id:<4}[/] {d.name:<8} {used}/{d.nports} "
|
|
142
|
+
f"{'NICs' if d.kind == 'c' else 'ports'}")
|
|
143
|
+
for l in net.links:
|
|
144
|
+
self.log_msg(" " + self._link_text(l))
|
|
145
|
+
if net.groups:
|
|
146
|
+
self.dispatch(["groups"])
|
|
147
|
+
if not net.devices:
|
|
148
|
+
self.log_msg("[dim]empty network[/]")
|
|
149
|
+
|
|
150
|
+
@command("clear")
|
|
151
|
+
def cmd_clear(self, args: list[str], opts: dict[str, str]) -> None:
|
|
152
|
+
self.replace_network(Network(), auto=True) # type: ignore[attr-defined]
|
|
153
|
+
self.log_msg("[yellow]network cleared[/]")
|
|
154
|
+
|
|
155
|
+
@command("demo")
|
|
156
|
+
def cmd_demo(self, args: list[str], opts: dict[str, str]) -> None:
|
|
157
|
+
name = args[0].lower() if args else "basic"
|
|
158
|
+
if name not in demos.DEMOS:
|
|
159
|
+
raise NetError(f"demos: {', '.join(demos.DEMOS)}")
|
|
160
|
+
net, hint = demos.DEMOS[name]()
|
|
161
|
+
self.replace_network(net, auto=True) # type: ignore[attr-defined]
|
|
162
|
+
self.log_msg(hint)
|
|
163
|
+
|
|
164
|
+
@command("save")
|
|
165
|
+
def cmd_save(self, args: list[str], opts: dict[str, str]) -> None:
|
|
166
|
+
path = Path(args[0] if args else DEFAULT_FILE).expanduser()
|
|
167
|
+
try:
|
|
168
|
+
path.write_text(self.net.to_json())
|
|
169
|
+
except OSError as e:
|
|
170
|
+
raise NetError(f"couldn't save {path}: {e}") from None
|
|
171
|
+
self.log_msg(f"saved to {path}")
|
|
172
|
+
|
|
173
|
+
@command("load", "open")
|
|
174
|
+
def cmd_load(self, args: list[str], opts: dict[str, str]) -> None:
|
|
175
|
+
path = Path(args[0] if args else DEFAULT_FILE).expanduser()
|
|
176
|
+
try:
|
|
177
|
+
text = path.read_text()
|
|
178
|
+
topology = looks_like_topology(text)
|
|
179
|
+
net = load_topology(text) if topology else Network.from_json(text)
|
|
180
|
+
except NetError as e:
|
|
181
|
+
raise NetError(f"{path}: {e}") from None
|
|
182
|
+
except (OSError, ValueError, KeyError, TypeError) as e:
|
|
183
|
+
raise NetError(f"couldn't load {path}: {e}") from None
|
|
184
|
+
# A topology has no positions, so it's laid out; a save file keeps its own.
|
|
185
|
+
self.replace_network(net, auto=topology) # type: ignore[attr-defined]
|
|
186
|
+
self.log_msg(f"loaded {path} ({len(net.devices)} devices, {len(net.links)} cables"
|
|
187
|
+
+ (", topology: auto-arranged)" if topology else ")"))
|
|
188
|
+
|
|
189
|
+
@command("export")
|
|
190
|
+
def cmd_export(self, args: list[str], opts: dict[str, str]) -> None:
|
|
191
|
+
if args and args[0].lower() == "script": # export script [mode] [file]
|
|
192
|
+
self.export_script(args[1:]) # type: ignore[attr-defined]
|
|
193
|
+
return
|
|
194
|
+
if not 1 <= len(args) <= 2 or (len(args) == 2 and args[1].lower() != "ports"):
|
|
195
|
+
raise NetError("usage: export <file.yaml> [ports] (ports = pin every cable to its ports)")
|
|
196
|
+
path = Path(args[0]).expanduser()
|
|
197
|
+
pin = len(args) == 2
|
|
198
|
+
try:
|
|
199
|
+
path.write_text(dump_topology(self.net, pin_ports=pin))
|
|
200
|
+
except OSError as e:
|
|
201
|
+
raise NetError(f"couldn't write {path}: {e}") from None
|
|
202
|
+
self.log_msg(f"exported topology to {path}"
|
|
203
|
+
+ (" (every port pinned)" if pin else " (compact: meshes folded, ports allocated on load)"))
|
|
204
|
+
|
|
205
|
+
@command("quit", "exit", "q")
|
|
206
|
+
def cmd_quit(self, args: list[str], opts: dict[str, str]) -> None:
|
|
207
|
+
self.exit() # type: ignore[attr-defined]
|
|
208
|
+
|
|
209
|
+
# ---- helpers -------------------------------------------------------
|
|
210
|
+
def _link_text(self, l: Link, state: bool = True) -> str:
|
|
211
|
+
net = self.net
|
|
212
|
+
txt = f"{l.a}:{net.devices[l.a].port_name(l.pa)} ── {l.b}:{net.devices[l.b].port_name(l.pb)}"
|
|
213
|
+
if l.role != "data":
|
|
214
|
+
txt += f" [{ROLE_COLOR[l.role]}]{l.role}[/]"
|
|
215
|
+
return txt + (" [red]✕ down[/]" if state and not l.up else "")
|
|
216
|
+
|
|
217
|
+
def _links_matching(self, args: list[str]) -> list[Link]:
|
|
218
|
+
"""Cables picked by `id:port` or `id[:port] id[:port]`."""
|
|
219
|
+
if not 1 <= len(args) <= 2:
|
|
220
|
+
raise NetError("pick a cable with id:port, or two device ids")
|
|
221
|
+
(a, pa) = self._ref(args[0])
|
|
222
|
+
self.net.get(a)
|
|
223
|
+
b, pb = self._ref(args[1]) if len(args) == 2 else (None, None)
|
|
224
|
+
if b is None and pa is None:
|
|
225
|
+
raise NetError(f"which cable of {a}? use {a}:<port> or name the other end")
|
|
226
|
+
hit = [l for l in self.net.links if l.touches(a)
|
|
227
|
+
and (pa is None or l.port_of(a) == pa)
|
|
228
|
+
and (b is None or (l.other(a)[0] == b and (pb is None or l.other(a)[1] == pb)))]
|
|
229
|
+
if not hit:
|
|
230
|
+
raise NetError("no matching cable")
|
|
231
|
+
return hit
|
|
232
|
+
|
|
233
|
+
def _fail(self, args: list[str], up: bool) -> None:
|
|
234
|
+
net = self.net
|
|
235
|
+
verb = "restored" if up else "failed"
|
|
236
|
+
if not args:
|
|
237
|
+
raise NetError(f"usage: {'restore' if up else 'fail'} <id> | <id:port> | <id> <id>"
|
|
238
|
+
+ (" | all" if up else ""))
|
|
239
|
+
if up and args[0].lower() == "all":
|
|
240
|
+
for d in net.devices.values():
|
|
241
|
+
d.up = True
|
|
242
|
+
for l in net.links:
|
|
243
|
+
l.up = True
|
|
244
|
+
self.log_msg("[green]✓ everything restored[/]")
|
|
245
|
+
elif len(args) == 1 and REF.match(args[0]) and self._ref(args[0])[1] is None:
|
|
246
|
+
d = net.get(self._ref(args[0])[0])
|
|
247
|
+
d.up = up
|
|
248
|
+
self.log_msg(f"[{'green' if up else 'red'}]{'✓' if up else '✕'} {d.id} {verb}[/]")
|
|
249
|
+
else:
|
|
250
|
+
for l in self._links_matching(args):
|
|
251
|
+
l.up = up
|
|
252
|
+
self.log_msg(f"[{'green' if up else 'red'}]{'✓' if up else '✕'}[/] cable "
|
|
253
|
+
f"{self._link_text(l, state=False)} {verb}")
|
|
254
|
+
net.mac_tables = {}
|
|
255
|
+
self.changed()
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Sending things: ping, unicast and multicast, in address or graph mode."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import random
|
|
5
|
+
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
|
|
8
|
+
from ...constants import ADDR, HOST_MAX, REF
|
|
9
|
+
from ...model import Leg, NetError, fmt
|
|
10
|
+
from ..cables import route
|
|
11
|
+
from .base import CommandHost
|
|
12
|
+
from .registry import command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TrafficCommands(CommandHost):
|
|
16
|
+
INTERNET = ("internet", "inet", "wan")
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def _flatten(legs: list[Leg]) -> list[tuple[str, int]]:
|
|
20
|
+
path: list[tuple[str, int]] = []
|
|
21
|
+
for _, _, p in legs:
|
|
22
|
+
path.extend(p if not path else p[1:])
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def _reverse(path: list[tuple[str, int]]) -> list[tuple[str, int]]:
|
|
27
|
+
return [(path[-1][0], -1)] + [(path[i][0], path[i + 1][1]) for i in range(len(path) - 2, -1, -1)]
|
|
28
|
+
|
|
29
|
+
def _ifname(self, ifc: tuple[str, int]) -> str:
|
|
30
|
+
return f"{ifc[0]}:{self.net.devices[ifc[0]].port_name(ifc[1])}"
|
|
31
|
+
|
|
32
|
+
def _parse_addr(self, tok: str) -> tuple[int, int] | None:
|
|
33
|
+
m = ADDR.match(tok)
|
|
34
|
+
if not m:
|
|
35
|
+
return None
|
|
36
|
+
net, host = int(m[1]), int(m[2])
|
|
37
|
+
if net < 1 or not 1 <= host < HOST_MAX:
|
|
38
|
+
raise NetError(f"bad address {tok}: network ≥ 1, host 1..{HOST_MAX - 1}")
|
|
39
|
+
return net, host
|
|
40
|
+
|
|
41
|
+
def _resolve_dst(self, tok: str) -> tuple[str, list[tuple[str, int]] | None]:
|
|
42
|
+
"""A destination token -> (label, candidate interfaces); None means the internet."""
|
|
43
|
+
net = self.net
|
|
44
|
+
if tok.lower() in self.INTERNET:
|
|
45
|
+
return "internet", None
|
|
46
|
+
if (addr := self._parse_addr(tok)) is not None:
|
|
47
|
+
hit = net.resolve(addr)
|
|
48
|
+
if hit is None:
|
|
49
|
+
raise NetError(f"nobody has {fmt(addr)} (ARP got no reply)")
|
|
50
|
+
return f"{fmt(addr)} {hit[0]}", [hit]
|
|
51
|
+
did, port = self._ref(tok)
|
|
52
|
+
dev = net.get(did)
|
|
53
|
+
if dev.kind == "s":
|
|
54
|
+
raise NetError(f"{did} is a switch (layer 2): it has no address to reach")
|
|
55
|
+
if port:
|
|
56
|
+
if not net.iface(did, port).addr:
|
|
57
|
+
raise NetError(f"{did}:{dev.port_name(port)} has no address")
|
|
58
|
+
return f"{did}:{dev.port_name(port)}", [(did, port)]
|
|
59
|
+
cands = [(did, p) for p, _ in net.addressed(did)]
|
|
60
|
+
if not cands:
|
|
61
|
+
raise NetError(f"{did} has no address (try: dhcp {did})")
|
|
62
|
+
return did, cands
|
|
63
|
+
|
|
64
|
+
def _best_route(self, src: str, cands: list[tuple[str, int]] | None) -> list[Leg]:
|
|
65
|
+
if cands is None:
|
|
66
|
+
return self.net.route(src, None)
|
|
67
|
+
best, err = None, None
|
|
68
|
+
for c in cands:
|
|
69
|
+
try:
|
|
70
|
+
legs = self.net.route(src, c)
|
|
71
|
+
except NetError as e:
|
|
72
|
+
err = e
|
|
73
|
+
continue
|
|
74
|
+
if best is None or len(self._flatten(legs)) < len(self._flatten(best)):
|
|
75
|
+
best, self._last_dst = legs, c
|
|
76
|
+
if best is None:
|
|
77
|
+
raise err # type: ignore[misc]
|
|
78
|
+
return best
|
|
79
|
+
|
|
80
|
+
def _src(self, tok: str) -> str:
|
|
81
|
+
did = self.net.get(self._ref(tok)[0]).id
|
|
82
|
+
if self.net.devices[did].kind == "s" and not self.graph:
|
|
83
|
+
raise NetError(f"{did} is a switch (layer 2): it can't originate addressed traffic")
|
|
84
|
+
return did
|
|
85
|
+
|
|
86
|
+
def _plan(self, src: str, tok: str) -> tuple[str, list[tuple[str, int]], list[Leg] | None, bool]:
|
|
87
|
+
"""Work out how a packet gets from src to tok: (label, device path, legs, to-internet).
|
|
88
|
+
|
|
89
|
+
In graph mode it's the shortest cable path; in address mode it follows
|
|
90
|
+
networks and gateways, and legs records each layer-2 hop."""
|
|
91
|
+
net = self.net
|
|
92
|
+
internet = tok.lower() in self.INTERNET
|
|
93
|
+
if self.graph:
|
|
94
|
+
if ADDR.match(tok):
|
|
95
|
+
raise NetError("graph mode ignores addresses: use device ids (or `mode addr`)")
|
|
96
|
+
tree = net.tree(src)
|
|
97
|
+
if internet:
|
|
98
|
+
dst = next((d for d in tree if net.devices[d].kind == "g"), None)
|
|
99
|
+
if dst is None:
|
|
100
|
+
raise NetError(f"no gateway reachable from {src}")
|
|
101
|
+
label = "internet"
|
|
102
|
+
else:
|
|
103
|
+
dst = net.get(self._ref(tok)[0]).id
|
|
104
|
+
label = dst
|
|
105
|
+
path = net.path_in(tree, dst)
|
|
106
|
+
if path is None:
|
|
107
|
+
raise NetError(f"{dst} is not reachable from {src}"
|
|
108
|
+
+ self.storm_hint(src, dst)) # type: ignore[attr-defined]
|
|
109
|
+
return label, path, None, internet
|
|
110
|
+
net.notes.clear()
|
|
111
|
+
label, cands = self._resolve_dst(tok)
|
|
112
|
+
try:
|
|
113
|
+
legs = self._best_route(src, cands)
|
|
114
|
+
except NetError as e:
|
|
115
|
+
hint = self.storm_hint(src, *(c[0] for c in cands or [])) # type: ignore[attr-defined]
|
|
116
|
+
raise NetError(f"{e}{hint}") from None
|
|
117
|
+
# Each hop starts with an ARP request: a broadcast. Into a live loop, that's a storm.
|
|
118
|
+
for a, b, _ in legs:
|
|
119
|
+
fl = net.flood(a)
|
|
120
|
+
if fl.storm:
|
|
121
|
+
self.set_off_storm(fl, f"the ARP for {fmt(net.addr_of(b))} from {self._ifname(a)}") # type: ignore[attr-defined]
|
|
122
|
+
raise NetError(f"{self._ifname(a)} never got an ARP reply: its broadcast set off a storm")
|
|
123
|
+
return label, self._flatten(legs), legs, internet
|
|
124
|
+
|
|
125
|
+
def _learn(self, legs: list[Leg] | None, both_ways: bool = False) -> None:
|
|
126
|
+
for a, b, p in legs or []:
|
|
127
|
+
self.net.learn(a, p)
|
|
128
|
+
if both_ways:
|
|
129
|
+
self.net.learn(b, self._reverse(p))
|
|
130
|
+
|
|
131
|
+
def _log_route(self, kind: str, color: str, src: str, label: str,
|
|
132
|
+
path: list[tuple[str, int]], legs: list[Leg] | None, internet: bool) -> None:
|
|
133
|
+
net = self.net
|
|
134
|
+
chain = " → ".join(f"[{net.devices[d].color}]{d}[/]" for d, _ in path)
|
|
135
|
+
if internet:
|
|
136
|
+
chain += " → [magenta]☁[/]"
|
|
137
|
+
if legs is None:
|
|
138
|
+
self.log_msg(f"[b {color}]{kind}[/] {src} → {label}: {chain}")
|
|
139
|
+
return
|
|
140
|
+
frm = fmt(net.addr_of(legs[0][0]))
|
|
141
|
+
to = "internet" if internet else label.split()[0] if label.startswith("(") else fmt(net.addr_of(self._last_dst))
|
|
142
|
+
self.log_msg(f"[b {color}]{kind}[/] {frm} → {to} [dim]({src} → {label})[/]: {chain}")
|
|
143
|
+
for a, b, _ in legs:
|
|
144
|
+
self.log_msg(f" [dim]ARP {fmt(net.addr_of(b))} is-at {net.devices[b[0]].mac(b[1])} "
|
|
145
|
+
f"(asked by {self._ifname(a)})[/]")
|
|
146
|
+
for note in dict.fromkeys(net.notes):
|
|
147
|
+
self.log_msg(f" [{'red' if note.startswith('⚠') else 'orchid'}]{escape(note)}[/]")
|
|
148
|
+
|
|
149
|
+
def _routers_in(self, path: list[tuple[str, int]], legs: list[Leg] | None) -> int:
|
|
150
|
+
if legs is not None:
|
|
151
|
+
return len(legs) - 1
|
|
152
|
+
return sum(1 for d, _ in path[1:-1] if self.net.is_router(d))
|
|
153
|
+
|
|
154
|
+
@command("ping", "trace", "tracert", "traceroute")
|
|
155
|
+
def cmd_ping(self, args: list[str], opts: dict[str, str]) -> None:
|
|
156
|
+
if len(args) != 2:
|
|
157
|
+
raise NetError("usage: ping <src> <id|(net,host)|internet>")
|
|
158
|
+
src = self._src(args[0])
|
|
159
|
+
if args[1].lower() not in self.INTERNET and self._ref_or_none(args[1]) == src:
|
|
160
|
+
self.log_msg(f"{src}: reply from self (loopback)")
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
label, path, legs, internet = self._plan(src, args[1])
|
|
164
|
+
except NetError as e:
|
|
165
|
+
raise NetError(f"ping {src} → {args[1]}: {e}") from None
|
|
166
|
+
if len(path) < 2:
|
|
167
|
+
self.log_msg(f"[green]{src} is a gateway: internet reachable directly[/]")
|
|
168
|
+
return
|
|
169
|
+
self._log_route("PING", "red", src, label, path, legs, internet)
|
|
170
|
+
self._learn(legs, both_ways=True)
|
|
171
|
+
back = self._reverse(path)
|
|
172
|
+
c = self.canvas
|
|
173
|
+
links = [li for _, li in path[1:]]
|
|
174
|
+
c.heat(links)
|
|
175
|
+
ttl = 64 - self._routers_in(path, legs)
|
|
176
|
+
rtt = sum(random.uniform(0.15, 0.6) for _ in path) * 2 + (12 if internet else 0)
|
|
177
|
+
|
|
178
|
+
def arrived() -> None:
|
|
179
|
+
c.flash_device(path[-1][0], "red")
|
|
180
|
+
c.send_packet(self._cells(back), "green", replied)
|
|
181
|
+
|
|
182
|
+
def replied() -> None:
|
|
183
|
+
c.cool(links)
|
|
184
|
+
c.flash_device(src, "green")
|
|
185
|
+
self.log_msg(f" [green]reply from {label}[/]: hops={len(path) - 1} "
|
|
186
|
+
f"ttl={ttl} time={rtt:.2f} ms")
|
|
187
|
+
self.refresh_sidebar()
|
|
188
|
+
|
|
189
|
+
c.send_packet(self._cells(path), "red", arrived)
|
|
190
|
+
|
|
191
|
+
def _ref_or_none(self, tok: str) -> str | None:
|
|
192
|
+
m = REF.match(tok)
|
|
193
|
+
return f"{m[1].lower()}{int(m[2])}" if m else None
|
|
194
|
+
|
|
195
|
+
def _cells(self, path: list[tuple[str, int]]) -> list[tuple[int, int]]:
|
|
196
|
+
"""Screen cells a packet crosses along a path, port to port."""
|
|
197
|
+
net = self.net
|
|
198
|
+
cells: list[tuple[int, int]] = []
|
|
199
|
+
for (u, _), (v, li) in zip(path, path[1:]):
|
|
200
|
+
l = net.links[li]
|
|
201
|
+
pts = route(net, l, li)
|
|
202
|
+
if l.a != u:
|
|
203
|
+
pts = pts[::-1]
|
|
204
|
+
du, dv = net.devices[u], net.devices[v]
|
|
205
|
+
cells.append((du.port_x(l.port_of(u)), du.y + 2))
|
|
206
|
+
cells.extend(pts)
|
|
207
|
+
cells.append((dv.port_x(l.port_of(v)), dv.y + 2))
|
|
208
|
+
return cells
|
|
209
|
+
|
|
210
|
+
@command("unicast", "ucast", "send")
|
|
211
|
+
def cmd_unicast(self, args: list[str], opts: dict[str, str]) -> None:
|
|
212
|
+
"""One packet, one destination, no reply."""
|
|
213
|
+
if len(args) != 2:
|
|
214
|
+
raise NetError("usage: unicast <src> <id|(net,host)|internet>")
|
|
215
|
+
src = self._src(args[0])
|
|
216
|
+
if self._ref_or_none(args[1]) == src:
|
|
217
|
+
raise NetError("source and destination are the same device")
|
|
218
|
+
try:
|
|
219
|
+
label, path, legs, internet = self._plan(src, args[1])
|
|
220
|
+
except NetError as e:
|
|
221
|
+
raise NetError(f"unicast {src} → {args[1]}: {e}") from None
|
|
222
|
+
if len(path) < 2:
|
|
223
|
+
raise NetError(f"{src} is a gateway: nothing to send through")
|
|
224
|
+
self._log_route("UNICAST", "orange1", src, label, path, legs, internet)
|
|
225
|
+
self._learn(legs)
|
|
226
|
+
c = self.canvas
|
|
227
|
+
links = [li for _, li in path[1:]]
|
|
228
|
+
c.heat(links)
|
|
229
|
+
routers = self._routers_in(path, legs)
|
|
230
|
+
|
|
231
|
+
def delivered() -> None:
|
|
232
|
+
c.cool(links)
|
|
233
|
+
c.flash_device(path[-1][0], "orange1")
|
|
234
|
+
self.log_msg(f" [orange1]delivered to {label}[/]: {len(path) - 1} hops, "
|
|
235
|
+
f"{routers} router{'s' if routers != 1 else ''}")
|
|
236
|
+
self.refresh_sidebar()
|
|
237
|
+
|
|
238
|
+
c.send_packet(self._cells(path), "orange1", delivered)
|
|
239
|
+
|
|
240
|
+
def _targets(self, toks: list[str]) -> list[str]:
|
|
241
|
+
"""Expand device ids, addresses and group names into a list of device ids."""
|
|
242
|
+
out: list[str] = []
|
|
243
|
+
for t in toks:
|
|
244
|
+
if t.lower() in ("all", "*"):
|
|
245
|
+
ids = [d.id for d in self.net.sorted_devices() if d.kind == "c"]
|
|
246
|
+
elif t.lower() in self.net.groups:
|
|
247
|
+
ids = [d for d in self.net.devices_in(t.lower())
|
|
248
|
+
if self.graph or self.net.devices[d].kind != "s"] # switches can't receive
|
|
249
|
+
elif ADDR.match(t) and not self.graph:
|
|
250
|
+
ids = [self._resolve_dst(t)[1][0][0]] # type: ignore[index]
|
|
251
|
+
else:
|
|
252
|
+
ids = [self.net.get(self._ref(t)[0]).id]
|
|
253
|
+
out.extend(i for i in ids if i not in out)
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
@command("multicast", "mcast")
|
|
257
|
+
def cmd_multicast(self, args: list[str], opts: dict[str, str]) -> None:
|
|
258
|
+
"""One packet, copied only where the paths to the receivers split."""
|
|
259
|
+
if len(args) < 2:
|
|
260
|
+
raise NetError("usage: multicast <src> <id|(net,host)|group|all> [...]")
|
|
261
|
+
net = self.net
|
|
262
|
+
src = self._src(args[0])
|
|
263
|
+
receivers = [d for d in self._targets(args[1:]) if d != src]
|
|
264
|
+
if not receivers:
|
|
265
|
+
raise NetError("no receivers (the source can't send to itself)")
|
|
266
|
+
paths: dict[str, list[tuple[str, int]]] = {}
|
|
267
|
+
lost: list[str] = []
|
|
268
|
+
for d in receivers:
|
|
269
|
+
try:
|
|
270
|
+
_, path, legs, _ = self._plan(src, d)
|
|
271
|
+
except NetError as e:
|
|
272
|
+
lost.append(f"{d} ({e})")
|
|
273
|
+
continue
|
|
274
|
+
self._learn(legs)
|
|
275
|
+
paths[d] = path
|
|
276
|
+
recv_txt = " ".join(f"[{net.devices[d].color}]{d}[/]" for d in receivers)
|
|
277
|
+
frm = src if self.graph else f"{fmt(next((i.addr for _, i in net.addressed(src)), None))} [dim]({src})[/]"
|
|
278
|
+
self.log_msg(f"[b bright_magenta]MULTICAST[/] {frm} → {recv_txt}")
|
|
279
|
+
for msg in lost:
|
|
280
|
+
self.log_msg(f" [red]✗ unreachable: {escape(msg)}[/]")
|
|
281
|
+
if not paths:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Links in the distribution tree, and where copies get made.
|
|
285
|
+
tree_links = {li for p in paths.values() for _, li in p[1:]}
|
|
286
|
+
children: dict[str, set[str]] = {}
|
|
287
|
+
for p in paths.values():
|
|
288
|
+
for (u, _), (v, _) in zip(p, p[1:]):
|
|
289
|
+
children.setdefault(u, set()).add(v)
|
|
290
|
+
forks = [f"{u}×{len(vs)}" for u, vs in children.items() if len(vs) > 1]
|
|
291
|
+
unicast_cost = sum(len(p) - 1 for p in paths.values())
|
|
292
|
+
self.log_msg(f" copies made at: {' '.join(forks) or 'none (single path)'}")
|
|
293
|
+
self.log_msg(f" {len(tree_links)} link transmissions "
|
|
294
|
+
f"(separate unicasts would need {unicast_cost})")
|
|
295
|
+
|
|
296
|
+
# All copies leave together and share cells until their paths split,
|
|
297
|
+
# so on screen one packet visibly forks at each branching device.
|
|
298
|
+
c = self.canvas
|
|
299
|
+
c.heat(tree_links)
|
|
300
|
+
pending = [len(paths)]
|
|
301
|
+
|
|
302
|
+
def arrived(dst: str) -> None:
|
|
303
|
+
c.flash_device(dst, "bright_magenta")
|
|
304
|
+
pending[0] -= 1
|
|
305
|
+
if pending[0] == 0:
|
|
306
|
+
c.cool(tree_links)
|
|
307
|
+
self.log_msg(f" [bright_magenta]delivered to {len(paths)} receiver{'s' if len(paths) != 1 else ''}[/]: "
|
|
308
|
+
+ " ".join(paths))
|
|
309
|
+
self.refresh_sidebar()
|
|
310
|
+
|
|
311
|
+
for dst, p in paths.items():
|
|
312
|
+
c.send_packet(self._cells(p), "bright_magenta", lambda d=dst: arrived(d))
|
bytesim/tui/help.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The text shown by the `help` command (Rich markup)."""
|
|
2
|
+
|
|
3
|
+
HELP = """\
|
|
4
|
+
[b]Commands[/] (device ids: g1 r2 s3 c4 · ports: r1:3 or c2:nic2)
|
|
5
|
+
[cyan]add[/] <g|r|s|c> \\[ports] \\[count] add devices e.g. [i]add s 24[/], [i]add c 2 3[/]
|
|
6
|
+
[cyan]<id> <id>[/] cable two devices (first free ports)
|
|
7
|
+
[cyan]<id>:<port> <id>:<port>[/] cable specific ports, e.g. [i]r1:2 s1:8[/]
|
|
8
|
+
[cyan]<id>[/] select device, then move it with arrow keys
|
|
9
|
+
[cyan]unlink[/] <id>\\[:port] \\[id] remove cable(s)
|
|
10
|
+
[cyan]del[/] <id> delete a device
|
|
11
|
+
[cyan]ports[/] <id> <n> change port / NIC count
|
|
12
|
+
[cyan]ping[/] <id> <id|addr|internet> echo request + reply (follows networks + gateways)
|
|
13
|
+
[cyan]unicast[/] <src> <id|addr> one-way packet to a single device
|
|
14
|
+
[cyan]multicast[/] <src> <ids|groups|all> one packet, copied where paths split, e.g. [i]mcast c1 pcs c8[/]
|
|
15
|
+
[cyan]group[/] <name> <id|group ...> logical layer; groups nest, e.g. [i]group dc1 fab1 rfab1[/]
|
|
16
|
+
[cyan]group[/] <name> or <name> select a group (arrows / drag its frame move everything in it)
|
|
17
|
+
[cyan]ungroup[/] <name> \\[member ...] take members out, or dissolve (members move up a level)
|
|
18
|
+
[cyan]clone[/] <group> \\[new-name] copy a group: devices, nested groups, internal cables
|
|
19
|
+
[cyan]ports copy[/] <group>(old, …) <group>(new, …) cable an existing group the way another is cabled, e.g.
|
|
20
|
+
[i]ports copy link1(rfab1, rfab2) link2(rfab2, rfab3)[/]
|
|
21
|
+
[cyan]copy[/] <group>(old, …) <new>(new, …) the same, creating the new group as a clone first
|
|
22
|
+
a name like [i]fab(1)[/] means the next free fabN (group, clone)
|
|
23
|
+
[cyan]renumber[/] bfs|dfs \\[root] renumber each kind 1..n in BFS/DFS order from root (default g1…)
|
|
24
|
+
[cyan]renumber auto[/] bfs|dfs \\[root] · [cyan]renumber off[/] keep renumbering after every change
|
|
25
|
+
[cyan]export[/] <file.yaml> \\[ports] declarative topology (load reads it back); ports = exact
|
|
26
|
+
[cyan]export script[/] \\[readable|shortest|explicit] \\[file] the commands that rebuild this network
|
|
27
|
+
[cyan]run[/] <file> run a script of commands (after [cyan]clear[/] for an export)
|
|
28
|
+
[cyan]groups[/] · [cyan]auto[/] (re-arrange) · [cyan]list[/] · [cyan]demo[/] · [cyan]clear[/] · [cyan]save[/]/[cyan]load[/] \\[file] · [cyan]quit[/]
|
|
29
|
+
[b]Addressing[/] an address is (network, host): network id + 8-bit host, e.g. [i](2,14)[/]
|
|
30
|
+
MACs name the physical port: R1A = r1 port 1. A network spans every switch it touches.
|
|
31
|
+
Routers/gateways take host 1, 2… on each network and serve DHCP; computers lease hosts 10+,
|
|
32
|
+
or take a free host on their own when no server answers (ad-hoc)
|
|
33
|
+
[cyan]ip[/] \\[id\\[:port]] table of MACs, networks and hosts
|
|
34
|
+
[cyan]ip[/] <id:port> (net,host) \\[gw] static, e.g. [i]ip c5 (2,50) 1[/] · [cyan]ip[/] <id:port> dhcp|adhoc|none
|
|
35
|
+
[cyan]dhcp[/] <id\\[:nic]|all> run DHCP (animated DISCOVER broadcast + OFFER)
|
|
36
|
+
[cyan]release[/] <id\\[:nic]|all> give up a lease · [cyan]adhoc[/] <id\\[:nic]|all> self-assign
|
|
37
|
+
[cyan]dhcpd[/] <router> on|off turn a router's DHCP server on or off
|
|
38
|
+
[cyan]mode[/] graph|addr graph mode: pure connectivity, no addressing rules
|
|
39
|
+
[b]Loops, STP, storms[/] switches run spanning tree unless it's switched off for them
|
|
40
|
+
[cyan]broadcast[/] <id\\[:port]> flood one frame; into a live loop it becomes a storm
|
|
41
|
+
[cyan]stp[/] · [cyan]stp[/] on|off · [cyan]stp[/] <switch…> on|off · [cyan]stp priority[/] <switch> <n> · [cyan]demo loop[/]
|
|
42
|
+
[b]Roles, failures, HA[/]
|
|
43
|
+
[cyan]<id> <id> role=[/]data|control|control-backup|fabric cable with a role (control = heartbeat only, ═)
|
|
44
|
+
[cyan]role[/] <id:port>|<id> <id> <role> change a cable's role (fabric = router↔router data, ━)
|
|
45
|
+
[cyan]fail[/] <id> | <id:port> | <id> <id> kill a device or cable(s) · [cyan]restore[/] … | all
|
|
46
|
+
[cyan]cluster[/] <name> <routers…> \\[policy=none|quorum|witness] HA pair/group sharing gateway host 254
|
|
47
|
+
[cyan]cluster[/] <name> · [cyan]clusters[/] · [cyan]uncluster[/] <name> · [cyan]demo ha[/]
|
|
48
|
+
[cyan]undo[/] \\[n] · [cyan]redo[/] \\[n] · [cyan]history[/] step back through changes (u / U on the canvas)
|
|
49
|
+
[b]Canvas keys[/] arrows move (shift = x4) · tab cycle · a arrange · x delete · u undo · U redo
|
|
50
|
+
hjkl / drag background / wheel pan · drag a box to move · v show/hide addresses · enter or : → command line"""
|