SchOS/cmd/httpd/main.c
Nexoniarz a1c965da1e include: one folder per subsystem, and includes that name it
Sixty-five headers sat side by side in include/, so `#include "irq.h"`
said nothing about where irq.h was or what it belonged to, and errno.h,
stat.h and fcntl.h at the top shadowed the system's for any host tool
built with -I./include. They are in folders now -- arch/ core/ fs/
dev/ gfx/ net/ uapi/ (and ldls/ for the Linux driver layer) -- and
every include names the folder, as Linux's do: "core/proc.h".

Done mechanically and checked the hard way: the kernel built from this
tree disassembles identically to the one built from the tree before
it. The full image boots, and userland is the same set of programs.
Paths in docs and comments follow; docs/guides/layout.md has a table
of what is where, and tools/fix-includes.py rewrites old-style
includes from the folders in the tree, for code written before this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
2026-10-23 15:05:12 +02:00

460 lines
15 KiB
C

// SPDX-License-Identifier: GPL-2.0-or-later
//
// 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 "core/types.h"
#include "uapi/user.h"
#include "uapi/fcntl.h"
#include "uapi/stat.h"
#include "net/socket.h"
#include "uapi/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)
;
}
}