duckdb-harbor

justhttp

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.

What it does

What it refuses to do

TLS (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.

Hardening carried in the source

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.

  1. Bounded body drain. Discarding an unread request body streams through a fixed 64 KiB buffer — never an allocation sized by the client’s declared 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.
  2. Response write timeout. Every accepted socket gets a 10 s write timeout, so a client that stops reading its response cannot pin a worker thread inside write forever.
  3. Bounded request head. A request line or header line is capped at 8 KiB and a request at 128 headers; over either is 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.
  4. Read timeout. Every accepted socket gets a 5 s read timeout, the whole request head gets 10 s from its first byte, and a body the handler reads gets 30 s in all. Before the first byte the connection is only idle, so it waits on the connection clocks below; after it, a client cannot hold a serving thread at one byte per minute.
  5. Unambiguous framing. Two 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.
  6. The client does not choose the server’s buffering. A response’s framing follows from its version, status and length alone; a request’s 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.
  7. Connection idle clocks. A connection that has never sent a byte is closed after 60 s. Once it has served one request it is a keep-alive client and gets 5 minutes between requests — far longer than any pooled client’s own idle timeout, so a REPL at its prompt or a pool between queries is untouched. Without those clocks a caller can hold sockets, and a thread apiece, for as long as it likes, and one /ready buys that right permanently (measured: 120 connections holding 120 threads and 240 descriptors, still answering after 100 s idle).
  8. Transient accept failures are retried. 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.

Layout

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()

Tests

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.

Lineage and license

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.