gini-toolkit 6.0.1.dev0__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.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,223 @@
1
+ """Minimal user-space L3 gRouter for the R0 spike.
2
+
3
+ Enough to prove the data plane end-to-end across subnets: ARP (answers for its own
4
+ interfaces, resolves next hops, queues packets pending resolution), connected-route
5
+ forwarding with TTL decrement + checksum fix, and ICMP echo to its own interfaces
6
+ (so you can ping the gateway). This is intentionally NOT the modular module-graph
7
+ router from the plan — it's the smallest thing that proves portability.
8
+
9
+ Run as a process: ROUTER_CONFIG='{"name":"r1","ifaces":[...]}' python -m dataplane.grouter
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import ipaddress
14
+ import json
15
+ import os
16
+ import sys
17
+
18
+ from .frame import (
19
+ BROADCAST, ETH_ARP, ETH_IP, ICMP_ECHO_REPLY, ICMP_ECHO_REQUEST, PROTO_ICMP,
20
+ ZERO_MAC, build_arp, build_eth, build_icmp, build_ipv4, dec_ttl,
21
+ parse_arp, parse_eth, parse_icmp, parse_ipv4,
22
+ )
23
+ from .control import maybe_start
24
+ from .transport import Port, run_loop
25
+
26
+
27
+ class Iface:
28
+ def __init__(self, cfg: dict, name: str) -> None:
29
+ self.port = Port.from_cfg(cfg["port"], name=name)
30
+ iface = ipaddress.ip_interface(cfg["ip"])
31
+ self.ip = str(iface.ip)
32
+ self.network = iface.network
33
+ self.mac = cfg["mac"]
34
+
35
+
36
+ class Router:
37
+ def __init__(self, cfg: dict) -> None:
38
+ self.name = cfg["name"]
39
+ self.ifaces = [Iface(f, name=f"eth{i}") for i, f in enumerate(cfg["ifaces"])]
40
+ self.arp: dict[str, str] = {} # ip -> mac
41
+ self.pending: dict[str, list[tuple[Iface, bytes]]] = {}
42
+ self.log = cfg.get("log", False)
43
+ self.pipeline: list[tuple[str, str]] = [] # Z2: inline modules (type, param)
44
+ self.cp_modules: list[tuple[str, str]] = [] # B2: control-plane modules (name, args)
45
+ self.mgroups: list[tuple[str, str]] = [] # B3: multicast memberships (group, iface)
46
+ self._ctrl = maybe_start(self.name, self._control, f"router {self.name}")
47
+
48
+ def _control(self, cmd: str) -> str:
49
+ cmd = cmd.lower()
50
+ if cmd.startswith("gpipe"):
51
+ return self._gpipe(cmd[len("gpipe"):].strip())
52
+ if cmd in ("help", "?", "h"):
53
+ return "commands: interfaces, routes, arp, gpipe, help, exit"
54
+ if cmd in ("interfaces", "int", "if"):
55
+ return "\n".join(f" {i.port.name} {i.ip}/{i.network.prefixlen} {i.mac}"
56
+ for i in self.ifaces)
57
+ if cmd in ("routes", "route"):
58
+ return "\n".join(f" {i.network} dev {i.port.name}" for i in self.ifaces)
59
+ if cmd == "arp":
60
+ if not self.arp:
61
+ return "(arp cache empty)"
62
+ return "\n".join(f" {ip} -> {mac}" for ip, mac in self.arp.items())
63
+ return f"unknown command: {cmd} (try 'help')"
64
+
65
+ def _gpipe(self, args: str) -> str:
66
+ """Z2 inline module pipeline — same vocabulary as the C gRouter's gr_control."""
67
+ parts = args.split()
68
+ if not parts or parts[0] in ("help", ""):
69
+ return ("gpipe: add acl <cidr>|nat <ip>|counter|block <ip>|lua <path> | "
70
+ "list | clear | trace <ip> | cp add <name> [args]|cp list|cp stop")
71
+ op = parts[0]
72
+ if op == "add" and len(parts) >= 2:
73
+ kind = parts[1]
74
+ # block (native/Zig) and lua (scripting) mirror the real gRouter's gr_control.
75
+ if kind in ("acl", "nat", "block", "lua") and len(parts) >= 3:
76
+ self.pipeline.append((kind, parts[2]))
77
+ return f"added {kind} {parts[2]} (pipeline: {len(self.pipeline)})"
78
+ if kind == "counter":
79
+ self.pipeline.append(("counter", ""))
80
+ return f"added counter (pipeline: {len(self.pipeline)})"
81
+ return f"unknown module: {kind}"
82
+ if op == "list":
83
+ chain = " ".join(f"-> [{i}:{t}]" for i, (t, _) in enumerate(self.pipeline))
84
+ return f"base: parse {chain} -> route -> rewrite"
85
+ if op == "clear":
86
+ self.pipeline.clear()
87
+ return "pipeline cleared (base only)"
88
+ if op == "cp": # B2: control-plane modules
89
+ sub = parts[1] if len(parts) >= 2 else ""
90
+ if sub == "add" and len(parts) >= 3:
91
+ name, cargs = parts[2], " ".join(parts[3:])
92
+ self.cp_modules.append((name, cargs))
93
+ return f"control module '{name}' started"
94
+ if sub == "list":
95
+ if not self.cp_modules:
96
+ return "no control modules loaded"
97
+ return "control modules (%d): %s" % (
98
+ len(self.cp_modules), " ".join(n for n, _ in self.cp_modules))
99
+ if sub == "stop":
100
+ self.cp_modules.clear()
101
+ return "control plane stopped (all modules removed)"
102
+ return "usage: cp add <name> [args] | cp list | cp stop (modules: hello dhcp rip igmp)"
103
+ if op == "mcast": # B3: multicast membership
104
+ sub = parts[1] if len(parts) >= 2 else ""
105
+ if sub in ("join", "leave") and len(parts) >= 4:
106
+ grp, iface = parts[2], parts[3]
107
+ key = (grp, iface)
108
+ if sub == "join" and key not in self.mgroups:
109
+ self.mgroups.append(key)
110
+ elif sub == "leave" and key in self.mgroups:
111
+ self.mgroups.remove(key)
112
+ return f"mcast {sub} {grp} if{iface}"
113
+ if sub == "show":
114
+ if not self.mgroups:
115
+ return "multicast groups: (none)"
116
+ return "multicast groups: " + ", ".join(f"{g}->if{i}" for g, i in self.mgroups)
117
+ return "usage: mcast join <group> <iface> | leave <group> <iface> | show"
118
+ if op == "trace" and len(parts) >= 2:
119
+ dst = parts[1]
120
+ out = [f"trace dst {dst}:"]
121
+ dropped = False
122
+ for i, (t, p) in enumerate(self.pipeline):
123
+ verdict = "CONTINUE"
124
+ if t == "acl":
125
+ try:
126
+ if ipaddress.ip_address(dst) in ipaddress.ip_network(p, strict=False):
127
+ verdict, dropped = "DROP", True
128
+ except ValueError:
129
+ pass
130
+ elif t == "block" and dst == p: # native module: exact-dst drop
131
+ verdict, dropped = "DROP", True
132
+ out.append(f" {i}. {t:<8} -> {verdict}")
133
+ if dropped:
134
+ break
135
+ if not dropped:
136
+ out.append(" -> base forwarding (route, rewrite, egress)")
137
+ return "\n".join(out)
138
+ return f"gpipe: unknown ({args})"
139
+
140
+ # routing ---------------------------------------------------------------- #
141
+ def _egress_for(self, dst_ip: str) -> Iface | None:
142
+ addr = ipaddress.ip_address(dst_ip)
143
+ for itf in self.ifaces:
144
+ if addr in itf.network: # connected route
145
+ return itf
146
+ return None
147
+
148
+ def _ingress(self, port: Port) -> Iface:
149
+ return next(itf for itf in self.ifaces if itf.port is port)
150
+
151
+ # main handler ----------------------------------------------------------- #
152
+ def handle(self, inport: Port, frame: bytes) -> None:
153
+ ingress = self._ingress(inport)
154
+ dst_mac, src_mac, etype, payload = parse_eth(frame)
155
+
156
+ if etype == ETH_ARP:
157
+ op, sha, spa, _tha, tpa = parse_arp(payload)
158
+ if spa != "0.0.0.0":
159
+ self.arp[spa] = sha
160
+ self._flush(spa)
161
+ if op == 1 and tpa == ingress.ip: # who-has my IP -> reply
162
+ reply = build_arp(2, ingress.mac, ingress.ip, sha, spa)
163
+ inport.send(build_eth(sha, ingress.mac, ETH_ARP, reply))
164
+ return
165
+
166
+ if etype == ETH_IP:
167
+ ip = parse_ipv4(payload)
168
+ self.arp.setdefault(ip["src"], src_mac) # learn from data plane
169
+
170
+ if any(ip["dst"] == itf.ip for itf in self.ifaces):
171
+ self._handle_local(ingress, inport, src_mac, ip)
172
+ return
173
+
174
+ egress = self._egress_for(ip["dst"])
175
+ if egress is None:
176
+ return # no route -> drop
177
+ new_ip = dec_ttl(payload)
178
+ if new_ip is None:
179
+ return # TTL expired -> drop
180
+ self._forward(egress, ip["dst"], new_ip)
181
+
182
+ def _handle_local(self, ingress: Iface, inport: Port, src_mac: str, ip: dict) -> None:
183
+ if ip["proto"] != PROTO_ICMP:
184
+ return
185
+ typ, ident, seq, data = parse_icmp(ip["payload"])
186
+ if typ == ICMP_ECHO_REQUEST:
187
+ rep = build_icmp(ICMP_ECHO_REPLY, ident, seq, data)
188
+ pkt = build_ipv4(ip["dst"], ip["src"], PROTO_ICMP, rep)
189
+ inport.send(build_eth(src_mac, ingress.mac, ETH_IP, pkt))
190
+
191
+ def _forward(self, egress: Iface, next_hop: str, ip_pkt: bytes) -> None:
192
+ mac = self.arp.get(next_hop)
193
+ if mac is not None:
194
+ egress.port.send(build_eth(mac, egress.mac, ETH_IP, ip_pkt))
195
+ if self.log:
196
+ print(f"[{self.name}] fwd -> {next_hop} via {egress.port.name}",
197
+ file=sys.stderr)
198
+ else: # resolve, queue, retransmit-safe
199
+ self.pending.setdefault(next_hop, []).append((egress, ip_pkt))
200
+ req = build_arp(1, egress.mac, egress.ip, ZERO_MAC, next_hop)
201
+ egress.port.send(build_eth(BROADCAST, egress.mac, ETH_ARP, req))
202
+
203
+ def _flush(self, ip: str) -> None:
204
+ queued = self.pending.pop(ip, [])
205
+ mac = self.arp.get(ip)
206
+ if mac is None:
207
+ return
208
+ for egress, ip_pkt in queued:
209
+ egress.port.send(build_eth(mac, egress.mac, ETH_IP, ip_pkt))
210
+
211
+ def run(self) -> None:
212
+ nets = ", ".join(str(i.network) for i in self.ifaces)
213
+ print(f"[{self.name}] gRouter up: {nets}", file=sys.stderr)
214
+ run_loop([itf.port for itf in self.ifaces], self.handle)
215
+
216
+
217
+ def main() -> None:
218
+ cfg = json.loads(os.environ["ROUTER_CONFIG"])
219
+ Router(cfg).run()
220
+
221
+
222
+ if __name__ == "__main__":
223
+ main()
@@ -0,0 +1,90 @@
1
+ """A simulated machine for the no-Docker loopback test.
2
+
3
+ Behaves like a real host at the Ethernet layer (ARP + ICMP echo) but originates and
4
+ receives frames over a UDP Port instead of a TAP. This lets the loopback test drive
5
+ real pings through the real switch and router code with no containers, no TAP, and no
6
+ privileges — so the forwarding logic can be verified anywhere, including CI.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+
12
+ from .frame import (
13
+ BROADCAST, ETH_ARP, ETH_IP, ICMP_ECHO_REPLY, ICMP_ECHO_REQUEST, PROTO_ICMP,
14
+ ZERO_MAC, build_arp, build_eth, build_icmp, build_ipv4,
15
+ parse_arp, parse_eth, parse_icmp, parse_ipv4,
16
+ )
17
+ from .transport import Port, run_loop
18
+
19
+
20
+ class HostSim:
21
+ def __init__(self, cfg: dict) -> None:
22
+ self.name = cfg["name"]
23
+ # in-process sim uses the machine's first interface (multi-homing is a
24
+ # Docker-runtime feature; the loopback sim just needs basic connectivity)
25
+ itf = cfg["ifaces"][0] if "ifaces" in cfg else cfg
26
+ iface = ipaddress.ip_interface(itf["ip"])
27
+ self.ip = str(iface.ip)
28
+ self.network = iface.network
29
+ self.mac = itf["mac"]
30
+ self.gw = cfg.get("gw")
31
+ self.port = Port.from_cfg(itf["port"], name="eth0")
32
+ self.arp: dict[str, str] = {}
33
+ self.pending: list[tuple[str, bytes]] = []
34
+ self.replies: set[tuple[int, int]] = set() # (ident, seq) seen as echo replies
35
+
36
+ # receive ---------------------------------------------------------------- #
37
+ def handle(self, _inport: Port, frame: bytes) -> None:
38
+ dst_mac, src_mac, etype, payload = parse_eth(frame)
39
+ if etype == ETH_ARP:
40
+ op, sha, spa, _tha, tpa = parse_arp(payload)
41
+ if spa != "0.0.0.0":
42
+ self.arp[spa] = sha
43
+ self._flush()
44
+ if op == 1 and tpa == self.ip:
45
+ reply = build_arp(2, self.mac, self.ip, sha, spa)
46
+ self.port.send(build_eth(sha, self.mac, ETH_ARP, reply))
47
+ return
48
+ if etype == ETH_IP:
49
+ ip = parse_ipv4(payload)
50
+ self.arp.setdefault(ip["src"], src_mac)
51
+ if ip["dst"] != self.ip or ip["proto"] != PROTO_ICMP:
52
+ return
53
+ typ, ident, seq, data = parse_icmp(ip["payload"])
54
+ if typ == ICMP_ECHO_REQUEST:
55
+ rep = build_icmp(ICMP_ECHO_REPLY, ident, seq, data)
56
+ pkt = build_ipv4(self.ip, ip["src"], PROTO_ICMP, rep)
57
+ self.port.send(build_eth(src_mac, self.mac, ETH_IP, pkt))
58
+ elif typ == ICMP_ECHO_REPLY:
59
+ self.replies.add((ident, seq))
60
+
61
+ # send ------------------------------------------------------------------- #
62
+ def _next_hop(self, dst_ip: str) -> str:
63
+ if ipaddress.ip_address(dst_ip) in self.network:
64
+ return dst_ip
65
+ return self.gw
66
+
67
+ def ping(self, dst_ip: str, ident: int, seq: int) -> None:
68
+ next_hop = self._next_hop(dst_ip)
69
+ icmp = build_icmp(ICMP_ECHO_REQUEST, ident, seq)
70
+ pkt = build_ipv4(self.ip, dst_ip, PROTO_ICMP, icmp)
71
+ mac = self.arp.get(next_hop)
72
+ if mac is not None:
73
+ self.port.send(build_eth(mac, self.mac, ETH_IP, pkt))
74
+ else:
75
+ self.pending.append((next_hop, pkt))
76
+ req = build_arp(1, self.mac, self.ip, ZERO_MAC, next_hop)
77
+ self.port.send(build_eth(BROADCAST, self.mac, ETH_ARP, req))
78
+
79
+ def _flush(self) -> None:
80
+ still: list[tuple[str, bytes]] = []
81
+ for next_hop, pkt in self.pending:
82
+ mac = self.arp.get(next_hop)
83
+ if mac is not None:
84
+ self.port.send(build_eth(mac, self.mac, ETH_IP, pkt))
85
+ else:
86
+ still.append((next_hop, pkt))
87
+ self.pending = still
88
+
89
+ def run(self) -> None:
90
+ run_loop([self.port], self.handle)
@@ -0,0 +1,348 @@
1
+ """Machine shuttle: the real entrypoint for a 'machine' container.
2
+
3
+ Creates one or more TAP interfaces inside the container (Linux; needs NET_ADMIN +
4
+ /dev/net/tun), assigns each its experiment IP, and bridges each TAP to its link's UDP
5
+ endpoint: TAP frame -> UDP datagram and back. A machine with several interfaces is
6
+ multi-homed (one TAP/IP per subnet); the default route uses the configured gateway.
7
+
8
+ The container's own docker eth0 stays as the management/transport NIC; experiment
9
+ traffic rides the TAPs (gini0, gini1, … so they don't clash with docker's eth0).
10
+
11
+ NODE_CONFIG = {"name":"m1", "gw":"10.0.1.1", "ifaces":[
12
+ {"ip":"10.0.1.10/24","mac":"..","tap":"gini0","port":{...}},
13
+ {"ip":"10.0.2.10/24","mac":"..","tap":"gini1","port":{...}} ]}
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import fcntl
18
+ from array import array
19
+ import json
20
+ import os
21
+ import selectors
22
+ import struct
23
+ import subprocess
24
+ import sys
25
+
26
+ from .transport import Port
27
+
28
+ TUNSETIFF = 0x400454CA
29
+ TUNSETOFFLOAD = 0x400454D0 # _IOW('T', 208, unsigned int)
30
+ IFF_TAP = 0x0002
31
+ IFF_NO_PI = 0x1000
32
+
33
+ # External uplink for the Internet element (NAT gateway). Must match the `wan` network
34
+ # in services/orchestrator.py so the gateway can find its WAN interface + default route.
35
+ WAN_SUBNET_PREFIX = "192.168.244."
36
+ WAN_GATEWAY = "192.168.244.1"
37
+ EXP_SUPERNET = "10.0.0.0/8"
38
+
39
+
40
+ _BIG_ENDIAN = sys.byteorder == "big"
41
+
42
+
43
+ def _ones_complement(data) -> int:
44
+ """The 16-bit one's-complement sum used by every IP checksum.
45
+
46
+ Written with array/sum rather than a Python loop over bytes: this runs on every
47
+ forwarded frame, and the naive version costs ~53 us per 1400-byte packet, which would
48
+ cap the whole data plane at a couple of hundred Mbps and quietly distort the iperf
49
+ experiments students are supposed to be able to trust.
50
+ """
51
+ raw = bytes(data)
52
+ if len(raw) & 1:
53
+ raw += b"\x00" # pad an odd tail, per RFC 1071
54
+ words = array("H", raw)
55
+ if not _BIG_ENDIAN:
56
+ words.byteswap() # network order
57
+ total = sum(words)
58
+ while total >> 16:
59
+ total = (total & 0xFFFF) + (total >> 16)
60
+ return total
61
+
62
+
63
+ def repair_l4_checksum(frame: bytearray) -> bool:
64
+ """Recompute a TCP/UDP checksum that the sender left unfinished. True if changed.
65
+
66
+ WHY THE DATA PLANE HAS TO DO THIS. A checksum is normally completed by whichever
67
+ kernel transmits the packet — either in software, or by the NIC when the driver
68
+ advertises offload. Neither happens here, and it took a long time to see why:
69
+
70
+ * Docker Desktop for macOS answers TCP from a userspace proxy inside the same
71
+ Linux VM. Its replies reach a container's `eth0` with the checksum field still
72
+ holding a partial value AND a flag saying it has already been verified.
73
+ * That is legitimate for a packet addressed to the container: the stack trusts the
74
+ flag and never looks at the bytes. But the NAT gateway FORWARDS these packets,
75
+ and routers do not validate layer-4 checksums, so the bad bytes go straight out.
76
+ * At transmit time the kernel sees a packet it believes is already correct, so it
77
+ will not recompute — turning off tap offload changes nothing, which is exactly
78
+ what we measured: `tx-checksumming: off` on both ends, packet still wrong.
79
+
80
+ The real machine behind the gateway then validates properly, fails, and drops in
81
+ silence. Every TCP connection to the internet dies mid-handshake while ping works at
82
+ any size and DNS works, because ICMP is always checksummed in software and DNS went
83
+ to Docker's loopback resolver without ever crossing a tap.
84
+
85
+ So the last component that can see the actual bytes has to fix them, and that is us.
86
+ Cheap in practice: only IPv4 TCP/UDP is touched, and only when the checksum really is
87
+ wrong, which after the handshake is essentially never on a healthy path.
88
+ """
89
+ n = len(frame)
90
+ if n < 34 or frame[12] != 0x08 or frame[13] != 0x00: # not IPv4
91
+ return False
92
+ ip = 14
93
+ if (frame[ip] >> 4) != 4:
94
+ return False
95
+ ihl = (frame[ip] & 0x0F) * 4
96
+ if ihl < 20 or ip + ihl > n:
97
+ return False
98
+ proto = frame[ip + 9]
99
+ if proto not in (6, 17): # TCP, UDP
100
+ return False
101
+ # Fragments carry only part of the payload; the checksum cannot be computed here.
102
+ if ((frame[ip + 6] << 8 | frame[ip + 7]) & 0x3FFF) != 0:
103
+ return False
104
+
105
+ total_len = (frame[ip + 2] << 8) | frame[ip + 3]
106
+ if total_len < ihl or ip + total_len > n:
107
+ return False # truncated or padded oddly
108
+ l4 = ip + ihl
109
+ l4_len = total_len - ihl
110
+ if l4_len < (20 if proto == 6 else 8):
111
+ return False
112
+
113
+ ck_off = l4 + (16 if proto == 6 else 6)
114
+ old = (frame[ck_off] << 8) | frame[ck_off + 1]
115
+ # A UDP checksum of zero means "not computed", which is legal. Leave it alone rather
116
+ # than inventing one — some protocols rely on it being absent.
117
+ if proto == 17 and old == 0:
118
+ return False
119
+
120
+ frame[ck_off] = 0
121
+ frame[ck_off + 1] = 0
122
+ view = memoryview(frame)
123
+ # pseudo-header: src, dst, zero, proto, length
124
+ total = _ones_complement(view[ip + 12:ip + 20]) + proto + l4_len
125
+ total += _ones_complement(view[l4:l4 + l4_len])
126
+ while total >> 16:
127
+ total = (total & 0xFFFF) + (total >> 16)
128
+ new = (~total) & 0xFFFF
129
+ if new == 0 and proto == 17:
130
+ new = 0xFFFF # 0 means "none" for UDP
131
+ if new == old:
132
+ frame[ck_off] = old >> 8
133
+ frame[ck_off + 1] = old & 0xFF
134
+ return False
135
+ frame[ck_off] = new >> 8
136
+ frame[ck_off + 1] = new & 0xFF
137
+ return True
138
+
139
+
140
+ def open_tap(name: str) -> int:
141
+ fd = os.open("/dev/net/tun", os.O_RDWR)
142
+ ifr = struct.pack("16sH", name.encode(), IFF_TAP | IFF_NO_PI)
143
+ fcntl.ioctl(fd, TUNSETIFF, ifr)
144
+
145
+ # Turn OFF every hardware offload the tap pretends to have. This is load-bearing.
146
+ #
147
+ # A tap device advertises NETIF_F_HW_CSUM by default, which tells the kernel "do not
148
+ # bother computing TCP/UDP checksums, the hardware will finish them". There is no
149
+ # hardware: the next thing to touch the frame is the read() below, and whatever it
150
+ # reads goes onto the wire verbatim. So any packet whose checksum was still pending
151
+ # left this container mathematically corrupt, and the far end dropped it in silence.
152
+ #
153
+ # It only bites on FORWARDED traffic, which is why it hid for so long. A locally
154
+ # generated packet gets its checksum completed on the way out; a packet merely
155
+ # passing through keeps whatever state it arrived with. On Docker Desktop for macOS
156
+ # the replies from the internet are generated by a userspace TCP proxy inside the
157
+ # same Linux VM, so they reach the NAT gateway with a PARTIAL checksum — and the
158
+ # gateway then forwarded them, unfinished, to every machine behind it.
159
+ #
160
+ # Symptoms, all explained by this one line: ping works at any size, DNS works, the
161
+ # SYN leaves correctly and the SYN-ACK comes back correctly at the gateway — but the
162
+ # machine never sees it, and every TCP connection to the internet times out during
163
+ # the handshake. ICMP is immune because the kernel always checksums it in software;
164
+ # DNS survived because it went to Docker's loopback resolver and never crossed a tap.
165
+ # The give-away in a capture is the SAME checksum on every inbound packet (the
166
+ # pseudo-header partial sum) while the correct values all differ.
167
+ #
168
+ # Passing 0 also disables TSO/UFO, which is equally necessary: segmentation offload
169
+ # would hand us frames far larger than the MTU, which the UDP transport cannot carry.
170
+ try:
171
+ fcntl.ioctl(fd, TUNSETOFFLOAD, 0)
172
+ except OSError as exc:
173
+ # Not fatal, but it must never be silent — this failing looks exactly like a
174
+ # broken network, and the last time it was quiet it cost a full debugging session.
175
+ print(f"[shuttle] WARNING: could not disable offloads on {name} ({exc}). "
176
+ f"TCP through this container may be dropped for bad checksums.",
177
+ file=sys.stderr)
178
+ return fd
179
+
180
+
181
+ def configure_iface(name: str, mac: str, cidr: str) -> None:
182
+ subprocess.run(["ip", "link", "set", name, "address", mac], check=True)
183
+ subprocess.run(["ip", "addr", "add", cidr, "dev", name], check=True)
184
+ subprocess.run(["ip", "link", "set", name, "up"], check=True)
185
+ subprocess.run(["ip", "link", "set", name, "mtu", "1400"], check=True) # absorb UDP encap
186
+
187
+
188
+ def add_default_route(gw: str, exp_net: str = EXP_SUPERNET) -> None:
189
+ # Route the experiment supernet via the gateway. Connected /24s (from `ip addr
190
+ # add`) are more specific and win for on-link subnets; this catches everything
191
+ # else. The container's default route stays on docker eth0 for the UDP transport.
192
+ subprocess.run(["ip", "route", "replace", exp_net, "via", gw], check=False)
193
+
194
+
195
+ def set_fabric_default(gw: str) -> None:
196
+ """Send the *real* default route (0.0.0.0/0) into the fabric via `gw`, so internet-
197
+ bound traffic egresses through the drawn routers + Internet element (not docker eth0).
198
+ Used on ordinary hosts when an Internet element is present on the canvas."""
199
+ subprocess.run(["ip", "route", "replace", "default", "via", gw], check=False)
200
+
201
+
202
+ def cut_default_route() -> None:
203
+ """Faithful mode with no Internet element: remove the docker-eth0 default route so this
204
+ host has NO path to the internet (the management NIC isn't a back door). Inter-container
205
+ traffic and the simulated fabric still work — those use connected/`10.0.0.0/8` routes."""
206
+ subprocess.run(["ip", "route", "del", "default"], check=False)
207
+ print("[gini] faithful mode: default route removed (no internet on this host)",
208
+ file=sys.stderr)
209
+
210
+
211
+ def _wan_iface() -> str | None:
212
+ """The interface attached to the external `wan` bridge (its IP is in WAN_SUBNET)."""
213
+ out = subprocess.run(["ip", "-o", "-4", "addr", "show"],
214
+ capture_output=True, text=True).stdout
215
+ for line in out.splitlines():
216
+ parts = line.split() # "<idx>: <ifname> inet <ip>/<pfx> ..."
217
+ if len(parts) >= 4 and parts[1] != "lo" and parts[3].startswith(WAN_SUBNET_PREFIX):
218
+ return parts[1]
219
+ return None
220
+
221
+
222
+ def _enable_ip_forward() -> None:
223
+ try:
224
+ with open("/proc/sys/net/ipv4/ip_forward", "w") as f: # namespaced; NET_ADMIN ok
225
+ f.write("1")
226
+ except OSError:
227
+ subprocess.run(["sysctl", "-w", "net.ipv4.ip_forward=1"], check=False)
228
+
229
+
230
+ def vnf_commands(nf: str, rules: str, gw: str | None = None) -> list[list[str]]:
231
+ """The commands that make this container an inline VNF of kind `nf` (a network function
232
+ in the forwarding path). PURE — returns the argv lists; `setup_vnf` runs them — so the
233
+ rule-to-iptables translation is unit-testable without Docker.
234
+
235
+ firewall/block are real (iptables on the FORWARD chain); ids/cache/shaper forward-only
236
+ for now (no real data-plane backend yet — labeled illustrative in the UI)."""
237
+ cmds: list[list[str]] = []
238
+ if gw: # onward route so transit traffic continues to egress
239
+ cmds.append(["ip", "route", "replace", EXP_SUPERNET, "via", gw])
240
+ lines = [ln.strip() for ln in (rules or "").replace(",", "\n").splitlines() if ln.strip()]
241
+ if nf == "firewall":
242
+ for ln in lines:
243
+ toks = ln.split()
244
+ verb = toks[0].lower() if toks else ""
245
+ rest = toks[1:]
246
+ if verb in ("deny", "drop", "block") and rest:
247
+ if len(rest) >= 2 and rest[0].lower() == "from":
248
+ cmds.append(["iptables", "-A", "FORWARD", "-s", rest[1], "-j", "DROP"])
249
+ else:
250
+ cmds.append(["iptables", "-A", "FORWARD", "-d", rest[0], "-j", "DROP"])
251
+ elif nf == "block":
252
+ for ln in lines:
253
+ cmds.append(["iptables", "-A", "FORWARD", "-d", ln.split()[0], "-j", "DROP"])
254
+ return cmds
255
+
256
+
257
+ def setup_vnf(cfg: dict) -> None:
258
+ """Inline VNF: forward between the interfaces and apply the network function."""
259
+ _enable_ip_forward()
260
+ for cmd in vnf_commands(cfg.get("nf", ""), cfg.get("nf_rules", ""), cfg.get("gw")):
261
+ subprocess.run(cmd, check=False)
262
+ print(f"[{cfg.get('name')}] VNF '{cfg.get('nf')}' inline (rules: {cfg.get('nf_rules')!r})",
263
+ file=sys.stderr)
264
+
265
+
266
+ def setup_nat_gateway(cfg: dict) -> None:
267
+ """Turn this container into the lab's NAT gateway (the drawn Internet element):
268
+ enable IP forwarding, pin the default route out the external uplink, MASQUERADE the
269
+ fabric out to the world, and route the experiment supernet back via the local router
270
+ so replies reach the hosts behind us."""
271
+ _enable_ip_forward()
272
+ subprocess.run(["ip", "route", "replace", "default", "via", WAN_GATEWAY], check=False)
273
+ wan = _wan_iface()
274
+ if wan:
275
+ subprocess.run(["iptables", "-t", "nat", "-A", "POSTROUTING",
276
+ "-o", wan, "-j", "MASQUERADE"], check=False)
277
+ fgw = cfg.get("fabric_gw")
278
+ if fgw: # return path: GINI subnets reachable via the router
279
+ subprocess.run(["ip", "route", "replace", EXP_SUPERNET, "via", fgw], check=False)
280
+ print(f"[{cfg.get('name')}] NAT gateway: {EXP_SUPERNET} via {fgw} "
281
+ f"<-> internet via {wan}", file=sys.stderr)
282
+
283
+
284
+ def main() -> None:
285
+ cfg = json.loads(os.environ["NODE_CONFIG"])
286
+ ifaces = cfg.get("ifaces")
287
+ if ifaces is None: # back-compat with the old single-iface shape
288
+ ifaces = [{"ip": cfg["ip"], "mac": cfg["mac"],
289
+ "tap": cfg.get("tap", "gini0"), "port": cfg["port"]}]
290
+
291
+ sel = selectors.DefaultSelector()
292
+ fd_port: dict[int, Port] = {} # tap fd -> its uplink Port
293
+ port_fd: dict[int, int] = {} # port socket fileno -> tap fd
294
+ for idx, itf in enumerate(ifaces):
295
+ tap = itf.get("tap", f"gini{idx}")
296
+ fd = open_tap(tap)
297
+ configure_iface(tap, itf["mac"], itf["ip"])
298
+ port = Port.from_cfg(itf["port"], name=tap)
299
+ fd_port[fd] = port
300
+ port_fd[port.sock.fileno()] = fd
301
+ sel.register(fd, selectors.EVENT_READ, "tap")
302
+ sel.register(port.sock, selectors.EVENT_READ, "udp")
303
+ print(f"[{cfg['name']}] {tap} {itf['ip']} <-> UDP "
304
+ f"{port.peer_host}:{port.peer_port}", file=sys.stderr)
305
+
306
+ if cfg.get("gateway"): # the drawn Internet element = NAT gateway
307
+ setup_nat_gateway(cfg)
308
+ elif cfg.get("nf"): # an inline VNF (network function in the path)
309
+ setup_vnf(cfg)
310
+ elif cfg.get("fabric_default") and cfg.get("gw"):
311
+ set_fabric_default(cfg["gw"]) # internet egresses through the fabric
312
+ else:
313
+ if cfg.get("gw"):
314
+ add_default_route(cfg["gw"]) # experiment supernet via the router
315
+ if cfg.get("cut_default"): # faithful mode: no internet on this host
316
+ cut_default_route()
317
+
318
+ # Counted, and announced the first time it happens: a data plane quietly rewriting
319
+ # packets is exactly the kind of thing that should be visible in a teaching tool.
320
+ repaired = [0]
321
+
322
+ while True:
323
+ for key, _ in sel.select():
324
+ if key.data == "tap":
325
+ frame = os.read(key.fileobj, 65535) # TAP -> UDP
326
+ if frame:
327
+ # Last chance to make the bytes true. Anything FORWARDED through this
328
+ # container may carry a checksum its originator never finished — see
329
+ # repair_l4_checksum() — and once it is on the fabric the receiving
330
+ # machine will drop it without a word. Locally generated traffic is
331
+ # already correct, so this is a no-op on the common path.
332
+ buf = bytearray(frame)
333
+ if repair_l4_checksum(buf):
334
+ repaired[0] += 1
335
+ if repaired[0] in (1, 100, 10000):
336
+ print(f"[{cfg['name']}] repaired {repaired[0]} unfinished "
337
+ f"L4 checksum(s) on forwarded traffic", file=sys.stderr)
338
+ frame = bytes(buf)
339
+ fd_port[key.fileobj].send(frame)
340
+ else:
341
+ port = fd_port[port_fd[key.fileobj.fileno()]]
342
+ frame = port.recv() # UDP -> TAP
343
+ if frame:
344
+ os.write(port_fd[key.fileobj.fileno()], frame)
345
+
346
+
347
+ if __name__ == "__main__":
348
+ main()