From b08432b2c330fd8b9c29aa813d7a4762ba73b01e Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Mon, 14 Sep 2026 19:24:55 +0200 Subject: [PATCH] userland: 268 applets, an editor, names that resolve, and a web server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four complaints, and only one of them was the thing it looked like. **"toybox is too small."** Half true, and the fix cost no licensing at all: 236 of toybox's applets are what defconfig builds, and the rest live in toys/pending/ -- awk, bc, diff, more, expr, tr, hexdump, stty among them. Same 0BSD tarball, switched off by a config default. 26 of them are on now, 236 -> 268. Not everything: strace wants ptrace, syslogd wants BSD's facilitynames[], git wants OpenSSL, and those three break the link. The toybox build also works again on this machine. Its scripts start #!/bin/bash, which does not exist on NixOS, so the binary in the tree was one nobody could reproduce; the shebangs are rewritten to /usr/bin/env bash on the way in. Pending applets are enabled by editing .config directly, because `make oldconfig` is the obvious way and undoes it -- every pending symbol is `default n`. **BusyBox was asked for and is not here.** It is GPL-2.0-only, and `make ramroot` links the root image *into the kernel binary*, so it would put GPLv2 inside an Apache-2.0 executable -- which is the exact case ports/Makefile's NOTPORTS filter and docs/licensing.md §4 exist for. nano is the same answer one version later. Both recorded in licensing.md with what was done instead, since "no" without an alternative is how a rule gets quietly broken later. **An editor that is not vi.** kilo, BSD-2-Clause, one 1300-line file, no dependencies beyond termios and TIOCGWINSZ, both of which zsh already needed. Installed as /bin/edit as well as /bin/kilo, because somebody with a file to fix will type `edit`. **"wget doesn't work."** Not a userland problem, and BusyBox's wget would have failed identically: nothing on this system could turn a name into an address, so every program taking a hostname failed in its own way and each looked like a separate bug. There is still no DNS client in SchOS and there does not need to be -- musl has a complete resolver and it works over ordinary UDP sockets. The one missing fact was which nameserver, which the kernel has had all along from DHCP and never published. /proc/resolv.conf publishes it in resolv.conf's exact syntax; cmd/resolvconf decides whether it becomes /etc/resolv.conf, and will not overwrite a hand-written one without -f. # wget -O - http://example.com/ | head -1 Example Domain **Something to host with.** cmd/httpd: GET and HEAD, static files, index.html for a directory and a listing otherwise, a child per connection. No CGI, no keep-alive, no TLS, no config file. The path check is the only security-relevant code in it and it refuses rather than normalises -- no component may be `..`, and percent-decoding happens first, because %2e%2e is `..` and a check before decoding does not see it. Both forms answer 403. And the disk is 100 MB, from 32. It is sparse, so the image is 21 MB on the host. RAMDISK_BLOCKS went 6000 -> 8000 with it: the ramdisk was at 86% and the next thing added would have broken `make ramroot`, which nobody would have noticed until they used it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2 --- Makefile | 38 ++- NOTICE | 17 +- README.md | 18 +- cmd/httpd/main.c | 460 ++++++++++++++++++++++++++++++++ cmd/resolvconf/main.c | 136 ++++++++++ docs/architecture/README.md | 2 +- docs/architecture/networking.md | 86 +++++- docs/licensing.md | 37 +++ etc/schinit/resolvconf.job | 25 ++ include/defs.h | 1 + kernel/dev/procfs.c | 2 + kernel/fs/procfs.c | 3 + kernel/net/net.c | 43 +++ ports/Makefile | 2 + ports/kilo/Makefile | 84 ++++++ ports/toybox/Makefile | 46 ++++ srv/index.html | 15 ++ 17 files changed, 994 insertions(+), 21 deletions(-) create mode 100644 cmd/httpd/main.c create mode 100644 cmd/resolvconf/main.c create mode 100644 etc/schinit/resolvconf.job create mode 100644 ports/kilo/Makefile create mode 100644 srv/index.html diff --git a/Makefile b/Makefile index 641e549..4860216 100644 --- a/Makefile +++ b/Makefile @@ -108,7 +108,7 @@ KERN_NOENTRY = $(filter-out $(BUILD)/kernel/arch/x86_64/entry.o, $(KERNOBJS)) # Third-party software does NOT go here — see ports/. # ----------------------------------------------------------------------- ULIB = $(addprefix $(BUILD)/lib/, start.o glue.o usys.o) -CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt pcitest vgpud schdbg schdbgd schmode netecho ldlsd +CMDS = schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt pcitest vgpud schdbg schdbgd resolvconf httpd schmode netecho ldlsd CMDBINS = $(addprefix $(BUILD)/cmd/, $(CMDS)) # ----------------------------------------------------------------------- @@ -379,11 +379,18 @@ CFLAGS_SYS = $(CFLAGS) -I./kernel/sys # # Links a complete SCHFS image into the kernel and boots from it instead of # the IDE disk, so the physical disk can be reformatted from a running system. -# Off by default because it adds RAMDISK_BLOCKS KB to the kernel, and stage2 -# loads the kernel by PIO one sector at a time -- a 6 MB kernel takes visibly -# longer to boot than a 350 KB one. +# Off by default because the image is linked into the kernel binary, and stage2 +# loads that by PIO one sector at a time -- a kernel with a userland inside it +# takes visibly longer to boot than a 350 KB one. +# +# 8000 blocks of 4 KB is 32 MB. It was 6000, which the userland had quietly +# grown to fill: 5157 of 6000 used, 86%, after toybox gained its pending +# applets and an editor arrived. That still fit, and the *next* thing added +# would not have -- and what mkschfs says when an image is full is "try a +# larger --size", which is a poor way to find out that a build variant you were +# not using has been broken for a while. # ----------------------------------------------------------------------- -RAMDISK_BLOCKS ?= 6000 +RAMDISK_BLOCKS ?= 8000 # RAMDISK_NINODES used to live here, sizing mkfs's preallocated inode table. # The ramdisk is a SchFS image now (see $(BUILD)/fs-ram.img) and SchFS allocates @@ -1051,12 +1058,18 @@ ETC_PLAIN = $(filter-out etc/schinit etc/zsh,$(ETC_FILES)) # anything with more room. LOGO_ASCII = docs/branding/Ascii/ascii_50.txt -etc-install: $(SCHINIT_JOBS) $(ETC_PLAIN) $(ETC_ZSH) $(LOGO_ASCII) - @mkdir -p $(SYSROOT)/etc/schinit $(SYSROOT)/etc/zsh $(SYSROOT)/usr/share/schos +# What httpd serves, if nobody has said otherwise. One page, whose job is to +# prove the path works end to end -- a directory listing of an empty /srv +# proves the same thing far less clearly. +SRV_FILES = $(wildcard srv/*) + +etc-install: $(SCHINIT_JOBS) $(ETC_PLAIN) $(ETC_ZSH) $(LOGO_ASCII) $(SRV_FILES) + @mkdir -p $(SYSROOT)/etc/schinit $(SYSROOT)/etc/zsh $(SYSROOT)/usr/share/schos $(SYSROOT)/srv @cp $(ETC_PLAIN) $(SYSROOT)/etc/ @cp $(SCHINIT_JOBS) $(SYSROOT)/etc/schinit/ @cp $(ETC_ZSH) $(SYSROOT)/etc/zsh/ @cp $(LOGO_ASCII) $(SYSROOT)/usr/share/schos/logo.txt + @cp $(SRV_FILES) $(SYSROOT)/srv/ @echo "installed $(words $(ETC_PLAIN)) /etc file(s), $(words $(ETC_ZSH)) zsh file(s) and $(words $(SCHINIT_JOBS)) schinit manifest(s)" # The mount points. Created here rather than by init, because a mkdir on a @@ -1213,10 +1226,19 @@ qemu: esp $(BUILD)/schfs.img $(OVMF_CODE) $(OVMF_VARS) # The root filesystem. Built from the staged sysroot; the kernel mounts it as # / in forkret(). +# +# 25600 blocks of 4 KB is 100 MB. It was 8000 (32 MB), which was comfortable +# when the whole userland was one multicall binary and stopped being so once +# there was more than one thing in it -- and mkschfs's answer to a full image +# is "try a larger --size", which is a worse thing to read than a number here +# is to change. A raw disk image is sparse on any filesystem worth the name, +# so the unused part costs nothing until it is used. +SCHFS_BLOCKS ?= 25600 + $(BUILD)/schfs.img: $(MKSCHFS) $(BUILD)/sysroot.stamp @mkdir -p $(BUILD) @rm -f $@ - ./$(MKSCHFS) $@ --size 8000 --label schos --sysroot $(SYSROOT) + ./$(MKSCHFS) $@ --size $(SCHFS_BLOCKS) --label schos --sysroot $(SYSROOT) # Boot with a SchFS volume attached as the second disk. # diff --git a/NOTICE b/NOTICE index e14e588..d029b17 100644 --- a/NOTICE +++ b/NOTICE @@ -37,9 +37,20 @@ toybox 0.8.12 Zero-Clause BSD (0BSD) -------------------------------------------------------------------------------- Copyright (c) 2006-2024 Rob Landley and contributors -Built from ports/toybox. Supplies the standard command set in /bin. 0BSD -imposes no attribution requirement at all; this entry is courtesy, not -obligation. +Built from ports/toybox. Supplies the standard command set in /bin -- 268 +applets, which is defconfig's 236 plus a curated set from toys/pending/ (awk, +bc, diff and the rest); see ports/toybox/Makefile. 0BSD imposes no attribution +requirement at all; this entry is courtesy, not obligation. + +-------------------------------------------------------------------------------- +kilo (commit 323d93b) BSD 2-Clause +-------------------------------------------------------------------------------- +Copyright (c) 2016 Salvatore Sanfilippo + +Built from ports/kilo. Supplies /bin/kilo and /bin/edit, the text editor. The +BSD 2-Clause terms require the copyright notice and disclaimer be retained in +binary distributions; the full text is installed at +/usr/share/licenses/kilo/LICENSE in every image. -------------------------------------------------------------------------------- mksh R59c MirOS Licence diff --git a/README.md b/README.md index 5b401fd..9145e8a 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,14 @@ serial. `/dev/fb0` speaks the Linux fbdev interface. **Networking** — ARP, IPv4, ICMP, UDP, TCP and DHCP, on lwIP behind a driver-agnostic interface class. Verified end to end from the host, both -directions, rather than by inspection. +directions, rather than by inspection. Names resolve, so `wget http://host/` +works; `httpd` serves a directory back the other way. -**Userland** — musl, toybox and mksh, built from source. `ls`, `cat`, `ps`, -`top`, `wc`, `cp`, `mount` and the rest behave the way you expect, because they -are the real programs. +**Userland** — musl, toybox, mksh and zsh, built from source. 268 applets: +`ls`, `cat`, `ps`, `top`, `wc`, `cp`, `mount`, `awk`, `bc`, `diff` and the rest +behave the way you expect, because they are the real programs. `edit` is +[kilo](https://github.com/antirez/kilo) — arrow keys, Ctrl-S, Ctrl-Q, nothing +to learn. **Text** — a TrueType rasteriser (`glyf` outlines, composite glyphs, cmap format 4) replacing the 8×16 cell grid. JetBrains Mono for the terminal, Roboto @@ -150,8 +153,11 @@ Another: **syscalls are declared in exactly one place**, **Apache-2.0**, copyright Nexoniarz. See [LICENSE](LICENSE). The kernel derives from xv6, which is MIT — see [LICENSE.MIT](LICENSE.MIT). -Third-party code redistributed in a built image (musl, toybox, mksh, the fonts) -keeps its own licence; [NOTICE](NOTICE) carries the attributions. +Third-party code redistributed in a built image (musl, toybox, mksh, zsh, kilo, +the fonts) keeps its own licence; [NOTICE](NOTICE) carries the attributions. +All of it is permissive — there is no GPL in an image this builds, which is why +the userland is toybox and not BusyBox and the editor is kilo and not nano. See +[docs/licensing.md](docs/licensing.md). Drivers may be adapted from BSD sources, which are compatible. Linux drivers are not: GPL-2.0-only and Apache-2.0 cannot be combined and redistributed. Reading diff --git a/cmd/httpd/main.c b/cmd/httpd/main.c new file mode 100644 index 0000000..89e9cdd --- /dev/null +++ b/cmd/httpd/main.c @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// httpd -- serve a directory over HTTP. +// +// SchOS could fetch a page before it could serve one, which is a strange shape +// for a system with a TCP stack, a filesystem and no web server. This is the +// other half. +// +// httpd /srv on port 80, anywhere +// httpd -r /tmp -p 8080 somewhere else, some other port +// httpd -a 127.0.0.1 loopback only +// +// GET and HEAD, static files, `index.html` for a directory and a listing when +// there is none. It is deliberately a *file server* and not a web server: +// there is no CGI, no scripting, no configuration file, no virtual hosts, no +// keep-alive and no TLS. Every one of those is a decision somebody should make +// on purpose, and none of them is needed to answer the question this exists to +// answer -- whether this machine can put a file on the network. +// +// **The path check is the only security-relevant code here**, and it is done +// by rejecting rather than by normalising. See resolve() below. + +#include "types.h" +#include "user.h" +#include "fcntl.h" +#include "stat.h" +#include "socket.h" +#include "posix.h" + +#define DEFAULT_PORT 80 +#define DEFAULT_ROOT "/srv" + +static char root[128] = DEFAULT_ROOT; + +static int +slen(const char *s) +{ + int n = 0; + while (s[n]) n++; + return n; +} + +static void +puts_(const char *s) +{ + write(1, (void *)s, slen(s)); +} + +static void +putn(int v) +{ + char b[12]; + int i = 0; + if (v == 0) { write(1, "0", 1); return; } + if (v < 0) { write(1, "-", 1); v = -v; } + while (v > 0 && i < 11) { b[i++] = '0' + (v % 10); v /= 10; } + while (i > 0) { i--; write(1, &b[i], 1); } +} + +static int +atoi_(const char *s) +{ + int v = 0; + while (*s >= '0' && *s <= '9') v = v * 10 + (*s++ - '0'); + return v; +} + +static int +scat(char *dst, int at, int cap, const char *src) +{ + while (*src && at < cap - 1) dst[at++] = *src++; + dst[at] = 0; + return at; +} + +static int +ucat(char *dst, int at, int cap, unsigned long long v) +{ + char b[24]; + int i = 0; + if (v == 0) b[i++] = '0'; + while (v > 0 && i < 23) { b[i++] = '0' + (int)(v % 10); v /= 10; } + while (i > 0 && at < cap - 1) dst[at++] = b[--i]; + dst[at] = 0; + return at; +} + +static unsigned int +parse_ip(const char *s, int *ok) +{ + unsigned int b[4] = { 0, 0, 0, 0 }; + int i; + + *ok = 0; + for (i = 0; i < 4; i++) { + int any = 0; + while (*s >= '0' && *s <= '9') { b[i] = b[i] * 10 + (*s++ - '0'); any = 1; } + if (!any || b[i] > 255) return 0; + if (i < 3) { if (*s != '.') return 0; s++; } + } + if (*s) return 0; + *ok = 1; + return b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24); +} + +// --- what a file is called, and what that means ----------------------------- + +static const struct { const char *ext, *type; } mime[] = { + { ".html", "text/html" }, + { ".htm", "text/html" }, + { ".css", "text/css" }, + { ".js", "text/javascript" }, + { ".json", "application/json" }, + { ".txt", "text/plain" }, + { ".md", "text/plain" }, + { ".c", "text/plain" }, + { ".h", "text/plain" }, + { ".png", "image/png" }, + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".gif", "image/gif" }, + { ".svg", "image/svg+xml" }, + { ".ico", "image/x-icon" }, + { ".pdf", "application/pdf" }, + { ".gz", "application/gzip" }, + { ".tar", "application/x-tar" }, +}; + +// Unknown types are octet-stream rather than text/plain. A browser handed the +// wrong text type renders a binary as mojibake; handed octet-stream it offers +// to save it, which is the behaviour you want for the file you did not think +// of when writing this table. +static const char * +type_of(const char *path) +{ + int n = slen(path), i, j; + + for (i = 0; i < (int)(sizeof(mime) / sizeof(mime[0])); i++) { + int e = slen(mime[i].ext); + if (n < e) continue; + for (j = 0; j < e; j++) { + char a = path[n - e + j], b = mime[i].ext[j]; + if (a >= 'A' && a <= 'Z') a += 32; + if (a != b) break; + } + if (j == e) return mime[i].type; + } + return "application/octet-stream"; +} + +// --- the path ---------------------------------------------------------------- + +static int +hexval(char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +// Turn a request target into a path under `root`, or refuse. +// +// **Refusing rather than normalising** is the whole design. The tempting +// version resolves `..` and collapses slashes and then checks the result, and +// it is wrong in a way that is hard to see: every such normaliser has to agree +// exactly with the kernel's, and when it does not, the difference is a file +// outside the root. So no component may be `..`, full stop -- there is nothing +// to get subtly wrong, and no legitimate request contains one. +// +// Percent-decoding happens first, because `%2e%2e` is `..` and a check that +// runs before decoding does not see it. +// +// Returns 0 on success, -1 on a malformed target, -2 on one that escapes. +static int +resolve(const char *target, char *out, int cap) +{ + char dec[512]; + int i = 0, n = 0, at; + + // Decode, stopping at the query string -- there is nothing here that reads + // one, and carrying it into a filename would look for a file named after + // it. + while (target[i] && target[i] != '?' && target[i] != '#' && n < (int)sizeof(dec) - 1) { + if (target[i] == '%') { + int h = hexval(target[i + 1]), l = h < 0 ? -1 : hexval(target[i + 2]); + if (l < 0) return -1; + dec[n++] = (char)((h << 4) | l); + i += 3; + } else { + dec[n++] = target[i++]; + } + } + dec[n] = 0; + + if (dec[0] != '/') + return -1; + // A NUL anywhere in the decoded target truncates the path for the kernel + // but not for any length check above it. Nothing legitimate contains one. + for (i = 0; i < n; i++) + if (dec[i] == 0) + return -1; + + // No component may be `..`, and none may be empty-with-a-dot. + for (i = 0; i < n; i++) { + if (dec[i] != '/') + continue; + if (dec[i + 1] == '.' && dec[i + 2] == '.' && + (dec[i + 3] == '/' || dec[i + 3] == 0)) + return -2; + } + + at = scat(out, 0, cap, root); + at = scat(out, at, cap, dec); + if (at >= cap - 1) + return -1; + // Trailing slash off, so that stat() and the listing agree about the name. + while (at > 1 && out[at - 1] == '/') + out[--at] = 0; + return 0; +} + +// --- responses --------------------------------------------------------------- + +static void +head(int fd, const char *status, const char *type, long long len) +{ + char h[256]; + int at = 0; + + at = scat(h, at, sizeof(h), "HTTP/1.0 "); + at = scat(h, at, sizeof(h), status); + at = scat(h, at, sizeof(h), "\r\nServer: SchOS httpd\r\nConnection: close\r\n" + "Content-Type: "); + at = scat(h, at, sizeof(h), type); + if (len >= 0) { + at = scat(h, at, sizeof(h), "\r\nContent-Length: "); + at = ucat(h, at, sizeof(h), (unsigned long long)len); + } + at = scat(h, at, sizeof(h), "\r\n\r\n"); + write(fd, h, at); +} + +static void +fail(int fd, const char *status, const char *msg) +{ + char b[256]; + int at = 0; + + at = scat(b, at, sizeof(b), ""); + at = scat(b, at, sizeof(b), status); + at = scat(b, at, sizeof(b), "

