diff options
| author | user <user@clank> | 2026-09-22 17:12:52 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-22 17:12:52 +0200 |
| commit | 30e67fe0f4e66e2161ca61f870f8947d50287995 (patch) | |
| tree | a04189f34687a4d600903100feca18aedc5d1604 | |
| parent | net: passive open (listen/accept) + own sockets + close promptly (diff) | |
| download | gbos-30e67fe0f4e66e2161ca61f870f8947d50287995.tar.gz gbos-30e67fe0f4e66e2161ca61f870f8947d50287995.tar.xz gbos-30e67fe0f4e66e2161ca61f870f8947d50287995.zip | |
usr: httpd - the Game Boy serves its filesystem over HTTP
/# httpd &
host$ curl http://10.0.0.2/ -> a listing of the cart RAM
host$ curl http://10.0.0.2/readme -> the file, from battery-backed SRAM
GET only. "/" serves /index.html if present, else a generated listing; any
directory lists with every entry linked, so you can click around a 1997
handheld's disk from a browser. Content type from the extension. No
Content-Length: HTTP/1.0 + Connection: close delimits the body by closing,
which suits a stack with one in-flight segment.
All output goes through one 200-byte buffer, so each net_send is a full
segment instead of one tiny segment per string.
It drains the whole request, not just the request line. That is not
politeness: leaving the rest unread means those segments are never
acknowledged, so the client retransmits headers for a minute, never sends
its FIN, and its dead connection keeps arriving at port 80 long after we
re-listen. One curl with 1.4 KB of headers stalled every following request
for 30-120s.
It deliberately does NOT read the keyboard either. Backgrounded - the useful
way to run it - every key it polled would be a key stolen from the shell's
prompt. It is a daemon: kill(1) stops it, and the kernel reclaims its socket.
Verified with curl: index, byte-exact file bodies, nested paths, directory
listings, 404, 501, a 1.4 KB request spanning segments, browser user-agents,
three parallel clients (0.1/1.1/2.1s), the shell staying usable alongside it,
and six kill/restart cycles - more than there are sockets.
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | docs/internals.md | 40 | ||||
| -rw-r--r-- | src/programs.asm | 7 | ||||
| -rw-r--r-- | usr/httpd.c | 277 |
5 files changed, 331 insertions, 5 deletions
@@ -22,7 +22,7 @@ $(BUILD)/%.o: src/%.asm | $(BUILD) PROGS := chello echo true false uname pid cat wc head args \ ls save rm sh ps kill spin blk mkdir count ptest necho \ wget chat netd ping nslookup dhcp irc uptime ansi \ - grep more cp nc + grep more cp nc httpd CBLOBS := $(patsubst %,$(BUILD)/usr/%.bin,$(PROGS)) $(BUILD)/programs.o: $(CBLOBS) @@ -26,6 +26,10 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).* `nc` (netcat: `nc HOST PORT` interactively, or `nc -s HOST PORT < file` to pipe a request in and print the reply), `chat`, and a colorized **IRC client** (`irc HOST [NICK]`, `/join` and friends, SELECT for the keyboard). +- **It serves, too**: the kernel does passive opens (`listen`/`accept`), and + `httpd` puts the cart's filesystem on the web — `httpd &`, then point a + browser at the Game Boy: files are served from battery-backed RAM and + directories come back as clickable listings. - **A network to plug into**: `tools/gbhub` is a virtual switch + DHCP server + NAT gateway — spawn a fleet of Game Boys that ping *each other* and reach the real internet; `tools/gbjoin` attaches an interactive GB to it, and @@ -33,7 +37,7 @@ into the on-screen console. Regenerate it anytime with `tools/gbdemo` (below).* - **40×18 color terminal** on the LCD (4×8 font, two chars per tile, ANSI-ish colors) + an **on-screen keyboard** on the window layer (SELECT toggles it). - **Persistent filesystem** on battery-backed cart RAM: inodes, nested - directories, per-process cwd — survives power-off. + directories, per-process cwd — survives power-off (files up to 3 KiB). - **A C toolchain**: userland is plain C compiled with SDCC (`sm83`); ~35 tools ship in `usr/` (`cat`, `wc`, `head`, `grep`, `cp`, `more`, `ls`, `uptime`, …). Adding a program is one `.c` file + a table entry. @@ -51,6 +55,10 @@ make # -> gbos.gb (needs RGBDS + SDCC) sudo tools/gbhub --daemon # virtual net + DHCP + NAT (needs root) tools/gbjoin # a windowed GB joins 10.0.0.0/24 tools/gbtype 'ping 10.0.0.3' # or type into it from the host + +# serve the Game Boy's filesystem to your browser: +tools/gbtype 'httpd &' # then open http://10.0.0.2/ +curl http://10.0.0.2/readme # files straight out of cart RAM ``` On the Game Boy: **SELECT** toggles the on-screen keyboard, d-pad + **A** diff --git a/docs/internals.md b/docs/internals.md index 8ee7816..62167f4 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -149,6 +149,17 @@ Userland networking is plain C on top of that: `dhcp` (DISCOVER→ACK, then pump stdin and print the reply), `irc` (a full client: channels, /join /msg /me, nick colors, flood-safe rendering) and `chat`. +**Listening** (`net_op_listen` / `net_op_accept`): sockets demux on the local +port *alone*, so a listener and its connection are the same socket - the SYN +fills in the peer and the socket becomes the connection. That means one +connection at a time, re-armed by listening again after close; a second client +knocking is dropped in silence and its SYN retransmit is taken when we are +free again (~1s later), which is why `httpd` serves three parallel clients in +0.1/1.1/2.1s. `accept` never blocks: one RX pump per call, like `recv_nb`. +Since SYNs reach the same socket as the live connection, `tcp_peer_is_current` +is what tells a peer's retransmitted SYN apart from a newcomer's - without it +a second client silently *hijacks* the connection mid-request. + **Receive-path invariants** (all three were bugs once, and each one is invisible until a peer sends more than a toy reply): @@ -165,6 +176,22 @@ invisible until a peer sends more than a toy reply): that sits past a gap marks the socket `TCP_DONE`, and `recv` then reports EOF at the hole — truncating the transfer just before the last segments. +`tcp_close` FINs and then pumps for the peer's FIN/ACK, but **stops as soon as +it arrives** instead of burning its whole budget: the socket otherwise sits in +`TCP_FINWAIT` for about a second, and a server that closes and re-listens drops +the next client's SYN in that window, so every request after the first paid a +SYN retransmit (1.1s → 0.1s per request). + +A server must also **drain the request it was sent**. Reading only the request +line leaves the rest unacknowledged: the client retransmits headers for a +minute, never sends its FIN, and its dead connection keeps arriving at the port +long after the server re-listens. One 1.4 KB request stalled every following +connection for 30-120s. + +Sockets are owned by the pid that created them (`SK_OWNER`) and released by +`SockReleaseOwner` on exit *and* on kill — the table has only 4 entries, and a +killed `irc`/`nc`/`httpd` used to hold its slot until reboot. + Bytes that arrive **outside** SLIP frames land in a console ring instead — that's how the host can type into the shell remotely (`tools/gbtype`). `sys_pollcon` pumps the link itself before draining that ring, so a program @@ -185,13 +212,20 @@ A small **block-based, persistent filesystem** on battery-backed cart RAM ``` block 0 superblock (magic + version) block 1 bitmaps (free blocks + free inodes) -blocks 2-3 inode table (32 inodes x 16 B: type, size, 8 direct block ptrs) +blocks 2-3 inode table (32 inodes x 16 B: type, nlink, size, 12 block ptrs) blocks 4.. data blocks ``` - **Inodes + nested directories.** A directory is a file of 16-byte entries - (inode# + name), each carrying `.`/`..`. Files are up to **2 KiB** (8 direct - blocks); **32** inodes, 16 entries per directory. + (inode# + name), each carrying `.`/`..`. Files are up to **3 KiB** + (`NDIRECT` = 12 direct blocks, the whole tail of the inode); **32** inodes, + 16 entries per directory. `sys_getb`/`sys_putb` **bounds-check the block + index** - they did not, so a file that grew past the pointers simply kept + walking into the *next inode*, reading its fields as block numbers. A file + could round-trip fine (reads and writes aliased identically) and only show + the damage once something else touched the neighbouring inode. +- **One-block data cache.** Two files open at once thrash it: `cp` of a 3 KiB + file alternates read/write blocks per byte and takes ~25s. Correct, slow. - **Paths + per-process cwd.** `open`/`mkdir`/`chdir`/`remove`/`ls` take paths (absolute `/a/b` or relative `a/b`); each process has a cwd inode (`PROC_CWD`, inherited on fork). Shell builtins: `cd`, and a cwd-aware prompt. diff --git a/src/programs.asm b/src/programs.asm index a3bd680..31125a2 100644 --- a/src/programs.asm +++ b/src/programs.asm @@ -151,6 +151,9 @@ ProgCp: SECTION "prog_nc", ROMX[$4000], BANK[38] ProgNc: INCBIN "build/usr/nc.bin" +SECTION "prog_httpd", ROMX[$4000], BANK[39] +ProgHttpd: + INCBIN "build/usr/httpd.bin" ; ----------------------------------------------------------------------------- ; PROG_SH (bank 3) - the shell, now written in C (usr/sh.c): parses >/</| and @@ -267,6 +270,8 @@ ProgramTable:: dw ProgCp db LOW(BANK(ProgNc)), HIGH(BANK(ProgNc)) dw ProgNc + db LOW(BANK(ProgHttpd)), HIGH(BANK(ProgHttpd)) + dw ProgHttpd ProgramTableEnd:: ; ----------------------------------------------------------------------------- @@ -348,4 +353,6 @@ NameTable:: db PROG_CP db "nc", 0 db PROG_NC + db "httpd", 0 + db PROG_HTTPD db 0 diff --git a/usr/httpd.c b/usr/httpd.c new file mode 100644 index 0000000..335e824 --- /dev/null +++ b/usr/httpd.c @@ -0,0 +1,277 @@ +#include "sock.h" +/* httpd [PORT] - serve the Game Boy's filesystem over HTTP. Default port 80. + * + * /# dhcp (or let boot do it) + * /# httpd & background it and keep using the shell + * host$ curl http://10.0.0.2/ -> a directory listing of the cart RAM + * /# ps ; kill 4 stop it (the kernel frees its socket) + * + * Deliberately does NOT read the keyboard: backgrounded (which is the useful + * way to run it) any key it polled would be a key stolen from the shell's own + * prompt. It is a daemon; kill(1) stops it. + * + * GET only. "/" serves /index.html if it exists, else a generated listing; + * any directory lists; anything else is served from the filesystem with a + * content type guessed from the extension. No Content-Length: HTTP/1.0 with + * Connection: close delimits the body by closing, which is also all a stack + * with one in-flight segment wants to promise. + * + * One connection at a time: sockets demux on local port alone, so the + * listener IS the connection (see net_op_listen). Serve, close, re-listen. + * + * All output goes through obuf, so every net_send() is one ~MSS segment + * rather than one tiny segment per string. + * + * Statics are fine here (unlike grep/more/cp) because main() copies the + * command line off $A000 before touching any of them - sock.h's _nr already + * sits there. Same trick as ping/irc. + */ + +#define PORT_DEF 80 +#define BUFSZ 208 /* one full segment in */ +#define OBUFSZ 200 /* one full segment out (TCP_MSS) */ +#define REQSZ 120 /* the request line is all we parse */ +#define PATHSZ 48 + +static unsigned char sock; +static unsigned char rbuf[BUFSZ]; +static char obuf[OBUFSZ]; +static unsigned char olen; +static char req[REQSZ]; +static unsigned char reqlen; +static char path[PATHSZ]; + +/* ---- buffered output ------------------------------------------------------ */ +static void oflush(void) { + if (olen) { + net_send(sock, obuf, olen); + olen = 0; + } +} +static void oputc(char c) { + if (olen >= OBUFSZ) oflush(); + obuf[olen++] = c; +} +static void oputs(const char *s) { + while (*s) oputc(*s++); +} + +/* ---- request parsing ------------------------------------------------------ */ +/* Read the request line, then KEEP READING to the blank line that ends the + headers - only the first line is kept. + + Draining is not politeness, it is required. Stopping at the request line + leaves the rest of a browser-sized request unread, so our window never + opens and those segments are never acknowledged: the client retransmits + headers for a minute, never sends its FIN, and its dead connection keeps + arriving at port 80 long after we re-listen. One curl with 1.4 KB of + headers was enough to stall every following request for 30-120s. */ +static unsigned char read_request(void) { + unsigned char idle = 0, n, i, c, nlrun = 0, got_line = 0; + unsigned int total = 0; + reqlen = 0; + req[0] = 0; + for (;;) { + n = net_recv_nb(sock, rbuf, BUFSZ); + if (n == 0) return reqlen; /* peer closed */ + if (n == 0xFE || n == 0xFF) { + if (++idle >= 40) return reqlen; /* ~2s of silence */ + msleep(50); + continue; + } + idle = 0; + total += n; + for (i = 0; i < n; i++) { + c = rbuf[i]; + if (c == '\r') continue; + if (c == '\n') { + if (!got_line) { /* first line: that's the one */ + req[reqlen] = 0; + got_line = 1; + } + if (++nlrun >= 2) return reqlen; /* blank line: headers done */ + continue; + } + nlrun = 0; + if (!got_line && reqlen < REQSZ - 1) req[reqlen++] = (char)c; + } + if (total > 4000) return reqlen; /* don't read a body forever */ + } +} + +/* "GET /path HTTP/1.0" -> path[]; query strings are dropped, %xx is not + decoded (gbos filenames are 15 chars of plain text). */ +static void parse_path(void) { + unsigned char i = 0, j = 0; + while (req[i] && req[i] != ' ') i++; /* skip the method */ + while (req[i] == ' ') i++; + while (req[i] && req[i] != ' ' && req[i] != '?' && j < PATHSZ - 1) path[j++] = req[i++]; + path[j] = 0; + if (!path[0]) { + path[0] = '/'; + path[1] = 0; + } +} + +static unsigned char is_get(void) { + return (unsigned char)(req[0] == 'G' && req[1] == 'E' && req[2] == 'T'); +} + +/* content type from the extension (everything unknown is text/plain, which + browsers will happily render) */ +static const char *ctype_for(const char *p) { + unsigned char i = 0, dot = 0; + while (p[i]) { + if (p[i] == '.') dot = (unsigned char)(i + 1); + i++; + } + if (!dot) return "text/plain"; + if (p[dot] == 'h' && p[dot + 1] == 't' && p[dot + 2] == 'm') return "text/html"; + if (p[dot] == 'c' && p[dot + 1] == 's' && p[dot + 2] == 's') return "text/css"; + if (p[dot] == 'j' && p[dot + 1] == 's') return "text/javascript"; + return "text/plain"; +} + +/* ---- responses ------------------------------------------------------------ */ +static void hdr(const char *status, const char *ctype) { + oputs("HTTP/1.0 "); + oputs(status); + oputs("\r\nServer: gbos\r\nContent-Type: "); + oputs(ctype); + oputs("\r\nConnection: close\r\n\r\n"); +} + +static void logline(const char *code) { + puts(code); + putc(' '); + puts(path); + nl(); +} + +static void serve_error(const char *status, const char *body) { + hdr(status, "text/plain"); + oputs(body); + oputs("\r\n"); + oflush(); + logline(status); +} + +static void serve_fd(unsigned char fd, const char *ctype) { + int ch; + hdr("200 OK", ctype); + while ((ch = fgetc(fd)) >= 0) oputc((char)ch); + close(fd); + oflush(); + logline("200"); +} + +/* a generated index: every entry linked, so you can click around the cart RAM + from a browser on the other end of the link cable */ +static void serve_dir(const char *dir) { + char name[16]; + unsigned char i, root; + if (opendir(dir)) { + serve_error("404 Not Found", "no such directory"); + return; + } + root = (unsigned char)(dir[0] == '/' && dir[1] == 0); + hdr("200 OK", "text/html"); + oputs("<html><head><title>gbos:"); + oputs(dir); + oputs("</title></head><body><h1>gbos:"); + oputs(dir); + oputs("</h1><ul>"); + for (i = 0; flist(i, name); i++) { + name[15] = 0; + oputs("<li><a href=\""); + oputs(dir); + if (!root) oputc('/'); + oputs(name); + oputs("\">"); + oputs(name); + oputs("</a></li>"); + } + oputs("</ul><hr><i>served from battery-backed cart RAM by a Game Boy " + "Color</i></body></html>"); + oflush(); + logline("200"); +} + +static void handle(void) { + unsigned char fd; + olen = 0; + if (!read_request()) return; /* connected, then said nothing */ + parse_path(); + if (!is_get()) { + serve_error("501 Not Implemented", "GET only"); + return; + } + if (path[0] == '/' && path[1] == 0) { /* "/" -> index.html, else listing */ + fd = open("/index.html", O_READ); + if (fd <= 3) { + serve_fd(fd, "text/html"); + return; + } + serve_dir("/"); + return; + } + fd = open(path, O_READ); + if (fd == EISDIR) { + serve_dir(path); + return; + } + if (fd > 3) { + serve_error("404 Not Found", "no such file"); + return; + } + serve_fd(fd, ctype_for(path)); +} + +/* atou() is 8-bit; ports are not */ +static unsigned int atou16(const char *s) { + unsigned int n = 0; + while (*s >= '0' && *s <= '9') { + n = n * 10 + (unsigned char)(*s - '0'); + s++; + } + return n; +} + +void main(void) { + char args[16]; + unsigned int port; + unsigned char i; + + { /* copy the command line before any static (incl. _nr) lands on $A000 */ + char *a = getargs(); + for (i = 0; a[i] && i < 15; i++) args[i] = a[i]; + args[i] = 0; + } + olen = 0; + port = (args[0] >= '0' && args[0] <= '9') ? atou16(args) : PORT_DEF; + + puts("httpd: listening on port "); + putu(port); + nl(); + puts(" (run it with & and kill it to stop)"); + nl(); + + for (;;) { + sock = net_socket(SOCK_TCP); + if (sock == 0xFF) { + puts("httpd: no socket"); + nl(); + sexit(1); + } + if (net_listen(sock, port) == 0xFF) { + puts("httpd: listen failed"); + nl(); + net_close(sock); + sexit(1); + } + while (net_accept(sock) != 0) /* one RX pump per call */ + msleep(20); + handle(); + net_close(sock); /* FIN: this is what ends the body */ + } +} |
