Just HTTP. A small, synchronous HTTP/1.1 server library in Rust — TCP and unix sockets, streaming responses, keep-alive, pipelining — and nothing else. No TLS, no websockets, no HTTP/2, no plugins, no feature flags. The edge proxy owns encryption; this crate owns fast, correct HTTP over a socket.
justhttp is the HTTP layer under harbor, created and
maintained here as a first-party workspace crate. Harbor depends directly on
this crate by path; justhttp is not vendored and has no upstream HTTP crate to
track. Its ancestry is recorded only for attribution in Lineage and license
below.
Server::http("127.0.0.1:9495") / Server::http_unix(path) — one
listener, TCP or UDS; Server::serve(listeners) — several, one queuerecv_timeout() hands out requests: each Request carries method, url,
headers, and a lazy body reader; for shutdown, close_doors() stops
accepting and unblock() wakes a waiting workerrequest.respond(response) — responses stream from any Read impl;
unknown-length responses are framed chunked, so a client reads row one
while the server produces row Nrequest.peer() — a cheap handle whose closed() turns true once the
client has closed or reset the connection, or shut down its sending side,
so a handler can stop work nobody will read; a quiet or pipelining client
is not a departureTLS (https is the edge proxy’s job), Connection: upgrade /
websockets, HTTP/2 and /3, routing, cookies, multipart, compression
negotiation, test scaffolding, and every API harbor does not call. Every one
of these is either another program’s job or a layer above, and surface is
added only when harbor needs it.
Eight behaviors here are deliberate local hardening, born in production.
Every one of them has a regression test; a failure in tests/drain.rs or
the head, first_request and stall modules of tests/suite.rs is a
security regression, not a flake.
Content-Length. A drain that gives up, and a
chunked body left unread (it has no length to skip by), end the
connection after the response, so no leftover byte is ever parsed as
the next request. A buffer sized by the declaration is a
memory-exhaustion DoS: a request declaring Content-Length: 1000000000
while sending three bytes costs the process a gigabyte at drop time.
The drain is bounded in time too, 2 s, so a client dribbling its body
cannot hold the thread that answered it.write forever.431 and a close.
The line buffer grows a byte at a time, so without the cap one socket
sending X-Junk: and never stopping takes RSS from 30 MB to 1.5 GB in
under five seconds, before routing or application handling.Content-Length headers that disagree, a
Content-Length beside a Transfer-Encoding, a Content-Length that
is not plain digits (+5, 2 3), or a Transfer-Encoding that is not
exactly one chunked are all 400 and a close, and so is a header
line that begins with whitespace (obs-fold) or holds a CR, LF or NUL.
A chunked body is read as strictly: a chunk size is hex digits and
nothing else, every line ends in CRLF, and anything else fails the
read and ends the connection. Each is a request-smuggling primitive:
whenever this server and a proxy in front of it can resolve a request
differently, they eventually will.TE header is not consulted. An unknown-length response is always
chunked on HTTP/1.1: identity framing would have to read the whole
body to learn its length, and a header that could ask for it would add
a result’s size to this process’s memory (+316 MB of RSS on one
streamed query). HTTP/1.0, which has no chunked encoding, streams an
unknown length to the connection close instead of buffering it, and
conn.rs closes after every 1.0 request so that delimiter always
arrives./ready buys that
right permanently (measured: 120 connections holding 120 threads and
240 descriptors, still answering after 100 s idle).ECONNABORTED (the peer
reset before accept took it) and descriptor or buffer exhaustion
(EMFILE/ENFILE/ENOBUFS/ENOMEM) leave the listener perfectly
usable, so the accept loop backs off and tries again. Leaving the loop
closes the listening socket for the life of the process; the server
then sits there alive, accepting nothing. Only a failure meaning the
listener is gone ends the loop, and that one is surfaced through
recv_timeout() so the host can say so — as does a transient failure
that has persisted for a full minute, which is not a storm.Seven one-word files, edition 2024, forbid(unsafe_code), three tiny
dependencies (ascii, chunked_transfer, httpdate):
| File | Owns |
|---|---|
lib.rs |
Server, the accept loop (socket timeouts and accept retry live here), recv_timeout/unblock/close_doors |
http.rs |
Method, StatusCode, Header, HttpVersion (strict, smuggling-hardened parsing) |
stream.rs |
TCP/UDS listeners and the half-close connection stream |
conn.rs |
per-connection request sequencing: keep-alive, pipelining, response ordering, request-head bounds and framing checks |
request.rs |
Request, lazy body readers (the bounded drain and the strict chunked decoder live here) |
response.rs |
Response, transfer-encoding choice, chunked/identity framing |
pool.rs |
the accept-side task pool and the message queue behind recv_timeout() |
cargo test -p justhttp (from harbor/) runs every test, the hardening
regressions included, on macOS and Linux, in about 75 s: the slowest has to
outwait the real 60 s first-request clock, and the rest run beside it. Two
files: tests/suite.rs, one module per property (basic, input, head,
network, keepalive — connection reuse + chunked streaming, buffering
— backpressure, prompt — latency properties, unblock, unix, peer,
first_request and stall — the write-timeout test); and tests/drain.rs,
the drain regressions, alone in their own binary because its measuring
global allocator must not see other tests’ allocations.
justhttp began from the synchronous HTTP/1.1 core of tiny_http 0.12.0 (MIT OR Apache-2.0), relicensed here under its MIT option. That is lineage, not a dependency, a vendoring relationship or a synchronization policy: justhttp carries none of that crate’s other surface, is maintained here, and keeps the HTTP semantics its tests pin. See LICENSE for combined attribution.