SchOS/cmd/httpd/main.c
Nexoniarz 26bca020b6 licence: SchOS is GPL-2.0-or-later, no longer Apache-2.0
Nexoniarz is the sole author and SchOS was never published under
Apache-2.0, so this is a straight relicence. LICENSE is the FSF GPLv2 text,
every first-party SPDX header moves to GPL-2.0-or-later, and NOTICE,
README.md, docs/licensing.md and the site say so. projects/schfs-linux
follows the rest of the tree.

The GPL gives copyleft: a fork has to ship its source. It also lifts the
old "Linux drivers, never" rule, since GPL-2.0-only code combines with
GPL-2.0-or-later. "Or later" leaves room for GPLv3, but one binary still
has to pick one version, so docs/licensing.md now keeps Apache-2.0 and
GPLv3 code out of the kernel to keep Linux code possible there. BSD
4-clause code goes from "avoid" to "no", because the advertising clause
is GPL-incompatible.

LDLS still ships no .ko. That boundary is now project policy rather than
a legal requirement, and ldls.md, devices.md and licensing.md section 6
say so. refmod declares license=GPL, and ldls-check passes (27 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:48:45 +02:00

460 lines
14 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 "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)
;
}
}