Rookin development ยท AGPLv3

// docs

Documentation

Introduction

Rook is an HTTP server and reverse proxy written in Rust. It serves HTTPS with certificates you supply or ones it obtains itself, and it forwards traffic to one or more upstream servers.

The daemon is called rookd and its config file is rookd.toml. FreeBSD is the main release and security-test platform. Linux and macOS are also supported, on x86_64 and ARM64.

Install

Waiting on the first release. Install steps go here, including the FreeBSD service and how to verify a signed release.

Quick start

Create rookd.toml with a hostname, a certificate and at least one upstream server. The private key must be readable only by its owner (mode 0600 or 0400). Check the config, then start Rook.

rookd.toml
[server]
listen = "0.0.0.0:80"
https_listen = "0.0.0.0:443"

[[sites]]
hosts = ["example.com"]
tls_cert = "/usr/local/etc/rook/example.com-cert.pem"
tls_key = "/usr/local/etc/rook/example.com-key.pem"

[[sites.routes]]
prefix = "/"
upstreams = ["http://127.0.0.1:9000"]
websocket = true
rookd --config rookd.toml --check
rookd --config rookd.toml

To use Let's Encrypt instead, see TLS and certificates.

Configuration

rookd --config <path> validates the file and runs. Adding --check validates it and exits, including local certificate, key, blocklist and ACME trust files and state directory permissions, without opening listeners or contacting Let's Encrypt. Without --config, Rook reads ./rookd.toml. The FreeBSD service uses an explicit path under /usr/local/etc/rook/. Set RUST_LOG to change the log level, which defaults to info.

Rook re-reads certificates and the blocklist every two seconds, and keeps the last valid version if a new file is invalid. It rejects unknown options. Every duration is a whole number of seconds, and limits and timeouts can't be zero, except hsts_max_age.

[server]

Every field is optional.

FieldDefaultMeaning
listen"0.0.0.0:80"HTTP/1.1 listener address.
https_listendisabledHTTPS listener address, with HTTP/1.1 and HTTP/2.
allow_tls12falseEnable TLS 1.2 as the only fallback.
hsts_max_age31536000HSTS max-age in seconds. 0 turns HSTS off.
hsts_include_subdomainsfalseAdd includeSubDomains.
hsts_preloadfalseAdd preload. Needs subdomains and a one-year max-age.
blocklist_filedisabledFile of blocked certificate and public-key hashes.
max_connections4096Simultaneous client connections.
max_connections_per_ip256Connections per IPv4 address or IPv6 /64.
max_connections_per_upstream1024In-flight requests or WebSocket tunnels per upstream.
max_upstream_per_ip64Total in-flight upstream slots per verified client IP.
max_tracked_clients10000Size limit for the connection and rate-limit tables.
requests_per_second100Sustained request rate per client and route.
request_burst200Request burst. Must be at least the sustained rate.
max_header_bytes32768HTTP header buffer, from 8192 to 16777216 bytes.
max_request_body_bytes1048576Largest request body (1 MiB). Raise this for uploads.
header_read_timeout10Seconds allowed for first bytes, headers and TLS handshakes.
request_timeout30Seconds allowed for upstream response headers, once the request body has been sent.
upstream_connect_timeout5Seconds allowed to connect to an upstream.
idle_timeout60Seconds without body progress or connection writes before closing.
websocket_idle_timeout300Seconds without WebSocket traffic before closing.
websocket_max_lifetime3600Longest a WebSocket tunnel can stay open, in seconds.
shutdown_drain15Seconds allowed for connections and WebSocket tunnels to finish at shutdown.
trusted_proxies[]Proxies you run whose X-Forwarded-For Rook believes.
cloudflare_ranges[]Cloudflare's address ranges. Only these may set the visitor's address with CF-Connecting-IP, and they're also trusted for X-Forwarded-For.

[[sites]]

FieldDefaultMeaning
hostsrequiredDNS names or IPv4 addresses, unique across the config. No ports or wildcards.
tls_certnoneCertificate chain path. Must be set with tls_key.
tls_keynonePrivate key path. Must be set with tls_cert.

With HTTPS on, each site needs supplied files, [sites.acme] or both. With HTTPS off, it can't have either.

