aboutsummaryrefslogtreecommitdiffstats
path: root/tools/tunbridge.py
blob: b462c1b22bd76e8966366333e22a291f092ec3ed (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
#!/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)

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
            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:
                    try: os.write(tun, bytes(buf))
                    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()