Documentation

A lightweight single-binary HTTP server written in Rust on tokio and hyper. This page covers what it does, how it's secured, and how to run it.

Contents

Overview

What ferrox is

ferrox serves static files and proxies /api to a configurable backend (including WebSocket upgrades). It includes the usual safety checks: path traversal protection, request size limits, method gating, and a basic Slowloris mitigation. Built directly on tokio and hyper 1.x with no web framework in between.

Routes

What's exposed

GET/Status page
GET, HEAD/healthReturns 200 if the server is up
GET, HEAD/helloPlain-text response, useful for smoke tests
GET, HEAD/metricsPrometheus-format metrics: requests, cache hit rate, uptime
any/api/*Reverse-proxied to the configured upstream, including WebSocket upgrades
GET, HEAD/*Static files served from ./public

TRACE is rejected everywhere with 405. It has no real use here and has been used for cross-site tracing attacks in browsers. Any other method not listed for a route also returns 405, except on /api/*, which stays open because it is a transparent proxy to a backend that may support its own methods.

Security

What's enforced

Path traversal

Any request path that contains a literal .. segment is rejected. Resolved file paths are also canonicalized and checked against the public root before being served. This catches symlink escapes as well as simple ../ sequences.

Request body limits

Requests through /api are limited to 10 MB. A Content-Length over the limit is rejected with 413 before any body is read. Bodies without a declared length are still counted as they stream in, and the connection is closed if the real size goes over the limit.

Slowloris mitigation

Every connection has a read-stall timeout. If no bytes arrive for 30 seconds the connection is closed. The timer resets whenever data arrives, so a slow but active client (an upload on a bad connection, a WebSocket with periodic frames) is left alone. Only a connection that makes no progress at all is cut. This is a simple first layer; it does not enforce minimum throughput.

Startup checks

ferrox checks that the public directory exists before binding a socket. If the working directory is wrong it exits with a clear error instead of failing on the first request.

Response headers

Every response, including errors, includes X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection, and Referrer-Policy: strict-origin-when-cross-origin.

Performance & reliability

How it stays fast

In-memory caching

Files under 5 MB are cached in memory after the first request, keyed by resolved path. Cache entries are checked against the file's modification time on every request, so edits on disk show up immediately with no restart or manual flush.

Streaming for large files

Files over the cache threshold are streamed from disk in fixed-size chunks and are never cached. Memory use per request stays constant regardless of file size.

HTTP Range requests

Single-range Range requests (bytes=0-99, bytes=500-, bytes=-100) are supported and return 206 Partial Content with a correct Content-Range header for both cached and streamed files. Multi-range requests fall back to a full 200. An out-of-bounds range returns 416.

Conditional requests

Static responses include a Last-Modified header. A client that sends If-Modified-Since gets a bare 304 Not Modified if the file has not changed.

Response compression

Cached text responses (HTML, CSS, JS, JSON, SVG) are gzip-compressed on the fly when the client sends Accept-Encoding: gzip. Range responses and large streamed files are left alone. Byte ranges do not compress cleanly, and large files are usually already compressed binaries.

Connection pooling

/api requests reuse keep-alive connections to the upstream through a pooled HTTP client instead of opening a new TCP connection for every request.

Graceful shutdown

On SIGINT or SIGTERM, ferrox stops accepting new connections right away and waits up to 10 seconds for in-flight requests to finish before exiting.

WebSocket proxying

Transparent upgrade relay

A request to /api/* with Connection: Upgrade and Upgrade: websocket is forwarded to the configured upstream. ferrox completes the upgrade on both sides and then pipes bytes both ways for the life of the connection.

Observability

Metrics and structured logs

Metrics

GET /metrics returns request counts, response byte totals, cache hit/miss counts, and uptime in standard Prometheus text format. Point a scraper or plain curl at it.

Structured logging

Every request is logged with a monotonic request_id, method, and path through tracing spans. Any log line emitted while handling that request carries the same ID, so one request stays easy to follow across multiple lines.

RUST_LOG=info cargo run

Set FERROX_LOG_JSON=1 to emit structured JSON instead of human-readable text, which most log aggregators prefer.

FERROX_LOG_JSON=1 RUST_LOG=info cargo run

Configuration

ferrox.conf and overrides

Configuration is a small TOML file:

listen = "0.0.0.0:3000"
proxy_upstream = "127.0.0.1:8000"

If no config file exists at the resolved path, ferrox creates one with these defaults on first run.

Resolution order

An explicit path (flag or env var) that does not exist is treated as an error. Only the implicit default is auto-created.

Static root

Set FERROX_PUBLIC_DIR to serve files from somewhere other than ./public.

TLS (optional)

ferrox can terminate TLS itself with rustls. The feature is gated so the default build stays light:

cargo build --release --features tls

Enable it by setting both fields in ferrox.conf:

tls_cert = "/etc/ferrox/cert.pem"
tls_key = "/etc/ferrox/key.pem"
Useful for a standalone deployment with nothing in front. Behind Caddy (see Deployment), edge TLS with automatic Let's Encrypt renewal is usually simpler. Native TLS means you handle certificate renewal yourself.

Testing

cargo test

ferrox has both unit and integration tests:

cargo test

Deployment

Running it for real

As a plain binary

ferrox has no runtime dependency on Docker or anything else. A built binary, a public/ directory, and an optional config file are enough.

cargo build --release
./target/release/ferrox

As a systemd service

[Unit]
Description=ferrox web server
After=network.target

[Service]
Type=simple
User=ferrox
WorkingDirectory=/opt/ferrox
ExecStart=/opt/ferrox/ferrox
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target

Behind Caddy (TLS + rate limiting)

A Caddy reverse proxy in front of ferrox can handle automatic TLS via Let's Encrypt and, with the mholt/caddy-ratelimit plugin, per-IP rate limiting. Both sit at the edge outside of ferrox itself.

A Docker Compose stack (ferrox + a custom Caddy build with the rate-limit plugin) is included in the repository, but Docker is optional. Caddy and its plugins run fine natively.

Maintenance

Keeping dependencies honest

Dependency vulnerabilities are checked with cargo audit against the RustSec advisory database. Worth running after any dependency bump.

cargo audit