"); + at = scat(b, at, sizeof(b), status); + at = scat(b, at, sizeof(b), "

"); + at = scat(b, at, sizeof(b), msg); + at = scat(b, at, sizeof(b), "\n"); + + head(fd, status, "text/html", at); + write(fd, b, at); +} + +static void +send_file(int fd, const char *path, int body) +{ + char buf[4096]; + struct stat st; + int in, n; + + in = open((char *)path, O_RDONLY); + if (in < 0) { fail(fd, "404 Not Found", "No such file."); return; } + if (fstat(in, &st) < 0) { close(in); fail(fd, "500 Internal Server Error", "stat failed."); return; } + + head(fd, "200 OK", type_of(path), (long long)st.size); + if (!body) { close(in); return; } + + while ((n = read(in, buf, sizeof(buf))) > 0) + if (write(fd, buf, n) != n) + break; // the client went away; not an error + close(in); +} + +// A directory with no index.html. Generated straight into the socket, so a +// directory with ten thousand entries does not need ten thousand entries' +// worth of memory -- which means no Content-Length, which is why every +// response here is HTTP/1.0 with Connection: close. +static void +send_listing(int fd, const char *path, const char *target, int body) +{ + char dbuf[2048], line[512]; + int d, n, at; + + d = open((char *)path, O_RDONLY); + if (d < 0) { fail(fd, "404 Not Found", "No such directory."); return; } + + head(fd, "200 OK", "text/html", -1); + if (!body) { close(d); return; } + + at = scat(line, 0, sizeof(line), ""); + at = scat(line, at, sizeof(line), target); + at = scat(line, at, sizeof(line), "

