#!/usr/bin/env python3 """tunbridge.py - give gbos a real routed IP over the link port. Opens a TUN device (gbtun0, 10.0.0.1/24), NATs it to the internet, spawns the emulator, and bridges: SLIP frames on the link serial <-> raw IP packets on the TUN. The Game Boy (10.0.0.2) becomes a genuine IP host - the host kernel routes its packets for real. Run as root: sudo python3 tools/tunbridge.py TLS is still not the GB's problem; plaintext services work end to end. """ import os, sys, fcntl, struct, subprocess, threading, time, signal, atexit # resolve paths against the invoking (non-root) user's home, since sudo makes ~ = /root _HOME = os.path.expanduser("~" + (os.environ.get("SUDO_USER") or "")) EMU = os.path.join(_HOME, "dev/gbc/build/sl0pboy") ROM = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "gbos.gb")) IFACE, GW_IP, GB_NET, MTU = "gbtun0", "10.0.0.1", "10.0.0.0/24", 256 END, ESC, ESC_END, ESC_ESC = 0xC0, 0xDB, 0xDC, 0xDD TUNSETIFF, IFF_TUN, IFF_NO_PI = 0x400454ca, 0x0001, 0x1000 def slip_encode(pkt): out = bytearray([END]) for c in pkt: if c == END: out += bytes([ESC, ESC_END]) elif c == ESC: out += bytes([ESC, ESC_ESC]) else: out.append(c) out.append(END) return bytes(out) # ---- built-in DHCP server: the bridge leases the GB 10.0.0.2 ---------------- GB_IP = b"\x0a\x00\x00\x02"; GW = b"\x0a\x00\x00\x01" def _cksum(d): s = 0 for i in range(0, len(d) - 1, 2): s += (d[i] << 8) | d[i + 1] if len(d) % 2: s += d[-1] << 8 while s >> 16: s = (s & 0xFFFF) + (s >> 16) return (~s) & 0xFFFF def _dhcp_mtype(dhcp): p = 240 while p < len(dhcp) and dhcp[p] != 255: if dhcp[p] == 0: p += 1; continue if dhcp[p] == 53: return dhcp[p + 2] p += 2 + dhcp[p + 1] return 0 def _dhcp_reply(req, mt): d = bytearray(240) d[0] = 2; d[1] = 1; d[2] = 6 d[4:8] = req[4:8] # xid d[16:20] = GB_IP # yiaddr d[20:24] = GW # siaddr d[28:44] = req[28:44] # chaddr d[236:240] = b"\x63\x82\x53\x63" d += bytes([53, 1, mt, 54, 4]) + GW + bytes([255]) udp = bytearray(8); udp[1] = 67; udp[3] = 68 ul = 8 + len(d); udp[4] = ul >> 8; udp[5] = ul & 0xFF udp += d tot = 20 + len(udp) ip = bytearray(20); ip[0] = 0x45; ip[2] = tot >> 8; ip[3] = tot & 0xFF ip[8] = 64; ip[9] = 17; ip[12:16] = GW; ip[16:20] = b"\xff\xff\xff\xff" ck = _cksum(ip); ip[10] = ck >> 8; ip[11] = ck & 0xFF return bytes(ip) + bytes(udp) def dhcp_intercept(frame, send_raw): """If frame is a DHCP request (UDP -> :67), answer it and return True.""" if len(frame) < 28 or frame[9] != 17: return False ihl = (frame[0] & 0x0f) * 4 if len(frame) < ihl + 4 or ((frame[ihl + 2] << 8) | frame[ihl + 3]) != 67: return False dhcp = frame[ihl + 8:] mt = _dhcp_mtype(dhcp) if mt == 1: send_raw(_dhcp_reply(dhcp, 2)) # DISCOVER -> OFFER elif mt == 3: send_raw(_dhcp_reply(dhcp, 5)) # REQUEST -> ACK return True def sh(cmd, quiet=True): subprocess.run(cmd, shell=True, check=False, stdout=subprocess.DEVNULL if quiet else None, stderr=subprocess.DEVNULL if quiet else None) def wan_iface(): try: return subprocess.check_output("ip route show default | awk '{print $5; exit}'", shell=True).decode().strip() or "eth0" except Exception: return "eth0" def main(): if os.geteuid() != 0: sys.exit("must run as root (sudo)") tun = os.open("/dev/net/tun", os.O_RDWR) fcntl.ioctl(tun, TUNSETIFF, struct.pack("16sH", IFACE.encode(), IFF_TUN | IFF_NO_PI)) wan = wan_iface() sh("ip addr add %s/24 dev %s" % (GW_IP, IFACE)) sh("ip link set %s mtu %d up" % (IFACE, MTU)) sh("sysctl -w net.ipv4.ip_forward=1") sh("iptables -t nat -C POSTROUTING -s %s -o %s -j MASQUERADE || " "iptables -t nat -A POSTROUTING -s %s -o %s -j MASQUERADE" % (GB_NET, wan, GB_NET, wan)) sh("iptables -C FORWARD -i %s -j ACCEPT || iptables -I FORWARD -i %s -j ACCEPT" % (IFACE, IFACE)) sh("iptables -C FORWARD -o %s -j ACCEPT || iptables -I FORWARD -o %s -j ACCEPT" % (IFACE, IFACE)) sys.stderr.write("[tun] %s up %s/24, NAT via %s, GB is 10.0.0.2\n" % (IFACE, GW_IP, wan)) def cleanup(): sh("iptables -t nat -D POSTROUTING -s %s -o %s -j MASQUERADE" % (GB_NET, wan)) sh("iptables -D FORWARD -i %s -j ACCEPT" % IFACE) sh("iptables -D FORWARD -o %s -j ACCEPT" % IFACE) atexit.register(cleanup) p = subprocess.Popen([EMU, "--headless", "--uncapped", ROM], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0, start_new_session=True) def tun_to_gb(): # host -> Game Boy while True: pkt = os.read(tun, 2048) if not pkt: return if len(pkt) < 20 or pkt[16:20] != b"\x0a\x00\x00\x02": continue # only unicast to 10.0.0.2 (drop mcast noise) try: p.stdin.write(slip_encode(pkt)); p.stdin.flush() except Exception: return def gb_to_tun(): # Game Boy -> host buf, in_frame, esc = bytearray(), False, False while True: b = p.stdout.read(1) if not b: return c = b[0] if c == END: if in_frame and buf: fr = bytes(buf) if not dhcp_intercept(fr, lambda pk: (p.stdin.write(slip_encode(pk)), p.stdin.flush())): try: os.write(tun, fr) except Exception: pass buf, in_frame, esc = bytearray(), False, False else: buf, in_frame, esc = bytearray(), True, False elif in_frame: if esc: c = {ESC_END: END, ESC_ESC: ESC}.get(c, c); esc = False; buf.append(c) elif c == ESC: esc = True else: buf.append(c) else: sys.stderr.write(chr(c) if 32 <= c < 127 else ("\n" if c == 10 else "")) # Drain the GB->host direction from the start, but only start feeding host->GB # packets AFTER netd is running: raw packet bytes fed to gbos's shell (before # netd claims the serial) corrupt the shell and crash the emulator. threading.Thread(target=gb_to_tun, daemon=True).start() args = sys.argv[1:] testmode = "--test" in args if testmode: args.remove("--test") cmd = args[0] if args else "netd" time.sleep(1.0) p.stdin.write(cmd.encode() + b"\n"); p.stdin.flush() time.sleep(0.8) threading.Thread(target=tun_to_gb, daemon=True).start() sys.stderr.write("[tun] running %s on the GB\n" % cmd) if testmode: time.sleep(3.0) r = subprocess.run("ping -c 4 -W 2 10.0.0.2", shell=True, capture_output=True, text=True) sys.stderr.write("\n=== ping 10.0.0.2 (host -> Game Boy, real routing) ===\n") sys.stderr.write(r.stdout + r.stderr + "\n") p.kill(); return sys.stderr.write("[tun] try: ping 10.0.0.2 (Ctrl-C to stop)\n") signal.signal(signal.SIGTERM, lambda *a: sys.exit(0)) try: while p.poll() is None: time.sleep(0.5) except KeyboardInterrupt: pass finally: p.kill() if __name__ == "__main__": main()