A single-file static HTTP server in C99.
- Serves static files from a document root over HTTP/1.0 / 1.1 with keep-alive.
- Answers
GETandHEADonly - anything else is405. IPv4. - Multithreaded - one detached POSIX thread per connection, capped to the file-descriptor limit.
- Drops privileges - binds as root, then sheds supplementary groups, gid, and uid to an unprivileged user.
- Path-traversal hardened - paths are URL-decoded, canonicalized with
realpath(), and confined to the docroot; only world-readable files are served. - Overload-resistant - per-request receive deadline (slowloris bound), idle and stalled-send timeouts, and a connection cap.
- Logs access lines to stdout, lifecycle and errors to syslog.
- Tiny and portable - one file, depends only on libpthread; builds on Linux (glibc/musl), macOS, and the BSDs.
For TLS, run it behind a terminator such as stunnel.
make # release: -O3 -DNDEBUG, produces ./srv
make debug # -g -DDEBUG, perror() diagnostics on the error path
make prof # -pg, for gprof
make tcc # build with tcc instead of cc
make clean # remove ./srvCompiler flags are strict: -std=c99 -pedantic -Wall -Wextra -Wshadow -Wpointer-arith -Wcast-qual -Wstrict-prototypes -Wmissing-prototypes -Wdeclaration-after-statement.
The release build also adds glibc/ELF hardening (_FORTIFY_SOURCE, stack
protector, PIE, full RELRO). It uses the system cc (override with
make CC=gcc). On macOS or the BSDs those Linux-specific flags don't apply;
build with make HARDEN= to drop them.
srv username port docroot [ip address]
| Argument | Meaning |
|---|---|
username |
user to setuid() to after the socket is bound |
port |
TCP port to listen on |
docroot |
directory to serve; resolved with realpath() at startup |
ip |
optional IPv4 bind address; default is all interfaces |
Example:
sudo ./srv nobody 8080 ~/docrootStartup order: open syslog, install signal handlers, canonicalize docroot,
chdir() into it, look up the user, bind the listening socket, then setuid()
to drop privileges. Binding before the drop is why it is normally started as
root. SIGINT and SIGTERM trigger a fast shutdown - the accept loop stops and
the process exits, letting the OS reclaim threads, sockets, and memory;
in-flight connections are not drained. The peak concurrent-thread count is
printed on exit.
A request for a directory is served index.html from that directory - there is
no directory listing, so a directory without index.html returns 404. A path
with no matching MIME extension is sent as application/octet-stream.
- Methods:
GETandHEAD.HEADsends headers only. Any other method on a well-formed request line gets405. - Versions: HTTP/1.0 and HTTP/1.1. The status line echoes the request minor version.
- Status codes emitted:
200,400(malformed request),403(resolves outside docroot, or not world-readable),404(not found),405(unsupported method),500(allocation failure). - Response headers:
Content-Type,Content-Length,Allow: GET, HEAD, andConnection. - Keep-alive: HTTP/1.1 keeps the connection open by default and honors
Connection: close; HTTP/1.0 closes after each response. Bytes received past the end of one request are retained for the next. - Logging: the request line (sanitized of control characters) is written to
stdout on every request - an access log for the supervisor to capture and
rotate. Lifecycle markers (
started,normal exit,error exit) go to syslog (LOG_DAEMON,LOG_PID).
- Privilege drop: when started as root, the socket is bound first, then
supplementary groups, gid, and uid are dropped to
username(refused if it resolves to uid 0). Started unprivileged, there is nothing to drop and it runs as the invoking user. - Path traversal: each request path is URL-decoded, rejected if it contains
control characters, joined to the docroot, and canonicalized with
realpath()(which resolves.., symlinks, and duplicate slashes). The result must still begin with the docroot followed by/, or the request is refused with403. - File constraints: only regular files are served, and only if
world-readable (the
otherread bit) - publication is opt-in; directories other than viaindex.htmlare refused. - Input limits: request size and header count are capped; oversized requests are refused (see Tunables).
- Timeouts: idle receives and stalled sends both time out, and a full
request must arrive within
HEADER_TIMEO_SEC- bounding slow-client (slowloris) and stalled-reader attacks that would otherwise pin a worker thread indefinitely. - Overload:
thread_count_maxcaps concurrent connections and is reduced at startup to fit the process descriptor limit ((RLIMIT_NOFILE - FD_RESERVE) / 2); under descriptor exhaustion the accept loop backs off instead of spinning. - Signals: workers block
SIGINT/SIGTERM; onlymain()handles them, andSIGPIPEis ignored.
Compile-time constants at the top of srv.c:
| Constant | Default | Meaning |
|---|---|---|
DEFAULT_FILE |
index.html |
served for directory requests |
DEFAULT_MIME_TYPE |
application/octet-stream |
fallback content type |
MIN_REQUEST_LEN |
12 |
shortest acceptable request |
MAX_REQUEST_LEN |
4096 |
request buffer size |
MAX_HEADERS |
32 |
header slots per request |
RCVTIMEO_SEC |
5 |
per-socket recv/send timeout |
HEADER_TIMEO_SEC |
10 |
deadline to receive a full request |
SEND_FILE_NBYTES |
65536 |
file send chunk size |
THREAD_STACK_SIZE |
16384 |
per-worker stack |
FD_RESERVE |
8 |
descriptors kept free when capping the connection limit |
thread_count_max |
4096 |
max connections; capped at startup to fit RLIMIT_NOFILE |
Extension lookup; anything unlisted falls back to application/octet-stream.
| Extension(s) | Type |
|---|---|
.html .htm |
text/html; charset=utf-8 |
.txt .c .h .asm .fs .vs |
text/plain; charset=utf-8 |
.js |
text/javascript |
.wasm |
application/wasm |
.css |
text/css; charset=utf-8 |
.xml |
application/xml; charset=utf-8 |
.json |
application/json |
.png |
image/png |
.jpg .jpeg |
image/jpeg |
.gif |
image/gif |
.svg |
image/svg+xml |
.ico |
image/x-icon |
srv speaks plain HTTP only. For HTTPS, terminate TLS in front of it.
tls.conf is an stunnel example that accepts on 443 and forwards
to a local srv on 80:
stunnel tls.confThe suite in test/ builds a fresh server and drives it with curl
(and raw /dev/tcp for malformed-request cases). 64 tests cover seven groups:
proto, routing, mime, headers, security, keepalive, concurrency.
make test # functional suite, plain mode
make test-valgrind # same tests under valgrind + a leak verdict
make test-asan # same tests built with AddressSanitizer
make menu # interactive dialog(1) front-endThe runner takes a mode and an optional group list:
test/run.sh [plain|valgrind|asan] [group ...]HOST, PORT, and SRV_USER override the defaults (127.0.0.1, 18080, the
current user).
See LICENSE.
Matt Vianueva - diatribes@gmail.com