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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/runtime/grouter.py
ADDED
|
@@ -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()
|
gini/runtime/hostsim.py
ADDED
|
@@ -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)
|
gini/runtime/shuttle.py
ADDED
|
@@ -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()
|