userland: 268 applets, an editor, names that resolve, and a web server

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
  <!doctype html><html lang="en"><head><title>Example Domain</title>

**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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-14 19:24:55 +02:00
commit b08432b2c3
17 changed files with 994 additions and 21 deletions

View file

@ -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.
#

17
NOTICE
View file

@ -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 <antirez@gmail.com>
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

View file

@ -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

460
cmd/httpd/main.c Normal file
View file

@ -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), "<!doctype html><title>");
at = scat(b, at, sizeof(b), status);
at = scat(b, at, sizeof(b), "</title><h1>");
at = scat(b, at, sizeof(b), status);
at = scat(b, at, sizeof(b), "</h1><p>");
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), "<!doctype html><meta charset=utf-8><title>");
at = scat(line, at, sizeof(line), target);
at = scat(line, at, sizeof(line), "</title><h1>");
at = scat(line, at, sizeof(line), target);
at = scat(line, at, sizeof(line), "</h1><ul>\n");
write(fd, line, at);
while ((n = getdents64(d, dbuf, sizeof(dbuf))) > 0) {
int off = 0;
while (off < n) {
struct dirent64 *e = (struct dirent64 *)(dbuf + off);
const char *nm = e->d_name;
off += e->d_reclen;
if (nm[0] == '.' && (nm[1] == 0 || (nm[1] == '.' && nm[2] == 0)))
continue;
at = scat(line, 0, sizeof(line), "<li><a href=\"");
at = scat(line, at, sizeof(line), nm);
if (e->d_type == DT_DIR)
at = scat(line, at, sizeof(line), "/");
at = scat(line, at, sizeof(line), "\">");
at = scat(line, at, sizeof(line), nm);
if (e->d_type == DT_DIR)
at = scat(line, at, sizeof(line), "/");
at = scat(line, at, sizeof(line), "</a>\n");
write(fd, line, at);
}
}
write(fd, "</ul>\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)
;
}
}

136
cmd/resolvconf/main.c Normal file
View file

@ -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;
}

View file

@ -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 │
├─────────────────────────────────────────────────┤

View file

@ -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
<!doctype html><html lang="en"><head><title>Example Domain</title>
```
## 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

View file

@ -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:

View file

@ -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

View file

@ -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 *);

View file

@ -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;
}

View file

@ -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])))

View file

@ -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)
{

View file

@ -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/<applet>
# 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

84
ports/kilo/Makefile Normal file
View file

@ -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)

View file

@ -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.

15
srv/index.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<meta charset="utf-8">
<title>SchOS</title>
<style>
body { font: 16px/1.6 system-ui, sans-serif; max-width: 34rem;
margin: 4rem auto; padding: 0 1.5rem; }
code { background: #0001; padding: .15em .4em; border-radius: .25em; }
</style>
<h1>SchOS</h1>
<p>This page is being served by <code>httpd</code>, from <code>/srv</code>,
on a 64-bit operating system written from scratch.</p>
<p>The kernel is SchIFT and the filesystem is SchFS. Neither of them knew
anything about HTTP until a few hundred lines in <code>cmd/httpd</code>
asked for a socket.</p>
<p>Replace this file to serve your own.</p>