// A virtual switch + DHCP server, in the page. // // This is tools/gbhub from the gbos repo, minus the parts that need a kernel: // it leases each Game Boy an address, routes IP packets between them, floods // broadcasts, and answers pings to the gateway itself. What it deliberately // cannot do is NAT to the real internet - that needs a TUN device, and a // browser tab has no raw sockets. So this is a closed network: the Game Boys // talk to each other and to the gateway, and nothing leaves the page. // // The wire is SLIP over the link port, exactly as the gbos kernel speaks it. // Bytes that arrive *outside* a SLIP frame are not packets at all - they are // console I/O, which is how typing into the link port reaches the shell. const END = 0xC0, ESC = 0xDB, ESC_END = 0xDC, ESC_ESC = 0xDD; export const GW = Uint8Array.from([10, 0, 0, 1]); const BCAST = "255.255.255.255"; export const ipStr = (b, o = 0) => `${b[o]}.${b[o + 1]}.${b[o + 2]}.${b[o + 3]}`; export function slipEncode(pkt) { const out = [END]; for (const c of pkt) { if (c === END) out.push(ESC, ESC_END); else if (c === ESC) out.push(ESC, ESC_ESC); else out.push(c); } out.push(END); return Uint8Array.from(out); } // Feed bytes in, get {frames:[Uint8Array], text:"..."} out. One decoder per // link, because the escape/in-frame state has to persist across chunks. export class SlipDecoder { constructor() { this.buf = []; this.inFrame = false; this.esc = false; this.ansi = 0; // 0 = text, 1 = saw ESC, 2 = inside a CSI sequence } feed(bytes) { const frames = []; let text = ""; for (const c of bytes) { if (c === END) { if (this.inFrame && this.buf.length) { frames.push(Uint8Array.from(this.buf)); this.buf = []; this.inFrame = false; } else { this.buf = []; this.inFrame = true; this.esc = false; } } else if (this.inFrame) { if (this.esc) { this.buf.push(c === ESC_END ? END : c === ESC_ESC ? ESC : c); this.esc = false; } else if (c === ESC) this.esc = true; else this.buf.push(c); } else if (this.ansi === 1) { this.ansi = (c === 0x5B) ? 2 : 0; // ESC [ ... starts CSI } else if (this.ansi === 2) { if (c >= 0x40 && c <= 0x7E) this.ansi = 0; // final byte ends it } else if (c === 0x1B) { // The console is a colour terminal (irc(1) paints nicks and status // bars), and the LCD renders those sequences. This mirror is plain // text, so drop them rather than printing "[44m[37m" at the reader. this.ansi = 1; } else if (c === 10 || (c >= 32 && c < 127)) { text += String.fromCharCode(c); // unframed = console output } } return { frames, text }; } } // One's-complement checksum over a byte range (IP header, ICMP message). function cksum(d, from = 0, to = d.length) { let s = 0; for (let i = from; i < to - 1; i += 2) s += (d[i] << 8) | d[i + 1]; if ((to - from) % 2) s += d[to - 1] << 8; while (s >> 16) s = (s & 0xFFFF) + (s >> 16); return (~s) & 0xFFFF; } // DHCP message type out of the options block (starts at 240). function dhcpType(d, off) { let p = off + 240; while (p < d.length && d[p] !== 255) { if (d[p] === 0) { p++; continue; } if (d[p] === 53) return d[p + 2]; p += 2 + d[p + 1]; } return 0; } // Build a BOOTP reply (OFFER=2 / ACK=5) wrapped in UDP+IP, like gbhub's _dreply. function dhcpReply(req, off, mt, gbip) { const d = new Uint8Array(240 + 10); d[0] = 2; d[1] = 1; d[2] = 6; d.set(req.subarray(off + 4, off + 8), 4); // xid d.set(gbip, 16); // yiaddr d.set(GW, 20); // siaddr d.set(req.subarray(off + 28, off + 44), 28); // chaddr d.set([0x63, 0x82, 0x53, 0x63], 236); // magic cookie d.set([53, 1, mt, 54, 4, GW[0], GW[1], GW[2], GW[3], 255], 240); const udp = new Uint8Array(8 + d.length); udp[0] = 0; udp[1] = 67; udp[2] = 0; udp[3] = 68; // 67 -> 68 const ul = udp.length; udp[4] = ul >> 8; udp[5] = ul & 0xFF; udp.set(d, 8); const ip = new Uint8Array(20 + udp.length); ip[0] = 0x45; const tot = ip.length; ip[2] = tot >> 8; ip[3] = tot & 0xFF; ip[8] = 64; ip[9] = 17; // TTL, UDP ip.set(GW, 12); ip.set([255, 255, 255, 255], 16); const ck = cksum(ip, 0, 20); ip[10] = ck >> 8; ip[11] = ck & 0xFF; ip.set(udp, 20); return ip; } // Turn an ICMP echo request addressed to the gateway into a reply. function icmpEchoReply(fr, ihl) { const r = fr.slice(); r.set(fr.subarray(12, 16), 16); // dst = old src r.set(GW, 12); // src = gateway r[10] = 0; r[11] = 0; const hck = cksum(r, 0, ihl); r[10] = hck >> 8; r[11] = hck & 0xFF; r[ihl] = 0; // type 8 (echo) -> 0 (reply) r[ihl + 2] = 0; r[ihl + 3] = 0; const ck = cksum(r, ihl, r.length); r[ihl + 2] = ck >> 8; r[ihl + 3] = ck & 0xFF; return r; } export class Hub { // onEvent({type, port, text, ...}) for the UI: "lease", "packet", "console". constructor({ onEvent = () => {} } = {}) { this.ports = new Map(); // id -> port this.byIp = new Map(); // "10.0.0.2" -> port this.pool = []; for (let o = 2; o < 40; o++) this.pool.push(o); this.onEvent = onEvent; this.packets = 0; } // A port is anything with .send(Uint8Array) - here, a Game Boy's link. attach(id, send) { const octet = this.pool.shift(); if (octet === undefined) throw new Error("address pool exhausted"); const port = { id, octet, send, ip: Uint8Array.from([10, 0, 0, octet]), dec: new SlipDecoder(), line: "", leased: false, rx: 0, tx: 0, }; port.ipText = ipStr(port.ip); this.ports.set(id, port); this.byIp.set(port.ipText, port); return port; } detach(id) { const port = this.ports.get(id); if (!port) return; this.ports.delete(id); this.byIp.delete(port.ipText); // Give the address back, keeping the pool ordered so numbering stays tidy. this.pool.push(port.octet); this.pool.sort((a, b) => a - b); } // Bytes a Game Boy put on the wire. feed(id, bytes) { const port = this.ports.get(id); if (!port) return; const { frames, text } = port.dec.feed(bytes); for (const fr of frames) { port.tx++; this.#route(port, fr); } if (text) { port.line += text; let nl; while ((nl = port.line.indexOf("\n")) >= 0) { const line = port.line.slice(0, nl); port.line = port.line.slice(nl + 1); this.onEvent({ type: "console", port, text: line }); } } } #deliver(port, fr) { port.rx++; port.send(slipEncode(fr)); } #route(from, fr) { this.packets++; if (fr.length >= 28 && fr[9] === 17) { // UDP const ihl = (fr[0] & 0x0F) * 4; const dport = (fr[ihl + 2] << 8) | fr[ihl + 3]; if (dport === 67) { // DHCP: we are the server const off = ihl + 8; const mt = dhcpType(fr, off); if (mt === 1) { this.#deliver(from, dhcpReply(fr, off, 2, from.ip)); // OFFER return; } if (mt === 3) { this.#deliver(from, dhcpReply(fr, off, 5, from.ip)); // ACK if (!from.leased) { from.leased = true; this.onEvent({ type: "lease", port: from }); } return; } return; } } if (fr.length < 20) return; const dst = ipStr(fr, 16); if (dst === BCAST) { for (const p of this.ports.values()) if (p !== from) this.#deliver(p, fr); this.onEvent({ type: "packet", port: from, dst, kind: "broadcast" }); return; } if (dst === ipStr(GW)) { // to the gateway itself const ihl = (fr[0] & 0x0F) * 4; if (fr[9] === 1 && fr[ihl] === 8) { // ICMP echo request this.#deliver(from, icmpEchoReply(fr, ihl)); this.onEvent({ type: "packet", port: from, dst, kind: "ping-gw" }); } return; } const to = this.byIp.get(dst); if (to) { this.#deliver(to, fr); this.onEvent({ type: "packet", port: from, dst, kind: "unicast" }); } else { // No NAT: a browser tab can't put this on the real internet. this.onEvent({ type: "packet", port: from, dst, kind: "no-route" }); } } }