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