[sites.acme]

FieldDefaultMeaning
contactrequiredContact address beginning with mailto:.
directory_urlLet's Encrypt productionHTTPS ACME directory URL.
state_dir"./rookd-acme"Where the account, certificates and keys are stored.
trusted_rootrequiredPEM root used only for this ACME connection.
renew_before_days30Renew this many days before expiry, from 1 to 60.

[[sites.routes]]

Each site needs at least one route.

FieldDefaultMeaning
prefixrequiredPath prefix starting with /. /api matches /api and /api/x, not /apix. Encoded ordinary characters are normalised, so /%61dmin becomes /admin. The prefix is kept when forwarding.
upstreamsrequiredHTTP base URLs of the upstream servers. A base path is added before the request path. --check rejects https:// until it's built.
websocketfalseAllow HTTP/1.1 WebSocket upgrades.
origins[]If set, WebSocket requests must have one of these exact Origin values.
allow_remote_httpfalseAllow plain HTTP to upstreams that aren't on loopback.
requests_per_secondfrom [server]Rate limit for this route.
request_burstfrom [server]Burst limit for this route.

TLS and certificates

Supplied certificates: Rook checks that the certificate and key match and that only the owner can read the key, and it rejects RSA keys under 2048 bits. A replaced certificate is picked up within about two seconds, without a restart. There's no default certificate, so a TLS connection for an unknown hostname fails. If a supplied certificate is invalid or expired, only that site's connections fail, and other sites keep running. Every HTTPS site redirects plain HTTP to HTTPS, after serving any ACME challenges.

Let's Encrypt: add a [sites.acme] block. Rook uses the HTTP-01 challenge, so port 80 must be reachable, and it only requests certificates for hostnames in your config.

rookd.toml
[server]
listen = "0.0.0.0:80"
https_listen = "0.0.0.0:443"

[[sites]]
hosts = ["example.com"]

[sites.acme]
contact = "mailto:admin@example.com"
directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
state_dir = "./rookd-acme"
trusted_root = "/usr/local/etc/rook/isrg-root-x1.pem"
renew_before_days = 30

[[sites.routes]]
prefix = "/"
upstreams = ["http://127.0.0.1:9000"]

ACME has only been tested locally, not yet against Let's Encrypt, so treat it as unverified. The example uses the Let's Encrypt staging service. Leaving out directory_url uses production.

The account is stored in state_dir/account.json. Each new certificate and key pair is checked, then published as a unit through the state_dir/<first-host>/current symlink, keeping only the current version and one previous. Directories use mode 0700 and files 0600, and Rook rejects an existing directory with looser permissions. Rook checks daily that the certificate isn't near expiry and exactly matches your hostnames. If a renewal fails or passes its five-minute deadline, Rook keeps serving the last valid certificate and tries again with backoff. If you also supply a certificate, Rook uses it only until it has a valid ACME one.

  • Single server: ACME is a good fit.
  • Several load-balanced servers: supply a certificate per server, because the HTTP-01 challenge may reach a different server from the one that asked.
  • Wildcard certificates: not supported in the first release.

Blocklist: blocklist_file lists hashes that Rook refuses on its ACME connection, on top of normal chain and hostname checks. It's re-read every two seconds, and blank lines and # comments are ignored. It can only match certificates the server sends, so to distrust a root, also remove it from trusted_root.

blocklist
cert-sha256:<64 hex characters>
key-sha256:<64 hex characters>

Still to cover: updating the trusted_root file when Let's Encrypt changes roots.

Upstream proxy

Rook routes by hostname, then by the longest matching path prefix. A request for an unknown hostname is never sent to an upstream and gets a 404, and Rook returns 421 when the TLS hostname and the Host header don't match.

Rook keeps the original Host header. It sets X-Real-IP, X-Forwarded-For and X-Forwarded-Proto itself, trusting addresses from a proxy only if it's in trusted_proxies or cloudflare_ranges. It strips other headers clients could use to spoof their address or host, such as X-Forwarded-Host, Forwarded and True-Client-IP. A request with an underscore in a header name gets a 400.