"); + at = scat(line, at, sizeof(line), target); + at = scat(line, at, sizeof(line), "

\n", 6); + close(d); +} + +// --- one request ------------------------------------------------------------- + +static void +serve(int fd) +{ + char req[1024], path[640], target[512]; + struct stat st; + int n = 0, i, body = 1; + + // The request line is all this reads. Headers are skipped rather than + // parsed: nothing here varies on one, and a parser for something nobody + // consults is a parser that can only be wrong. + while (n < (int)sizeof(req) - 1) { + int r = read(fd, req + n, 1); + if (r <= 0) return; + if (req[n] == '\n') break; + n++; + } + req[n] = 0; + while (n > 0 && (req[n - 1] == '\r' || req[n - 1] == ' ')) + req[--n] = 0; + + if (req[0] == 'G' && req[1] == 'E' && req[2] == 'T' && req[3] == ' ') + i = 4, body = 1; + else if (req[0] == 'H' && req[1] == 'E' && req[2] == 'A' && req[3] == 'D' && req[4] == ' ') + i = 5, body = 0; + else { + fail(fd, "501 Not Implemented", "GET and HEAD only."); + return; + } + + { int j = 0; + while (req[i] && req[i] != ' ' && j < (int)sizeof(target) - 1) + target[j++] = req[i++]; + target[j] = 0; } + + switch (resolve(target, path, sizeof(path))) { + case -1: fail(fd, "400 Bad Request", "That is not a path this serves."); return; + case -2: fail(fd, "403 Forbidden", "The path leaves the document root."); return; + } + + puts_("httpd: "); puts_(body ? "GET " : "HEAD "); puts_(path); puts_("\n"); + + if (stat(path, &st) < 0) { fail(fd, "404 Not Found", "No such file."); return; } + + if (st.type == T_DIR) { + char idx[700]; + int at = scat(idx, 0, sizeof(idx), path); + at = scat(idx, at, sizeof(idx), "/index.html"); + if (stat(idx, &st) == 0 && st.type != T_DIR) + send_file(fd, idx, body); + else + send_listing(fd, path, target, body); + return; + } + send_file(fd, path, body); +} + +int +main(int argc, char *argv[]) +{ + struct sockaddr_in sa; + unsigned int addr = 0; // 0.0.0.0 + int port = DEFAULT_PORT, lfd, i; + + for (i = 1; i < argc; i++) { + if (argv[i][0] != '-' || argv[i][2] != 0 || i + 1 >= argc) + goto usage; + switch (argv[i][1]) { + case 'p': port = atoi_(argv[++i]); break; + case 'r': { int at = scat(root, 0, sizeof(root), argv[++i]); + while (at > 1 && root[at - 1] == '/') root[--at] = 0; } break; + case 'a': { int ok; addr = parse_ip(argv[++i], &ok); + if (!ok) { puts_("httpd: -a wants an address\n"); return 1; } } break; + default: + usage: + puts_("usage: httpd [-r root] [-p port] [-a address]\n" + " serves " DEFAULT_ROOT " on port 80, from anywhere, by default.\n"); + return 1; + } + } + if (port <= 0 || port > 65535) { puts_("httpd: that is not a port\n"); return 1; } + + { struct stat st; + if (stat(root, &st) < 0 || st.type != T_DIR) { + puts_("httpd: no such directory: "); puts_(root); puts_("\n"); + return 1; + } } + + lfd = socket(AF_INET, SOCK_STREAM, 0); + if (lfd < 0) { puts_("httpd: no socket\n"); return 1; } + + sa.sin_family = AF_INET; + sa.sin_port = (unsigned short)(((port & 0xff) << 8) | ((port >> 8) & 0xff)); + sa.sin_addr = addr; + for (i = 0; i < 8; i++) sa.sin_zero[i] = 0; + + if (bind(lfd, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + puts_("httpd: cannot bind port "); putn(port); + puts_(port < 1024 ? " (ports below 1024 may be reserved)\n" : "\n"); + return 1; + } + if (listen(lfd, 8) < 0) { puts_("httpd: cannot listen\n"); return 1; } + + puts_("httpd: serving "); puts_(root); + puts_(" on port "); putn(port); puts_("\n"); + + for (;;) { + int fd = accept(lfd, 0, 0); + if (fd < 0) + continue; + + // A child per connection, so that one client that stops reading cannot + // stop the server. The parent reaps without blocking: a zombie per + // request would fill the process table on a busy machine, and blocking + // to reap would undo the fork. + int pid = fork(); + if (pid == 0) { + close(lfd); + serve(fd); + close(fd); + exit(); + } + close(fd); + if (pid < 0) + puts_("httpd: cannot fork; dropped a connection\n"); + while (waitpid(-1, 0, WNOHANG) > 0) + ; + } +} diff --git a/cmd/resolvconf/main.c b/cmd/resolvconf/main.c new file mode 100644 index 0000000..f5d9e50 --- /dev/null +++ b/cmd/resolvconf/main.c @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// resolvconf -- put the nameserver where the resolver will look. +// +// SchOS has no DNS client, and the interesting part is that it does not need +// one. musl contains a complete resolver: `getaddrinfo` reads +// /etc/resolv.conf, sends a query over an ordinary UDP socket and parses the +// reply, and every one of those pieces already worked here. The single missing +// fact was *which* nameserver -- which arrives in a DHCP reply the kernel +// handled and never told anyone about. +// +// That is what made `wget` look broken. It was not wget, and it was not the +// core utilities: nothing on this system could turn a name into an address, so +// every program that took a hostname failed the same way and each one looked +// like its own separate bug. +// +// resolvconf wait for a nameserver, write it, exit +// resolvconf -n write whatever there is now, do not wait +// resolvconf -p print what the kernel has, change nothing +// +// The kernel publishes /proc/resolv.conf and stops there, deliberately: it +// renders resolv.conf's exact syntax so this is a copy rather than a +// translation, and *whether* /etc/resolv.conf should be overwritten is a +// policy question. A machine with a hand-written one should keep it, which is +// why -f exists and why it is not the default. + +#include "types.h" +#include "user.h" +#include "fcntl.h" + +#define SRC "/proc/resolv.conf" +#define DST "/etc/resolv.conf" + +// DHCP takes a few seconds on a quiet network and longer on a busy one. Two +// minutes is past any of that and short enough that a machine with no DHCP +// server does not hold a boot job open for ever. +#define WAIT_SECONDS 120 + +static void +puts_(const char *s) +{ + int n = 0; + while (s[n]) n++; + write(1, (void *)s, n); +} + +static int +slurp(const char *path, char *buf, int cap) +{ + int fd = open((char *)path, O_RDONLY); + int n, len = 0; + + if (fd < 0) + return -1; + while (len < cap - 1 && (n = read(fd, buf + len, cap - 1 - len)) > 0) + len += n; + close(fd); + buf[len] = 0; + return len; +} + +int +main(int argc, char *argv[]) +{ + char buf[512]; + int i, n, fd, wait = 1, force = 0, print = 0; + + for (i = 1; i < argc; i++) { + if (argv[i][0] != '-' || argv[i][2] != 0) + goto usage; + switch (argv[i][1]) { + case 'n': wait = 0; break; + case 'f': force = 1; break; + case 'p': print = 1; wait = 0; break; + default: + usage: + puts_("usage: resolvconf [-n] [-f] [-p]\n" + " -n do not wait for DHCP\n" + " -f overwrite an existing " DST "\n" + " -p print what the kernel has and exit\n"); + return 1; + } + } + + // Wait by polling rather than by blocking on the file: /proc has no + // readiness to report and inventing one for this would be a worse + // interface than a loop that ends. + for (i = 0; ; i++) { + n = slurp(SRC, buf, sizeof(buf)); + if (n < 0) { + puts_("resolvconf: no " SRC " -- this kernel has no network\n"); + return 1; + } + if (n > 0 || !wait || i >= WAIT_SECONDS) + break; + sleep(100); // one second, at 100 Hz + } + + if (print) { + if (n > 0) write(1, buf, n); + else puts_("resolvconf: the kernel has no nameserver yet\n"); + return n > 0 ? 0 : 1; + } + + if (n == 0) { + puts_("resolvconf: no nameserver after "); + puts_(wait ? "two minutes" : "no wait"); + puts_(" -- is there a DHCP server?\n"); + return 1; + } + + // An existing file is somebody's decision until told otherwise. + if (!force) { + char old[512]; + if (slurp(DST, old, sizeof(old)) > 0) { + puts_("resolvconf: " DST " already exists; -f to replace it\n"); + return 0; + } + } + + fd = open(DST, O_WRONLY | O_CREAT | O_TRUNC); + if (fd < 0) { + puts_("resolvconf: cannot write " DST "\n"); + return 1; + } + if (write(fd, buf, n) != n) { + puts_("resolvconf: short write to " DST "\n"); + close(fd); + return 1; + } + close(fd); + + puts_("resolvconf: "); + write(1, buf, n); + return 0; +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 866e61d..81b643b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -29,7 +29,7 @@ each with its own screen, line discipline and foreground process group. See ``` ┌─────────────────────────────────────────────────┐ - │ toybox mksh zsh musl first-party cmd/ │ userland + │ toybox kilo mksh zsh musl first-party cmd/ │ userland ├─────────────────────────────────────────────────┤ │ vgpud — a driver that is an ordinary process │ ├─────────────────────────────────────────────────┤ diff --git a/docs/architecture/networking.md b/docs/architecture/networking.md index 33c0a4e..85d5788 100644 --- a/docs/architecture/networking.md +++ b/docs/architecture/networking.md @@ -341,6 +341,71 @@ be fixed before `recvfrom` could report a source address at all. --- +## Names + +For a long time nothing on this system could turn a name into an address, and +the shape of that failure is worth keeping: it did not look like one missing +feature. `wget` did not work. `telnet` did not work. Anything taking a hostname +did not work, each in its own way, and each looked like a separate broken +program — which is a good argument for a userland with fewer applets, and a +wrong one. + +**There is still no DNS client in SchOS, and there does not need to be.** musl +contains a complete resolver: `getaddrinfo` reads `/etc/resolv.conf`, sends a +query over an ordinary UDP socket and parses the reply. Every one of those +pieces already worked. The single missing fact was *which nameserver* — and +that arrives in a DHCP reply the kernel handled and never told anyone about. + +So the mechanism is one file: + +``` +# cat /proc/resolv.conf +nameserver 10.0.2.3 +``` + +`net_resolv()` renders **resolv.conf's exact syntax**, so that putting it where +the resolver looks is a copy and not a translation. It is empty until DHCP +answers, which is the honest report: a file saying nothing is right, and one +naming a server that does not exist is not. + +The policy is `cmd/resolvconf`, run once at boot by a `schinit` job. It waits +for a nameserver, writes `/etc/resolv.conf` and exits — and **will not +overwrite an existing one** without `-f`, because a machine with a hand-written +`resolv.conf` has been configured on purpose. + +That division is the usual one here: the kernel knows a fact a process cannot +find out for itself and publishes it; whether that fact should become +`/etc/resolv.conf` is not the kernel's business. + +``` +# wget -O - http://example.com/ | head -1 +Example Domain +``` + +## Serving + +`cmd/httpd` is the other direction, and it existed for the same reason this +section does: a machine that could fetch a page but not serve one is a strange +shape for something with a TCP stack and a filesystem. + +GET and HEAD, static files out of a directory, `index.html` for a directory and +a generated listing when there is none. A child per connection, so one client +that stops reading cannot stop the server. No CGI, no keep-alive, no TLS, no +configuration file — it is a *file server*, and each of those is a decision +somebody should make on purpose. + +**The path check is the only security-relevant code in it**, and it refuses +rather than normalises. The tempting version resolves `..`, collapses slashes +and then checks the result — and it has to agree exactly with the kernel's +resolver, and where it does not, the difference is a file outside the root. So +no component may be `..` at all. Percent-decoding happens first, because +`%2e%2e` is `..` and a check that runs before decoding does not see it. + +``` +$ curl -s -o /dev/null -w '%{http_code}\n' --path-as-is host/%2e%2e/etc/passwd +403 +``` + ## What it does not do - **`ping` is a first-party program**, `cmd/ping`, not toybox's. `recvmsg` now @@ -355,9 +420,24 @@ be fixed before `recvfrom` could report a source address at all. Past that `sendmsg` answers `ENOBUFS`. The cap exists because the kernel holds a reference to each one until it is received; a sender eight unreceived messages ahead of its receiver is not rescued by a ninth slot. -- **No resolver reachable from userland.** lwIP's DNS client is compiled but - nothing exposes it, so `ping` takes a dotted quad. `localhost` is special-cased - in the program rather than resolved. +- **`ping` still takes a dotted quad**, even though names now work. It is a + first-party program that does not link musl's resolver, and `localhost` is + special-cased in it rather than resolved. Every *ported* program resolves + names properly; ours is the one that does not. +- **Nothing re-runs `resolvconf`.** The nameserver is written once at boot. A + lease that changes the DNS server, or a `/dev/pci` handover that brings up a + new interface, leaves a stale `/etc/resolv.conf` until somebody runs it again. +- **lwIP's own DNS client is still unused.** It is compiled in and only + `dns_getserver()` is ever called; resolution happens entirely in the process. + Nothing in the kernel can resolve a name, which matters the day something in + the kernel wants to. +- **`httpd` does not serve HTTP/1.1.** Every response is HTTP/1.0 with + `Connection: close`, because the directory listing is generated straight into + the socket and so has no `Content-Length` to give. One request per connection + is the cost. +- **`httpd` has no timeouts.** A client that connects and says nothing ties up + a child process until it goes away; `SO_RCVTIMEO` does not exist here (see + below), so there is nothing to set. - **No routing table**, and no `/proc/net/route` to report one. With two interfaces up, the first is the default route and the second is reachable only by an address on its own subnet. That is a rule rather than an accident, and it diff --git a/docs/licensing.md b/docs/licensing.md index 7016d71..9b2bc69 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -212,6 +212,43 @@ with SchOS. Keeping the distinction sharp is what stops it eroding: rebuild never needs the network; not part of any image, and not all of it is redistributable. +### The userland this rules out, and what was done instead + +The rule costs something real and it is worth naming the bill rather than only +the principle. + +**BusyBox** is the obvious userland for a system this size — about four hundred +applets, including a shell, an editor, `less` and an `httpd`. It is +**GPL-2.0-only**, so §2 applies, and here the consequence is sharper than +"cannot be linked": `make ramroot` links the root filesystem image *into the +kernel binary*, so a BusyBox in `sysroot/` would put GPLv2 code inside an +Apache-2.0 executable. Excluding it from the ramdisk avoids that and leaves the +ramroot build with no userland at all, which is not a trade worth making. + +**nano** is the same answer for the same reason, one version later: GPL-3.0, +which is incompatible with Apache-2.0 in the other direction. + +What was done instead, when the complaint "the userland is too thin" came up: + +| wanted | answer | +|---|---| +| `awk`, `bc`, `diff`, `more`, `expr`, `tr`, … | already in toybox, in `toys/pending/`, switched off by `defconfig`. Turning 26 of them on took the shipped set from 236 applets to 268, at no licensing cost — it is the same 0BSD tarball. | +| an editor that is not `vi` | [kilo](https://github.com/antirez/kilo), BSD-2-Clause, one file, no dependencies. Installed as `/bin/edit` as well as `/bin/kilo`. | +| `wget` that works | not a userland problem at all. See below. | +| something to host with | `cmd/httpd`, written here, Apache-2.0. | + +The `wget` one is worth recording because it was diagnosed wrongly at first, +and switching userland would not have fixed it: **SchOS had no DNS reachable +from a process**, so every program taking a hostname failed and each looked +like its own bug. BusyBox's `wget` would have failed identically. musl already +contains a complete resolver; the only missing fact was which nameserver, which +the kernel had all along from DHCP and never published. See +[architecture/networking.md](architecture/networking.md). + +The general shape: **a permissive answer existed for every one of these**, and +three of the four were already in the tree. That will not always be true, and +when it is not the answer is still no. + The mechanisms that keep this from eroding, since good intentions have already failed once here: diff --git a/etc/schinit/resolvconf.job b/etc/schinit/resolvconf.job new file mode 100644 index 0000000..0ce5739 --- /dev/null +++ b/etc/schinit/resolvconf.job @@ -0,0 +1,25 @@ +# Put the nameserver where the resolver will look. +# +# musl's resolver reads /etc/resolv.conf and has always been able to do the +# rest by itself; the kernel learns the nameserver from DHCP and publishes it +# at /proc/resolv.conf. This job is the one line joining the two, and without +# it nothing on this system could turn a name into an address -- which made +# wget, and every other program that takes a hostname, look individually +# broken. +# +# RunAtLoad with no KeepAlive: it waits for DHCP, writes the file once and +# exits. A job that has finished is not a job that failed, so restarting it +# would be wrong. +# +# It will not overwrite an existing /etc/resolv.conf. A machine with a +# hand-written one has been configured on purpose, and `resolvconf -f` is how +# you say you meant it. + +Label = os.schos.resolvconf +Program = /bin/resolvconf +Args = resolvconf +RunAtLoad = yes +KeepAlive = no + +StandardOut = /dev/tty1 +StandardError = /dev/tty1 diff --git a/include/defs.h b/include/defs.h index c162e55..a22f4c4 100644 --- a/include/defs.h +++ b/include/defs.h @@ -624,6 +624,7 @@ int net_info(char *buf, int cap); void lwip_sys_init(void); void lwip_sys_lock(void); void lwip_sys_unlock(void); +int net_resolv(char *, int); int net_eth_attach(struct device *); int net_eth_detach(struct device *); int net_detach(struct device *); diff --git a/kernel/dev/procfs.c b/kernel/dev/procfs.c index 1b8ba1f..68e00a6 100644 --- a/kernel/dev/procfs.c +++ b/kernel/dev/procfs.c @@ -342,6 +342,7 @@ gen_filesystems(struct sbuf *sb) #define PROC_FSSTAT 14 #define PROC_NETIF 15 #define PROC_KMSG 17 +#define PROC_RESOLV 18 // Render global file `id` (a PROC_* constant) into `buf`. Returns its length. // @@ -376,6 +377,7 @@ proc_gen_global(int id, char *buf, int cap) // headers -- reaching into the stack from this one would put them on every // kernel file's include path. case PROC_NETIF: sb.len = net_info(buf, PROC_BUFSZ); break; + case PROC_RESOLV: sb.len = net_resolv(buf, PROC_BUFSZ); break; case PROC_DEVICES: gen_devices(&sb); break; default: return 0; } diff --git a/kernel/fs/procfs.c b/kernel/fs/procfs.c index 5263d61..9c3c844 100644 --- a/kernel/fs/procfs.c +++ b/kernel/fs/procfs.c @@ -94,6 +94,9 @@ static const struct global_file globals[] = { { "devices", 13 }, { "fsstat", 14 }, { "netif", 15 }, + // The nameservers DHCP supplied, in resolv.conf's own syntax. See + // net_resolv() and cmd/resolvconf. + { "resolv.conf", 18 }, { "kmsg", 17 }, }; #define NGLOBALS ((int)(sizeof(globals) / sizeof(globals[0]))) diff --git a/kernel/net/net.c b/kernel/net/net.c index 6fa8c5f..903e6fd 100644 --- a/kernel/net/net.c +++ b/kernel/net/net.c @@ -33,6 +33,7 @@ #include "lwip/netif.h" #include "lwip/dhcp.h" #include "lwip/ip4_addr.h" +#include "lwip/dns.h" // Wait until the next timer tick. `ticks` advances at 100 Hz, so this is a // sleep of up to 10 ms -- and, more importantly, it is the kernel's only timed @@ -414,6 +415,48 @@ put_field(char *out, const char *name) // can parse one can parse any number, which is the whole reason it is not a // single flat list of values: with two NICs a flat list has no way to say which // address belongs to which. +// /proc/resolv.conf -- the nameservers, in the format a resolver expects. +// +// SchOS has no DNS client of its own and does not need one: musl contains a +// complete resolver, and it works over ordinary UDP sockets, which this system +// has. The only thing it was missing was the one fact a process cannot find +// out for itself -- *which* nameserver -- because that arrives in a DHCP reply +// the kernel handled. +// +// So this is the mechanism and nothing more. It renders resolv.conf's exact +// syntax so that putting it where the resolver looks is a copy rather than a +// translation, and deciding whether to do that is userspace's business; see +// cmd/resolvconf. +// +// Empty until DHCP answers, which is the honest report: a file saying nothing +// is right, and a file naming a server that does not exist is not. +int +net_resolv(char *buf, int cap) +{ + int len = 0, i; + + if (cap < 64 * DNS_MAX_SERVERS) + return 0; + if (!net_started) + return 0; + + lwip_sys_lock(); + for (i = 0; i < DNS_MAX_SERVERS; i++) { + const ip_addr_t *a = dns_getserver(i); + + if (a == 0 || ip4_addr_isany_val(*ip_2_ip4(a))) + continue; + // put_field pads to a column, which is right for /proc/netif and + // wrong here: this file is not for looking at, it is for copying to + // where a resolver reads it. + len += put_str(buf + len, "nameserver "); + len += put_ip4(buf + len, ip_2_ip4(a)); + buf[len++] = '\n'; + } + lwip_sys_unlock(); + return len; +} + int net_info(char *buf, int cap) { diff --git a/ports/Makefile b/ports/Makefile index 42128f1..575331f 100644 --- a/ports/Makefile +++ b/ports/Makefile @@ -5,6 +5,7 @@ # ports/musl/ libc — installs headers/libs to build/ports/musl/prefix # ports/mksh/ MirBSD Korn Shell — installed as sysroot/bin/sh # ports/toybox/ core utilities — installed as sysroot/bin/ +# ports/kilo/ the text editor — installed as sysroot/bin/{kilo,edit} # ports/ncurses/ terminfo + screen library — link-time only, ships nothing # ports/zsh/ the Z shell — installed as sysroot/bin/zsh, beside /bin/sh # @@ -28,6 +29,7 @@ all: $(PORTS) mksh: musl toybox: musl +kilo: musl fastfetch: musl ncurses: musl zsh: musl ncurses diff --git a/ports/kilo/Makefile b/ports/kilo/Makefile new file mode 100644 index 0000000..50cadba --- /dev/null +++ b/ports/kilo/Makefile @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 +# kilo — BSD-2-Clause: https://github.com/antirez/kilo +# +# SchOS's text editor, installed as sysroot/bin/kilo with `edit` beside it. +# +# It is here because the alternative every small system reaches for is vi, and +# vi is a program you have to be taught before you can use it to fix the file +# that is stopping the machine booting. kilo is the other kind: the arrow keys +# move the cursor, typing inserts, Ctrl-S saves, Ctrl-Q quits, Ctrl-F finds, +# and the bottom line says so while you work. Nobody has to be told any of it. +# +# The whole editor is one 1,300-line C file with no dependencies beyond libc -- +# termios and TIOCGWINSZ, both of which SchOS already has because zsh needs +# them. That is the entire reason it was chosen over nano: nano is GPLv3 and +# wants ncurses at runtime, and GPLv3 cannot be combined with Apache-2.0 any +# more than GPLv2 can. See ../../docs/licensing.md. +# +# What it does not do: no undo, no multiple buffers, no configuration, no +# mouse, no UTF-8 beyond passing bytes through. It edits one file. + +include ../mk/port.mk + +# Pinned to a commit rather than a release, because kilo has never had one -- +# it was written as the worked example for a tutorial and has been finished +# ever since. A commit is at least an exact thing to name. +# +# The checksum is of GitHub's generated archive. Those are reproducible for a +# given commit, but they are generated rather than uploaded, so if this ever +# fails to verify check that upstream still has the commit before assuming the +# tarball was tampered with. +KILO_COMMIT := 323d93b29bd89a2cb446de90c4ed4fea1764176e +KILO_URL := https://github.com/antirez/kilo/archive/$(KILO_COMMIT).tar.gz +KILO_SHA256 := 79e62c23fdf369fa0cbbeaab9fd8ae2848dcc0156faf6efc89ea274528b39723 + +TARBALL := $(DISTDIR)/kilo-$(KILO_COMMIT).tar.gz + +CFLAGS := -static -Os -D__SCHOS__ -I$(MUSL)/include $(CFLAGS_BT) \ + -D_BSD_SOURCE -std=c99 -Wall +LDFLAGS := -static -L$(MUSL)/lib -lc + +$(TARBALL): | $(DISTDIR) + curl -fsSL -o $@ $(KILO_URL) + sh $(SHA256CHECK) "$(KILO_SHA256)" "$@" + +download: $(TARBALL) + +$(SRCDIR): $(TARBALL) + mkdir -p $(PORTDIR) + rm -rf $(PORTDIR)/kilo-$(KILO_COMMIT) + tar -xzf $< -C $(PORTDIR) + mv $(PORTDIR)/kilo-$(KILO_COMMIT) $@ + +# One file, so its own Makefile is not worth invoking -- it hardcodes flags +# that assume a hosted glibc and would link against the build host's libc. +$(BUILDDIR)/kilo: | $(SRCDIR) + mkdir -p $(BUILDDIR) + $(CC) $(CFLAGS) -o $@ $(SRCDIR)/kilo.c $(LDFLAGS) + +build: $(BUILDDIR)/kilo + +# `edit` as well as `kilo`. +# +# Two real copies rather than a link, for the reason ports/toybox/Makefile +# gives at length: mkschfs deduplicates by content, so the image holds one copy +# with two names either way, and this needs nothing from the host filesystem. +# +# The second name is not decoration. Somebody who has just arrived at a SchOS +# prompt with a file to fix will type `edit`; they will not guess `kilo`. +$(PORTDIR)/.installed: $(BUILDDIR)/kilo | $(SYSROOT)/bin + cp $(BUILDDIR)/kilo $(SYSROOT)/bin/kilo + cp $(BUILDDIR)/kilo $(SYSROOT)/bin/edit + chmod 755 $(SYSROOT)/bin/kilo $(SYSROOT)/bin/edit + @mkdir -p $(SYSROOT)/usr/share/licenses/kilo + cp $(SRCDIR)/LICENSE $(SYSROOT)/usr/share/licenses/kilo/LICENSE + @touch $@ + @echo "kilo: installed as /bin/kilo and /bin/edit" + +install: $(PORTDIR)/.installed + +clean: + rm -rf $(BUILDDIR) $(PORTDIR)/.installed + +distclean: clean + rm -rf $(PORTDIR) diff --git a/ports/toybox/Makefile b/ports/toybox/Makefile index 2d7d786..58e43a4 100644 --- a/ports/toybox/Makefile +++ b/ports/toybox/Makefile @@ -47,17 +47,63 @@ $(SRCDIR): $(TARBALL) tar -xzf $< -C $(PORTDIR) mv $(PORTDIR)/toybox-$(TOYBOX_VERSION) $@ +# Applets toybox has written but leaves out of defconfig. +# +# `defconfig` builds the 236 applets upstream considers finished. The other +# ~50 live in toys/pending/ -- complete enough to use, not yet held to toybox's +# standard for a release -- and among them are **awk**, **bc** and **diff**, +# which is a large part of why the shipped userland felt thin. They are the +# same 0BSD code from the same tarball; the only thing standing between them +# and the image was a config default. +# +# This list is curated rather than "enable everything", because everything does +# not build: `strace` wants xfork and ptrace, `syslogd` wants BSD's +# facilitynames[], and `git` wants OpenSSL. Those three break the link, so they +# are not here. If one of them ever matters, it goes in with the patch it needs +# rather than by widening a wildcard. +# +# `vi` is in pending too and is deliberately **not** on this list. SchOS ships +# an editor you do not have to be taught -- see ports/kilo. +TOYBOX_PENDING = \ + AWK BC DIFF MORE EXPR TR HEXDUMP STTY XZCAT CSPLIT \ + LSOF MAN LAST FDISK CHSH GETTY \ + TELNET TRACEROUTE ARP ARPING ROUTE \ + IPCS IPCRM USERADD USERDEL GROUPADD GROUPDEL + # toybox has no out-of-tree build mode, so its source is copied into $(BUILDDIR) # and built in place. $(SRCDIR) is an order-only prerequisite (|): the extracted # tarball never changes, and depending on it normally made a directory mtime # trigger a rebuild. The rebuild then failed anyway, because `cp -r` over a # populated $(BUILDDIR) hits "File exists" on kconfig's symlinks — so wipe it # first and always build from a clean copy. +# +# Two things happen to the copy before it is built. +# +# **The shebangs are rewritten.** toybox's build scripts start `#!/bin/bash`, +# and on a distribution that does not keep bash at that path -- NixOS, and any +# other that takes the FHS as a suggestion -- the build fails at the first +# script with ENOENT. bash is installed and on PATH; it is only the absolute +# path that is wrong, so `/usr/bin/env bash` fixes it everywhere without +# needing bash to move. This is not a nicety: without it toybox could not be +# rebuilt on this machine at all, and the binary in the tree was one nobody +# could reproduce. +# +# **Then the pending applets are switched on**, by editing .config directly. +# Not via `make oldconfig`, which is the obvious way and does not work: every +# pending symbol is `default n`, so oldconfig dutifully turns all of them back +# off again. generated/ is removed so the edit is actually noticed -- toybox +# caches the expanded config there and will otherwise link the previous set and +# report success. $(BUILDDIR)/toybox: | $(SRCDIR) rm -rf $(BUILDDIR) mkdir -p $(BUILDDIR) cp -r $(SRCDIR)/. $(BUILDDIR)/ + sed -i '1s|^\#!/bin/bash$$|\#!/usr/bin/env bash|' $(BUILDDIR)/scripts/*.sh $(MAKE) -C $(BUILDDIR) defconfig + for c in $(TOYBOX_PENDING); do \ + sed -i "s/^\# CONFIG_$$c is not set$$/CONFIG_$$c=y/" $(BUILDDIR)/.config; \ + done + rm -rf $(BUILDDIR)/generated $(MAKE) -C $(BUILDDIR) CC="$(CC)" CFLAGS="$(CFLAGS)" LDFLAGS="$(LDFLAGS)" # Install every applet the binary provides, one name per applet. diff --git a/srv/index.html b/srv/index.html new file mode 100644 index 0000000..633f194 --- /dev/null +++ b/srv/index.html @@ -0,0 +1,15 @@ + + +SchOS + +

SchOS

+

This page is being served by httpd, from /srv, + on a 64-bit operating system written from scratch.

+

The kernel is SchIFT and the filesystem is SchFS. Neither of them knew + anything about HTTP until a few hundred lines in cmd/httpd + asked for a socket.

+

Replace this file to serve your own.