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.