Plain HTTP to a loopback address just works. Plain HTTP to any other address needs allow_remote_http = true on the route. Once HTTPS upstreams are built, their certificates will be verified by default.

WebSockets work over HTTP/1.1 Upgrade. Rook starts tunnelling only after a valid 101 response from the upstream. Turn them on per route with websocket = true. You can limit them with origins; otherwise, checking Origin is up to your application.

Load balancing

When a route has more than one upstream server, Rook sends requests to them in turn (round robin). It stops using upstreams that are failing, and its health checks reflect upstream health, so a load balancer in front won't treat a server as healthy just because Rook is listening.

Rook retries only requests known to be idempotent. It never automatically replays a POST.

Still to cover: health check options.

Security

Rook uses TLS 1.3 by default. TLS 1.2 is the only fallback and stays off unless you enable it. Older versions and 0-RTT are never used.

HTTPS responses include HSTS with a one-year max-age by default. Plain HTTP responses never do, and Rook won't add a second header if the upstream already sent one. includeSubDomains and preload are opt-in. When you migrate a live site, start with a short max-age such as one day (hsts_max_age = 86400).

Requests are parsed strictly. Rook rejects malformed or ambiguous requests, encoded slashes and path traversal, including /..;/ forms, then forwards only a path the upstream will read the same way. Logs leave out request bodies, credentials and query strings by default, and error pages don't reveal internal details. Metrics listen on loopback by default.

Abuse limits: Rook limits connections, TLS handshakes and request rates per client, using fixed-size tables. Strike and ban rules start in log-only mode and expire. Rook limits behaviour it can observe. It isn't a web application firewall, so patching and application security are still your job.

Still to cover: running without root, jails, and how to report a vulnerability (linked from SECURITY.md before the first public release).

Behind Cloudflare

List Cloudflare's address ranges in cloudflare_ranges. Rook reads the visitor's address from CF-Connecting-IP only when the connection comes from one of those ranges. From any other address the header is ignored, never rejected. Rook doesn't download the ranges itself, so updating them is part of your maintenance. Cloudflare's ranges are never banned.

trusted_proxies is for other proxies you run that pass on the visitor's address faithfully in X-Forwarded-For. Being on it doesn't let a proxy use CF-Connecting-IP. pf is a separate decision: it decides who can reach the port. A VPN or deploy address can be allowed through pf without being on either list, and then it reaches Rook as itself.

Rook doesn't forward CF-Connecting-IP to your upstream. Read the visitor's address from X-Real-IP or X-Forwarded-For instead, and update any application that reads the Cloudflare header.

We recommend a pf rule that allows only Cloudflare and your VPN or a fixed deploy address, so nobody can bypass Cloudflare by connecting to the origin directly.

Set Cloudflare's SSL mode to Full (strict). In Flexible mode, Cloudflare connects over plain HTTP, Rook redirects it to HTTPS, and visitors get a redirect loop.

Still to cover: the example pf rule, range updates and load balancer health checks.

Migrating from Caddy

Keep Caddy available for rollback and move one server at a time, starting with a low-risk site. Start with a short HSTS max-age and rate limits in log-only mode.

A Caddy reverse_proxy block becomes a route. A tls block becomes tls_cert and tls_key or [sites.acme], and protocols tls1.3 is already the default. You can drop the header_up lines for Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto, because Rook does this itself.

rookd.toml
[server]
listen = "0.0.0.0:80"
https_listen = "0.0.0.0:443"
hsts_max_age = 86400

[[sites]]
hosts = ["example.com"]
tls_cert = "/usr/local/etc/rook/origin-cert.pem"
tls_key = "/usr/local/etc/rook/origin-key.pem"

[[sites.routes]]
prefix = "/"
upstreams = ["http://127.0.0.1:8080"]

Three things may catch you out. Request bodies are limited to 1 MiB, so raise max_request_body_bytes if a site takes uploads. WebSockets are off until you set websocket = true on the route. Rook returns 400 for header names containing underscores, so check that your clients don't send any.

Not in the first release

  • HTTP/3, h2c and WebSockets over HTTP/2
  • Wildcard certificates, DNS-01 and on-demand certificates
  • A signature-based web application firewall