Maintenance
Keeping dependencies honest
Dependency vulnerabilities are checked with cargo audit against the RustSec advisory database. Worth running after any dependency bump.
cargo audit
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
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
| GET | / | Status page |
| GET, HEAD | /health | Returns 200 if the server is up |
| GET, HEAD | /hello | Plain-text response, useful for smoke tests |
| GET, HEAD | /metrics | Prometheus-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
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
/api requests reuse keep-alive connections to the upstream through a pooled HTTP client instead of opening a new TCP connection for every request.
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
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
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.
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
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.
--config <path> or --config=<path> command-line flagFERROX_CONFIG environment variable./ferrox.conf (default)An explicit path (flag or env var) that does not exist is treated as an error. Only the implicit default is auto-created.
Set FERROX_PUBLIC_DIR to serve files from somewhere other than ./public.
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"
Testing
ferrox has both unit and integration tests:
cargo test
Deployment
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
[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
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.
Maintenance
Dependency vulnerabilities are checked with cargo audit against the RustSec advisory database. Worth running after any dependency bump.
cargo audit