HTTP reverse proxy and static site server
  • Rust 96.1%
  • HTML 3.1%
  • Shell 0.5%
  • Dockerfile 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
James Oates d0801d3a0c
All checks were successful
Build Image (main) / build-amd64 (push) Successful in 1m11s
Build Image (main) / build-arm64 (push) Successful in 3m53s
Build Image (main) / manifest (push) Successful in 1s
Converge the resolver on the desired set, not the DB rows (#53)
A reserved name (.localhost, .test) is tombstoned by reconcile_once's
skip branch, which continues before any DB write, so it never has a
certificates row. Cleanup only iterated rows, and resolver.remove is
called in exactly that one place, so the NotCertifiable tombstone
outlived the route that created it for the life of the process: the same
name refused the handshake or got the fallback depending on process
history rather than current config, and only a restart fixed it.

Converges the resolver's own keys against desired as well. This is not
the destructive half -- it forgets an in-memory entry, making the name
unconfigured, which is what it now is -- so it is deliberately separate
from the row loop's irreversible private-key delete, and inherits the
same 'desired is real' assumption without widening it.

Takes the issue's second option rather than writing a row for skipped
names: the resolver is documented as the single source of truth for what
ER can serve, so converging it from the desired set removes the
dependency on a row existing instead of papering over one case.
2026-10-27 11:06:17 +00:00
.aider.tags.cache.v4/7e/36 Stream request bodies instead of buffering into RAM 2026-08-13 16:58:56 +01:00
.forgejo fix: never order a certificate for a reserved special-use name 2026-10-22 18:08:58 +01:00
assets feat: add EdgeRouter logo and favicon, wire into dashboard 2026-04-09 17:48:24 +01:00
migrations fix: make flipping staging actually change ACME environment 2026-10-23 09:52:58 +01:00
src Converge the resolver on the desired set, not the DB rows (#53) 2026-10-27 11:06:17 +00:00
static/default feat: initial commit — edge-router standalone project 2026-04-08 17:37:14 +01:00
.env.example fix!: refuse ALLOWED_ADMIN_HOSTS and ALLOWED_ADMIN_IPS instead of ignoring them 2026-10-26 14:24:27 +00:00
.gitignore Document match_local_ip networking requirement; gitignore encryption keys 2026-08-03 11:54:53 +01:00
build.rs Surface version/commit/build via generic runtime env vars 2026-08-07 10:25:17 +01:00
Cargo.lock feat: tunnel a WebSocket to an https backend (closes #45) 2026-10-23 10:06:19 +01:00
Cargo.toml feat: tunnel a WebSocket to an https backend (closes #45) 2026-10-23 10:06:19 +01:00
CLAUDE.md Converge the resolver on the desired set, not the DB rows (#53) 2026-10-27 11:06:17 +00:00
docker-compose.yml fix: bound the header read, and keep query strings out of the log 2026-10-26 15:37:00 +00:00
docker-entrypoint.sh feat: initial commit — edge-router standalone project 2026-04-08 17:37:14 +01:00
Dockerfile build: use mimalloc as the global allocator 2026-10-22 19:49:28 +01:00
edge-router.example.toml Refuse an httpreq endpoint that embeds credentials (#58) 2026-10-27 10:22:49 +00:00
README.md Refuse an httpreq endpoint that embeds credentials (#58) 2026-10-27 10:22:49 +00:00
rust-toolchain.toml build: bump pinned toolchain to 1.98.0 (this machine's rustc) 2026-08-26 16:58:15 +01:00

Edge Router

A lightweight, self-contained reverse proxy written in Rust. Services register themselves dynamically via a REST API — no config-file restarts required. It handles HTTP and WebSocket proxying, HTTPS upstreams, L4/SNI TCP passthrough, static-site serving, and automatic HTTPS with certificates from Let's Encrypt (DNS-01) — all behind secure-by-default network access control. Backed by SQLite, with no external database dependency.

Designed for Docker-based environments where multiple services share a single entry point and hostname.

Features

  • Dynamic service registration — services register and deregister via REST API; no restart needed.
  • Host & path routing — route by virtual host and path; when several services match, the most specific path wins (exact > longest prefix).
  • Secure-by-default network access control — bindings, named listener trust zones, and per-resource access rules; services, the admin dashboard, and L4 routes are all unreachable until a rule grants them a listener.
  • PROXY protocol v2 (inbound) — recover the real client IP behind an L4 load balancer (AWS NLB, HAProxy, …) that speaks PROXY v2.
  • Dual-stack IPv6 — listens on IPv4 and IPv6 by default (v4 + v6 socket per port, v6 bound IPV6_V6ONLY); access rules and backends accept IPv6 addresses/CIDRs.
  • HTTP & WebSocket proxying — transparent upgrade tunnelling.
  • HTTPS upstreams — proxy to a TLS backend and verify it against a per-service CA; ER presents its own cert to the client and bridges trust to an internal-CA backend.
  • L4 SNI passthrough — peek the TLS ClientHello and tunnel raw TCP to a backend by SNI, without terminating TLS at the edge.
  • Automatic HTTPS, derived from your routes — every name your services, static sites and admin hosts mention gets its own certificate from Let's Encrypt, auto-renewed before expiry. There is no certificate list to maintain: add a route, get a certificate; remove it, the certificate goes.
  • DNS-01 through any httpreq endpoint — challenges are published by a server speaking lego's httpreq protocol, so ER works with any DNS provider without holding a provider credential itself. Wildcards are supported but opt-in, via a wildcard route host.
  • HTTP → HTTPS redirect — automatic 301 when both ports are active (per-service opt-out).
  • A name with no certificate fails the handshake — it is never served a different identity, not a parent's certificate and not the self-signed fallback.
  • Active health checking — per-service probe interval; four-state health (healthy / unhealthy / unknown / unmonitored) with fail-open routing that withholds only confirmed-unhealthy backends.
  • Dashboard with session-cookie auth — the admin UI exchanges an API key for an httpOnly session cookie, so the browser never stores the key.
  • Static site serving — serve files from a mounted directory.
  • Request body limits — global and per-service caps; bodies are streamed, never buffered into RAM (over-limit → 413).
  • Configurable proxy timeout — a connect timeout plus per-direction idle timeouts, global default with per-service override.
  • Host rewriting — optionally rewrite the upstream Host header (rewrite_host) for backends that key on it.
  • Forwarding headers — injects X-Forwarded-For, X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host.
  • Graceful shutdown — drains in-flight connections on SIGTERM / Ctrl-C before stopping.
  • Content-negotiated error pages — HTML for browsers, JSON for API clients, plain text otherwise.
  • Request logging — optional per-service capture of request/response for debugging.
  • API key authentication — a master key, plus service keys for self-registering workloads. Note that a service key is not currently restricted to a narrower set of operations; see #55.
  • TOML route config — seed services, static sites, and L4 routes from a file at startup.
  • Build/version metadata — the image reports its release version, source commit, and CI build via APP_VERSION / APP_COMMIT / APP_BUILD.
  • SQLite storage — single-file database, no external dependencies.

Getting Started

Docker Compose

services:
  edge-router:
    build: ./edge-router
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./edge-router/edge-router.toml:/app/edge-router.toml:ro
      - edge_router_data:/app
    environment:
      HTTPS_PORT: "443"
    networks:
      - devnet

volumes:
  edge_router_data:   # persists the database: certificates, keys, and the ACME account

networks:
  devnet:
    external: true

On first boot, a master API key is generated and printed to stdout once:

========================================
MASTER API KEY GENERATED
========================================
Key: er_AbCdEfGhIjKlMnOpQrStUvWxYz==
========================================
Save this key — it cannot be retrieved again.

Only its hash is stored, so it genuinely cannot be recovered. Because stdout usually goes to the container log, prefer generating it ahead of time and configuring the hash instead — see master-key.

The database holds private keys in plaintext. Certificates, their keys, and the ACME account key are not encrypted at rest; the file mode is their protection. ER sets umask(077) before opening the database, corrects the main file's mode at startup, and refuses to start if the database is owned by another user. Put it on a volume only ER can read. See Certificate storage.

Local Development

cargo build --release
cargo run

# or with hot reload:
cargo watch -x run

Standalone binary (no container)

Every tagged release carries Linux binaries for x86_64 and aarch64, for running EdgeRouter as a native service (a systemd unit, say) on a host with no container runtime. They are extracted from the released image rather than built separately, so a binary and the image of the same version are the same bytes.

The binaries are statically linked against musl: no interpreter, no shared libraries, nothing to install alongside them. A CA bundle is still needed on the host for ACME and for https upstreams.

ER is Linux-only. The crate does compile on macOS, which is useful for development, but a Mac is not a supported runtime and nothing is tested there.

Beyond that it needs only a writable path for its SQLite database and, optionally, an edge-router.toml.

V=v1.0.6
BASE=https://forge.isohex.uk/IsoHex/edge-router/releases/download/$V
curl -LO "$BASE/edge-router-$V-x86_64-linux"
curl -LO "$BASE/SHA256SUMS"
sha256sum -c SHA256SUMS --ignore-missing
chmod +x "edge-router-$V-x86_64-linux"

Command line

With no arguments edge-router runs the reverse proxy. One subcommand runs instead of it, and works on a host with no config and no database — which is exactly when it is needed. An unrecognised argument is an error, not ignored, because the container ENTRYPOINT passes arguments straight through and a typo must not silently boot a server nobody asked for.

edge-router master-key   # generate a master API key and the hash to configure it by
edge-router help

master-key prints the key once and the MASTER_API_KEY_HASH to configure. Configure the hash, not the key: ER never holds the plaintext, so the config is not a secret and the key cannot reach a log line. It touches no database and no files.

Configuration

Settings can be provided via environment variables, a .env file, or a [config] section in edge-router.toml. Environment variables always take precedence over TOML values.

Variable Default Description
HTTP_PORT 80 HTTP listen port
HTTPS_PORT — HTTPS listen port. Required to serve TLS.
DATABASE_URL sqlite://edge-router.db SQLite database path
MASTER_API_KEY_HASH — The master key's sha256:… hash, as edge-router master-key prints. Preferred form: ER never holds the plaintext, so the config carrying it is not a secret and the key cannot reach a log line. Authoritative on every boot; changing it rotates the key. A plaintext MASTER_API_KEY/_FILE alongside it is a hard error.
MASTER_API_KEY — Pre-define the master API key in plaintext, instead of scraping the auto-generated one from first-boot logs. Prefer MASTER_API_KEY_HASH. If set it is authoritative on every boot: it seeds the key on a fresh DB, and changing it rotates the key — the previous value stops working. Minimum 16 characters. Unset → auto-generate on first boot and print once.
MASTER_API_KEY_FILE — Path to a file holding the master key (e.g. a mounted secret). Takes precedence over MASTER_API_KEY when both are set.
HTTPREQ_ENDPOINT — Base URL of the httpreq server that publishes DNS-01 challenges. Required to issue certificates; without it ER starts and proxies, but cannot obtain any. /present and /cleanup are appended beneath this path, so a mount point like https://host/dns is respected. Must not embed credentials (https://user:pass@host/) — that is a startup error, because a URL carrying a password is copied into error messages, which EdgeRouter persists in certificates.last_error and serves from GET /api/certificates and /ready. Use HTTPREQ_USERNAME/HTTPREQ_PASSWORD.
HTTPREQ_USERNAME — Basic-auth user for that endpoint, if it wants one. Set this and HTTPREQ_PASSWORD together or neither — exactly one is a startup error.
HTTPREQ_PASSWORD — Basic-auth password for that endpoint.
HTTPREQ_HTTP_TIMEOUT 30 Request timeout for the endpoint, in whole seconds.
HTTPREQ_ENDPOINT_FILE, HTTPREQ_USERNAME_FILE, HTTPREQ_PASSWORD_FILE — Read the value from a file instead (e.g. a mounted secret). Setting a variable and its _FILE form together is a hard error, so there is never doubt which is in use. ER reads the file as provided and does not inspect its mode or owner — a Compose secret arrives 0444 on a read-only mount, and refusing that would refuse the recommended mechanism.
LOG_RETENTION_COUNT 1000 Max request log entries to keep
LOG_RETENTION_HOURS 24 Max age of request log entries
DEFAULT_SITE_DIR — Directory to serve when no service or static site matches. Prefer [default_site].dir
DEFAULT_SITE_ENABLED true Whether to fall back to the default site (set false for pure proxy mode). Prefer [default_site].enabled
TLS_FALLBACK_CERT true Serve a self-signed cert for IP/unknown-domain HTTPS connections. Set false to let the TLS handshake fail instead.
REDIRECT_HTTP_TO_HTTPS true Issue 301 redirects from HTTP to HTTPS when HTTPS_PORT is configured. Set false to serve both protocols independently.
PROXY_TIMEOUT_SECONDS 30 Upstream timeout in seconds: a connect timeout plus per-direction idle timeouts (send + read) that reset on activity — a large actively-transferring body (e.g. a Docker push) is never capped, but a stalled transfer 502/504s. 0 disables all upstream timeouts (for streaming/SSE/long-poll services). Override per service with proxy_timeout_seconds.
MAX_REQUEST_BODY — Global max request body size (e.g. 2GB, 500MB, or a raw byte count). Unset = unlimited. Override per service with max_request_body; the effective limit is the minimum of the two. Bodies are streamed (never buffered into RAM); an over-limit request gets 413.
ROUTES_CONFIG edge-router.toml Path to the TOML routes config file
RUST_LOG edge_router=info,tower_http=info Log level. Per-request spans record the method and path only — never the query string — so raising this to debug does not put credentials carried in a URL (a presigned link, ?token=, an OAuth ?code=) into the host log.
APP_VERSION build value Release version shown on the dashboard/status. Override at deploy to the release tag (e.g. v1.6.1).
APP_COMMIT build value Git commit the image was built from (set by CI).
APP_BUILD local CI build that produced the image, e.g. forge-102 (set by CI).

DATABASE_URL and the HTTPREQ_* variables are infrastructure concerns and must be set via environment — they are intentionally not configurable from the TOML file.

Secrets — prefer a file to an env var. For MASTER_API_KEY and the HTTPREQ_* credentials, point the *_FILE variant at a Docker/Podman/Compose secret (mounted at /run/secrets/<name>) rather than setting the value inline: a file-mounted secret is not exposed via docker inspect, /proc/<pid>/environ, or to child processes.

The two families resolve it differently, deliberately. For the master key, the file variant takes precedence over the inline env var when both are set, and MASTER_API_KEY_HASH outranks both and rejects them if set alongside — ER never sees the plaintext at all, so there is nothing to leak. Break-glass is a deliberate reconfiguration (swap the hash for a file secret), never a silent fallback. For the HTTPREQ_* credentials, setting both forms is a hard error rather than a ranking: these are read once at startup and a silent preference would leave an operator editing the value that is not in use.

TOML-based config

Operational settings can be moved out of the environment and into edge-router.toml:

[config]
http_port                = 80
https_port               = 443
default_site_enabled     = true
tls_fallback_cert        = true    # set false to reject TLS handshakes for unknown domains/IPs
redirect_http_to_https   = true    # set false to serve HTTP and HTTPS independently
proxy_timeout_seconds    = 30      # global upstream timeout; override per service
max_request_body         = "2GB"   # global request-body cap; unset = unlimited
log_retention_count      = 1000
log_retention_hours      = 24
redact_log_headers       = ["x-auth-token"]   # extra headers to redact from request logs

Environment variables override TOML values when both are present.

Request-log redaction: when a service has enable_logging = true, EdgeRouter records request/response headers. The values of secret-bearing headers are stored as <redacted> — a built-in, non-removable floor (authorization, proxy-authorization, cookie, set-cookie, x-api-key) that redact_log_headers extends (never replaces) with your own header names (case-insensitive). Header names are kept for debugging; only their values are masked.

Secret injection with ER_ variables

Any value in edge-router.toml can reference an environment variable using ${VAR_NAME} syntax. EdgeRouter looks up ER_VAR_NAME and substitutes it at startup; if the variable is not set, EdgeRouter exits with a clear error naming the variable.

Substitution happens after the file is parsed, so it applies to values only. A ${VAR} in a comment — or on a config line you have commented out — is just text and needs no variable set.

[[services]]
name          = "api"
routing_hosts = ["api.example.com"]
routing_paths = ["/"]
target_host   = "${API_BACKEND}"   # reads ER_API_BACKEND from environment
target_port   = 8080
# docker-compose.yml
environment:
  ER_API_BACKEND: "api.internal"

The ER_ prefix namespaces pass-through values away from EdgeRouter's own config variables, preventing accidental cross-use.

Network Access Control

EdgeRouter is secure by default: services, the admin dashboard, and L4 routes are unreachable from anywhere until an access rule grants them a listener. An empty rule list is a deny-all, not "open" — there is no legacy unrestricted state. Three layers, all configured in edge-router.toml:

Bindings are the OS-level socket binds — where the process actually listens. Each carries a mode saying what it may do with an accepted connection:

[[bindings]]
name = "https"
bind = "0.0.0.0:443"
mode = "tls"
mode Behaviour
plain Plaintext HTTP. The default when mode is omitted.
tls Peek the ClientHello's SNI for an L4 route and tunnel a match; otherwise terminate TLS and serve HTTP.
passthrough Peek the SNI and tunnel a match; drop anything else. No TLS terminator is built for the socket at all.

mode = "passthrough" makes a binding a blind switchboard: it routes by server name and cannot decrypt, because there is nothing in the process to decrypt with. Use it to front backends that hold their own certificates and must not be terminated at the edge — a secrets store, a CA — where "we configured no certificates" is not a strong enough guarantee. See L4 SNI Passthrough.

The older terminates_tls = true|false still works and means mode = "tls"|"plain". Setting both keys on one binding is a startup error.

Define no bindings and EdgeRouter binds dual-stack by default — a v4 (0.0.0.0) and a v6 ([::]) socket for each configured port (http/http6/https/https6; the v6 socket is IPV6_V6ONLY so the pair never collides). Both sockets of a port share one listener/trust-zone name (http/https), so an access rule via = "http" covers IPv4 and IPv6 alike. Resources still need an explicit access rule to be reachable. (If you declare bindings yourself but no listeners, each binding gets its own catch-all listener named after it — no implicit v4/v6 pairing.)

Declaring listeners is all-or-nothing. The moment any [[listeners]] entry exists, that list is used verbatim and the per-binding catch-alls are gone — so every binding needs its own entry. A binding no listener names as its parent still opens its socket and accepts connections, then drops each one; a client sees an empty reply. EdgeRouter warns at startup, naming the binding.

Listeners are named trust zones layered on a binding. A listener can match on the accepted connection's real local address (match_local_ip — e.g. to tell LAN from Tailscale; never a client-supplied value) and/or declare that forwarded client-IP headers are trustworthy on it (trust_forwarded_for):

[[listeners]]
name = "lan"
parent = "https"
match_local_ip = "192.168.0.247/32"    # this host's LAN address

[[listeners]]
name = "tailscale"
parent = "https"
match_local_ip = "100.71.56.28/32"     # this host's Tailscale address

# A binding reachable only from an internal network (e.g. a cloudflared
# sidecar). Trusting CF-Connecting-IP / X-Real-IP is sound here precisely
# because nothing else can reach this binding.
[[bindings]]
name = "cloudflare-internal"
bind = "0.0.0.0:8443"
mode = "tls"

[[listeners]]
name = "cloudflare"
parent = "cloudflare-internal"
trust_forwarded_for = true

Bindings and listeners are startup-only — changing them requires a restart. Access rules (below) hot-reload.

Access rules grant a resource reachability through a listener. Each names a via listener and may further restrict to client IPs/CIDRs with allow_ips (bare IPs and CIDR blocks, mixed freely). Multiple rules are OR'd; an empty list means unreachable. The same [[…access]] mechanism gates services ([[services.access]]), the admin dashboard ([[admin.access]]), and L4 routes ([[l4_routes.access]]):

[[services.access]]
via = "cloudflare"                       # reachable from the internet via the tunnel

[[services.access]]
via = "tailscale"                        # and directly over Tailscale...
allow_ips = ["100.64.0.0/10"]            # ...optionally narrowed to specific peers
# no "lan" rule → not reachable from the LAN directly

Rule changes on DB-backed resources (services, L4 routes) take effect immediately via the API, or within a few seconds when edited in the TOML file; admin rules refresh on the same config-file poll.

trust_forwarded_for and the client IP. For both access checks and forwarding headers, EdgeRouter uses the effective client IP: it honors a claimed CF-Connecting-IP / X-Real-IP only on a listener marked trust_forwarded_for (one structurally reachable solely through a trusted intermediary). On any other listener the real TCP peer address is used — so a client can't spoof its source IP to satisfy an allow_ips rule.

proxy_protocol and the client IP behind an L4 load balancer. Set proxy_protocol = true on a binding and every connection on it MUST begin with a PROXY protocol v2 header (the format an AWS NLB, HAProxy, or any PROXY-v2-speaking L4 proxy writes). EdgeRouter consumes that header before the TLS handshake and uses its declared source as the connection's client address — so allow_ips, logging, and X-Forwarded-For reflect the real client, not the balancer. A missing or malformed header drops the connection (required mode — no auto-detect). It composes with mode = "tls" and mode = "passthrough" alike: the header is consumed first, then the ClientHello proceeds untouched. Because the header lets its sender claim any IP, this is the L4 analogue of trust_forwarded_for — only ever enable it on a binding reachable solely via a trusted L4 proxy.

⚠ match_local_ip needs the process to see the host's real interface addresses. Under Docker bridge networking, DNAT rewrites the destination before EdgeRouter sees it, so match_local_ip never matches and that traffic fails closed. Use network_mode: host (or run Tailscale on the host), or use a single catch-all listener per binding. See edge-router.example.toml for the full annotated example.

Routing & Health

Matching rules

A request matches a service when both are true:

  • The Host header exactly equals one of the service's routing_hosts, or matches one written as an explicit *.example.com wildcard — which covers exactly one label beneath it and never the bare apex, so sub.example.com matches while deep.sub.example.com and example.com do not
  • The request path matches one of the service's routing_paths

Path patterns:

Pattern Meaning
/foo exact — only /foo
/foo/? optional trailing slash — /foo and /foo/
/foo/* segment prefix — /foo/ and below (not /foobar, not bare /foo)
/foo* raw prefix — /foo, /foobar, /foo/bar
/* catch-all

When several services match one request, the most specific path wins, independent of registration or name order: exact and /? matches outrank any prefix, and among prefixes the longest wins. The request path is forwarded to the backend unchanged. A service must be granted a listener via an access rule to be reachable at all, and confirmed-unhealthy backends are skipped (below).

routing_hosts entries match a Host exactly, or as an explicit *.sub.domain wildcard covering exactly one label beneath it — never the bare apex, and never two labels down. The same rule applies to [[l4_routes]] SNI and [[static_sites]] hosts.

One label is what a wildcard certificate covers (RFC 6125), and routing deliberately matches that: a host EdgeRouter would route but could never obtain a certificate for would fail its TLS handshake rather than be served, so the two rules are kept in step. Need two levels? Add a route host for the intermediate wildcard (*.sub.example.com) and it gets its own certificate.

Prior to v2.0.0 matching was a prefix comparison, which admitted an attacker-chosen suffix (example.com matched example.com.evil) and rejected genuine subdomains; see #32. v2.0.0 also narrowed wildcards from any depth to one label; see #56.

Health checking

Each service with a health_check_path is probed on its own health_check_interval_seconds. A shared checker ticks faster than any interval and probes a backend only once its interval has elapsed; every backend is probed once on startup so a restart doesn't serve stale health. An https service is probed over TLS with its upstream_ca_cert (see HTTPS upstreams). Health is four-state:

State Dashboard Meaning
healthy green last probe passed
unhealthy red last probe failed
unknown amber a health check is configured but hasn't run yet
unmonitored grey no health_check_path — the service is never probed

Routing is fail-open: only a confirmed-unhealthy backend is withheld. An unknown or unmonitored backend still receives traffic, so a freshly registered or just-restarted service isn't black-holed waiting on its first probe. /api/status and the dashboard report the four-state health field alongside the legacy boolean is_healthy.

A withheld backend is not treated as a missing route: the request gets 503 plus whatever the fallback chain provides, so an outage is visible to monitoring instead of looking like a typo.

HTTPS Upstreams

By default EdgeRouter proxies to backends over plaintext http://. Set target_scheme = "https" on a service to open a TLS connection to the backend instead. This bridges trust: EdgeRouter still terminates the client's TLS with its own (ACME/Let's Encrypt) certificate, then opens a separate, verified TLS connection to the backend — so a browser reaches a backend that presents an internal-CA certificate over a normal trusted chain, with no need to install the internal CA locally.

[[services]]
name          = "vault-ui"
target_host   = "api.internal.example"  # MUST match a SAN on the backend cert
target_port   = 443
target_scheme = "https"              # open TLS to the backend (default "http")
upstream_ca_cert = "/etc/edge-router/internal-ca.pem"  # CA that verifies it
routing_hosts = ["bao.example.com"]
routing_paths = ["/*"]

[[services.access]]
via = "tailscale"
  • upstream_ca_cert — path to a PEM CA bundle that verifies the backend. It replaces the public roots (trust pinned to exactly that CA). Omit it to verify an https backend against the normal system roots instead. Ignored for http. A CA that can't be read or parsed fails closed (502), never a downgrade to plaintext or to public roots.
  • target_host must match a SAN on the backend certificate. The TLS SNI and verification host is target_host, so point it at the name the backend cert is valid for (e.g. api.internal.example), not a bare container name. rewrite_host only rewrites the forwarded HTTP Host header — it does not change TLS verification.
  • The same scheme + CA are used by the health-check probe, so an https-only backend is probed over TLS (an http probe against it would fail and the service would never be routed).
  • WebSocket upgrades to an https backend work: the tunnel gets its own TLS client layer, using the same trust decision as the proxy path and the health probe, with target_host as the SNI and verification name. A handshake or verification failure reports as an ordinary proxy error (502), not a blanket refusal.

Automatic HTTPS

Certificates are derived from the route table, never configured. Every host your [[services]], [[static_sites]] and [[admin]] entries mention is a name ER should be able to serve, so ER obtains a certificate for each one and keeps it renewed. There is no certificate list to maintain and nothing to add when you add a route.

Turn it on with an [[acme]] block, which carries only the account:

[[acme]]
name    = "letsencrypt"         # identifies the account
email   = "[email protected]"   # the ACME account contact
# staging = true                # Let's Encrypt staging — see below

[[acme]] is an array of tables, and exactly one entry is supported — two is an error rather than a silent choice. It is an array because using several ACME directories is planned (#50), and settling the shape now means that arrives as new fields instead of another breaking change to this block.

and point ER at an httpreq endpoint that owns the DNS writes:

HTTPREQ_ENDPOINT=https://dns01.internal.example/dns

Both are required to issue anything. Without them ER starts and proxies normally; it simply has no way to obtain a certificate.

Upgrading from a [[tls]] config? A leftover [[tls]] block is a hard startup error, not a silent ignore — ER names [[acme]] and HTTPREQ_ENDPOINT as its replacement and refuses to boot, so you cannot accidentally run with certificate config that nothing reads. cloudflare_api_token is gone entirely.

How it works

A reconciler compares the set of names your routes mention against the certificates in the database, and acts on the difference:

  1. A name with no certificate gets one ordered: a single-identifier ACME order, DNS-01, published through the httpreq endpoint.
  2. A certificate within 30 days of expiry is renewed. Renewals are exempt from Let's Encrypt's per-registered-domain limit, so steady-state operation does not consume it.
  3. A name no longer in the route table has its certificate deleted and its resolver entry dropped.
  4. New certificates load into the live SNI resolver immediately — no restart.

Each pass is spaced and each name is independent: one name failing to issue does not stop or delay the others, and a name whose renewal fails keeps serving its existing certificate until that certificate actually expires.

Failures back off exponentially from 60 seconds to a 6-hour cap, jittered downward so a fleet of instances does not retry in lockstep.

What a name with no certificate gets

Nothing — the handshake fails. ER refuses it with a TLS alert rather than serving a certificate for a different name. It will not substitute the parent domain's certificate, and it will not substitute the self-signed fallback. A client gets a connection error, never a wrong identity.

The self-signed fallback (TLS_FALLBACK_CERT) applies only to names ER was never configured for at all — an IP address, or a host that matches no route.

Wildcards

Supported, but opt-in: a certificate covers a wildcard only if a route host is written as one.

[[services]]
routing_hosts = ["*.apps.example.com"]   # one certificate covers every subdomain

Without that, each name gets its own certificate, which is usually what you want — it means a failure for one name cannot affect another. Use a wildcard where names are ephemeral and a certificate each would be wasteful, such as per-branch preview environments.

Note this is ER's own behaviour, not a boundary an endpoint can enforce: *.example.com and example.com validate through the same _acme-challenge.example.com record, so a DNS-01 endpoint cannot tell them apart.

Names no public CA will issue for

ER skips names reserved by RFC 6761 and RFC 6762 — anything under .localhost, .local, .test, .invalid or .example. This is not a policy choice: those names are not in the public DNS, so there is nothing for an ACME challenge to validate, and the CA/Browser Forum Baseline Requirements forbid any publicly trusted CA from issuing for them.

Such a name refuses its TLS handshake, like any other name with no usable certificate — it is never served a different identity. It does not count as a failure, so a development host whose routes are all .localhost still reports ready.

Watch ER's own redirect. A route without skip_https_redirect gets a 301 from HTTP to HTTPS, so a reserved name reachable over HTTP becomes a redirect into a refused handshake. Either give those routes real names in a zone ER can get a certificate for, or set skip_https_redirect = true on them.

There is currently no way to serve HTTPS on a name no public CA will issue for — no operator-supplied certificate source, and the ACME directory is not configurable. Issue #50 tracks that gap.

Staging mode

Set staging = true in [[acme]] to use Let's Encrypt's staging environment. Staging certificates are not browser-trusted, but the rate limits are far looser — the right place to prove your httpreq endpoint and DNS wiring work.

The flag applies to the whole instance, not per name.

Flipping it is all you have to do. Set staging = false and restart: ER registers a production account (it keeps one account per environment, so returning to staging later reuses the staging one) and reissues every certificate the staging run produced, because a staging chain is not trusted by any browser and waiting for its 90 days to run down would serve an untrusted certificate the whole time. Nothing to delete by hand.

That reissue consumes production rate limit for every name at once, which is the main reason to get the httpreq endpoint working under staging first.

Rate limits worth knowing

Let's Encrypt allows 50 new certificates per registered domain per week, with renewals exempt. One certificate per name means a first rollout of many names consumes this where a single wildcard would not — ER spaces its orders, but a fleet of a few hundred names needs either a wildcard route or a staged rollout.

The 5 duplicate certificates per week limit counts renewals, but per-name issuance partitions it: each name has its own exact identifier set, so one name's retries cannot exhaust another's allowance.

Certificate storage

Certificates, their private keys, and the ACME account key are stored in SQLite unencrypted. The database's file mode is their protection:

  • ER sets umask(077) before opening the database, so SQLite's -wal and -shm sidecars are created private too.
  • The main database file's mode is corrected at startup, for installs predating this.
  • A database owned by another user is a hard error — ER refuses to start rather than use a key store it does not own.

This is defensible because ER holds no DNS provider credential. The httpreq endpoint owns the DNS writes, so the worst a readable database yields is the certificates themselves, not the ability to issue new ones for the zone. Do not reintroduce a provider token here.

L4 SNI Passthrough

For backends that must terminate their own TLS (mutual TLS, or a service with its own certificate), EdgeRouter can tunnel raw TCP by SNI without decrypting at the edge. On any binding whose mode is tls or passthrough it peeks the ClientHello, reads the SNI, and:

  • if the SNI matches an [[l4_routes]] entry (exact match first, then a *.wildcard), it streams raw bytes straight to the backend — TLS is never terminated at the router;
  • otherwise the binding's mode decides: tls rewinds the connection and normal TLS termination proceeds unaffected, while passthrough drops it.

Several backends demux off one socket by name, and TLS-layer mutual auth survives the tunnel intact — the backend sees the client's certificate.

On a passthrough binding, EdgeRouter is given no TLS terminator at all, so an unmatched name cannot be served even if certificates are configured elsewhere in the file. That is the difference worth caring about when fronting something sensitive: with mode = "tls" and no certificates the handshake also fails today, but only until someone adds a [[tls]] or [[managed_cert]] entry. Startup warns if a passthrough binding exists with no L4 routes at all — it would drop every connection.

[[l4_routes]]
name = "vault"
match_sni = "vault.example.com"
target_host = "vault"
target_port = 8200
# health_check_interval_seconds = 30   # optional TLS-handshake probe (display-only)
# max_connections = 100

[[l4_routes.access]]
via = "tailscale"

Access rules are enforced on L4 routes too, using the real peer IP — L4 passthrough has no HTTP headers, so trust_forwarded_for does not apply at this layer. L4 health checks (a TLS handshake to the backend) are display-only: passthrough is never gated on them. Connections that don't send a complete ClientHello within a few seconds are dropped (slow-loris guard).

Static Sites

Static sites are served from a mounted directory, seeded from [[static_sites]] in edge-router.toml:

[[static_sites]]
host = "docs.example.com"
dir  = "/app/static/docs"

A static site is a local directory of files. For object-storage-backed sites, register the storage service itself as a [[services]] entry and let it serve the site — Garage does this natively, and proxying to it keeps EdgeRouter out of the business of speaking S3. Object-storage sources (bucket + minio) were removed in v2.0.0 along with MinIO support; see Migrating from bucket sites.

Migrating from bucket sites

A [[static_sites]] entry with bucket/minio is now a startup error naming the host. Replace it with a service pointing at whatever serves the bucket:

# Before
# [[static_sites]]
# host   = "marketing.example.com"
# bucket = "marketing-site"
# minio  = "primary"

# After — Garage (or any S3 gateway) serves the site; EdgeRouter proxies to it
[[services]]
name          = "marketing-site"
target_host   = "garage"
target_port   = 3902
routing_hosts = ["marketing.example.com"]
routing_paths = ["/*"]

[[services.access]]
via = "cloudflare"

This is strictly more capable than the old bucket source: the site gets health checking, access rules, request logging, timeouts and HTTPS-upstream support, none of which applied to bucket-backed static sites.

Fallback chain

A request that no service serves falls through, in this order:

Tier Gated by enabled
1 [[static_sites]] entry whose host matches no — explicit config is always served
2 [default_site]'s dir yes
3 Built-in page (HTML requests only) yes
4 Bare error response —

The status says why you landed there, and the body is independent of it — a friendly page and an honest status are not in conflict:

Situation Status Page
Service matched, backend confirmed unhealthy 503 designated error_pages[503], else the built-in down page
Nothing matched this host/path 404 the static site's content, else error_pages[404], else the built-in holding page
Service matched but the caller may not reach it 404 as above — deliberately indistinguishable from unconfigured

Before this, a down backend was withheld during matching and answered 404 plus the holding page, identical to a host nobody had registered — so nothing could alert on an outage, and 404 invites crawlers to deindex a site that is merely down. 503 is the HTTP status for exactly this (RFC 9110: temporarily unavailable, body expected), and it is what search engines treat as "come back later".

Every fallback response carries X-Edge-Router-Fallback, either backend-unhealthy or no-matching-service, so one curl -I tells you which happened.

A non-HTML client (Accept: application/json, say) asking for a down service gets a content-negotiated error rather than a maintenance page.

error_pages

Map a status to one file, on a static site or the default site:

[[static_sites]]
host = "app.example.com"
dir  = "/srv/app"
error_pages = { 503 = "maintenance.html", 404 = "not-found.html" }

[default_site]
dir = "/srv/default"
enabled = true
error_pages = { 503 = "maintenance.html" }

Keys must be 4xx or 5xx; anything else is a startup error naming the key.

Two deliberate properties:

  • An error page ignores the request path. A down service's fallback serves that one file whatever was requested. Resolving the URI inside the static dir instead could return a file from an unrelated source dressed up as the app, and would send HTML to an /api/* caller.
  • Config can never rewrite the status. nginx's error_page 503 =200 /maint.html form is intentionally not supported — serving a maintenance page as 200 is what hides outages in the first place. The file is configurable; the status is not.

[default_site] and the legacy keys

DEFAULT_SITE_DIR and DEFAULT_SITE_ENABLED still work, as do [config].default_site_dir/default_site_enabled. Precedence is env vars, then [default_site], then [config], and EdgeRouter warns at startup if the block and the legacy keys are both set. enabled = false turns off tiers 2 and 3 — pure proxy mode — and never suppresses a configured [[static_sites]] entry.

At-rest protection

Nothing in the database is encrypted. Certificates, their private keys, and the ACME account key are stored as plaintext, and the database's file mode is what protects them — see Certificate storage for the mechanics and for why this is a defensible position rather than an omission.

The one value that is not stored in recoverable form is the master API key: only a SHA-256 hash of it is kept, because ER only ever needs to compare, never to recover. That is also why there is nothing to rotate here — rotating the master key means setting a new MASTER_API_KEY_HASH, not re-encrypting a store.

Earlier versions encrypted the database with AES-256-GCM or OpenBao transit, configured via ENCRYPTION_KEY, ENCRYPTION_ENGINE, BAO_* and TPM_*. All of those variables were removed in v2.0.0 and setting any of them now is a startup error, so an upgrade fails loudly rather than silently ignoring your configuration. See the release notes for the migration.

Dashboard & Auth

Each [[admin]] host serves the dashboard at / (plus favicon and logo), reachable only on a granted listener from a permitted IP (see Network Access Control). The page itself needs no API key; its /api/* calls are authenticated by either a session cookie or an X-API-Key header.

So that the browser never has to store the master key, the dashboard exchanges the key for an httpOnly session cookie:

  • POST /api/session with { "key": "er_…" } → 200 and a Set-Cookie: er_session=… (HttpOnly; SameSite=Strict; Path=/, and Secure). An invalid key → 401. This endpoint is deliberately unauthenticated (it's how you authenticate) and is reachable only on an admin host from a permitted network.

    The session routes refuse a cleartext hop with 403, independently of redirect_http_to_https — a login carries the master key, so it is never served in the clear even where an operator has turned the redirect off. A request counts as HTTPS when EdgeRouter terminated TLS itself, or when a listener carrying trust_forwarded_for reports X-Forwarded-Proto: https. So if TLS terminates at a CDN or tunnel ahead of EdgeRouter, set trust_forwarded_for on that listener or the dashboard cannot be logged into. The same rule sets the cookie's Secure flag. /health is exempt — a liveness probe has neither TLS nor a key.

  • The cookie is valid for a 60-minute idle window that slides on each use, with a hard 12-hour absolute cap. Sessions live in memory only.

  • POST /api/session/logout invalidates the session and clears the cookie.

Machine/automation callers skip all of this and send X-API-Key directly.

API Reference

All endpoints require authentication unless noted: an X-API-Key: <key> header, or (for dashboard calls) a valid er_session cookie. Every endpoint is gated by the [[admin]] host and its access rules — the request must arrive on a granted listener from a permitted IP, registration endpoints included.

What a service key can and cannot do. A service key may register and modify services and L4 routes, read state, and hold a dashboard session. It may not manage keys: GET/POST /api/keys and DELETE /api/keys/:id require a master key and return 403 otherwise. A session carries the authority of the key that created it and no more, so exchanging a service key for a cookie does not widen it.

Service keys are still not scoped to particular services — any key may modify any service (#55 covers per-key resource scoping as later work). And revoking a key does not end dashboard sessions already established with it (#60) — restart EdgeRouter if that matters.

Session (unauthenticated; admin host only)

POST /api/session          # { "key": "er_..." } → 200 + Set-Cookie er_session (401 if invalid)
POST /api/session/logout   # invalidate the session and clear the cookie

Keys (admin)

POST   /api/keys           # Create a service key  { "description": "my-service" }
GET    /api/keys           # List all keys
DELETE /api/keys/<id>      # Revoke a key

Status & logs (admin)

GET /api/status                          # Router status summary
GET /api/metrics                         # Uptime, request counts, error rate, latency
GET /api/logs?service_id=<id>&limit=100  # Request logs (requires enable_logging on the service)

Services

GET    /api/services            # List all services            (admin)
GET    /api/services/<name>     # Get one service              (admin)
POST   /api/services            # Register/upsert a service    (key only)
PATCH  /api/services/<name>     # Update fields on a service   (key only)
DELETE /api/services/<name>     # Deregister a service         (key only)

Register (upserts on name):

POST /api/services
{
  "name":                          "my-api",
  "target_host":                   "my-api",
  "target_port":                   3000,
  "target_scheme":                 "http",       // optional; "http" (default) or "https"
  "upstream_ca_cert":              null,         // optional; PEM CA path verifying an https backend
  "routing_hosts":                 ["api.example.com"],
  "routing_paths":                 ["/*"],       // exact (/health) and wildcard (/api/*) supported
  "health_check_path":             "/health",    // optional
  "health_check_interval_seconds": 30,
  "health_check_timeout_seconds":  5,
  "enable_logging":                true,         // required for /api/logs and dashboard stats
  "proxy_timeout_seconds":         60,           // optional; overrides global default
  "max_request_body":              5368709120,   // optional; per-service body cap in BYTES (min with global)
  "skip_https_redirect":           false,        // optional; set true when a CDN terminates TLS
  "rewrite_host":                  false,        // optional; rewrite upstream Host to target_host
  "access_rules": [                              // optional; the JSON-API form of [[services.access]]
    { "via": "tailscale", "allow_ips": ["100.64.0.0/10"] }
  ]
}

Update (all fields optional; only provided fields change):

PATCH /api/services/<name>
{
  "enable_logging": true,
  "rewrite_host":   true
  // Any subset of the POST fields. Omitted fields are left unchanged.
  // Pass null explicitly to CLEAR a nullable field: health_check_path,
  // proxy_timeout_seconds, max_request_body, upstream_ca_cert.
}

The service response exposes both the legacy boolean is_healthy and the four-state health field, plus the stored target_scheme and upstream_ca_cert.

Certificates (read-only)

There is no certificate CRUD: the set is derived from the route table, so you change it by changing routes. What the API offers is observation.

GET /api/certificates    # Every certificate's state                       (admin)
GET /ready               # Readiness, including per-name certificate state (admin)
GET /health              # Liveness                           (admin host, no auth)
// GET /api/certificates
[
  {
    "name":            "api.example.com",
    "issued":          true,          // a usable chain AND key are present
    "expires_at":      "2027-01-20T09:14:00Z",
    "last_renewed_at": "2026-10-22T09:14:00Z",
    "last_error":      null,          // the most recent failure, if any
    "last_attempt_at": "2026-10-22T09:14:00Z"
  }
]

Responses never include a private key.

/ready reports each name's state and distinguishes three cases that matter operationally:

  • serving — a usable certificate is loaded in the resolver.
  • not ready — ER should have a certificate for this name and does not, or is serving one that expires within 14 days and whose last renewal attempt failed. A healthy certificate with a transient failure behind it is still ready: it is serving, and there is time to retry.
  • never certifiable — a reserved name such as anything under .localhost. Reported, but it does not make the instance un-ready, so a development host whose routes are all .localhost is not permanently 503.

/health is the probe you can use unauthenticated. It is gated to an admin host but carries no auth, because a liveness probe has no key to present. /ready sits on the admin API behind both the IP allow-list and key auth, so probing it means supplying the key — in Kubernetes, a probe httpHeaders entry; in Compose, a healthcheck that passes Authorization. Use /health for liveness and /ready only where you can authenticate.

L4 routes

The TOML key is [[l4_routes]]; the API path is /api/l4.

GET    /api/l4             # List all L4 routes            (admin)
GET    /api/l4/<name>      # Get one L4 route              (admin)
POST   /api/l4            # Register/upsert an L4 route   (key only)
PATCH  /api/l4/<name>     # Update fields on an L4 route  (key only)
DELETE /api/l4/<name>     # Deregister an L4 route        (key only)

Operational

HTTP → HTTPS redirect

The redirect needs https_port (or HTTPS_PORT) set, and that is a separate setting from what makes a binding terminate TLS. A [[bindings]] entry with mode = "tls" serves HTTPS perfectly well while https_port is unset — and then no redirect is ever issued, because https_port is what supplies the target port. EdgeRouter warns at startup when the redirect is on, something terminates TLS and https_port is missing. Set it to the port clients reach TLS on, which is not necessarily the binding's: a TLS binding on :8443 behind a hop that publishes :443 wants https_port = 443.

With it configured, EdgeRouter issues a port-aware 301 Moved Permanently from HTTP to HTTPS for all requests (the Location omits the port when it is 443). Set REDIRECT_HTTP_TO_HTTPS=false (or redirect_http_to_https = false) to serve both protocols independently. To opt a single service out while keeping the redirect on globally, set skip_https_redirect = true on it — useful when a CDN terminates TLS and forwards plain HTTP, where redirecting would loop.

Request-head timeout

A connection has 30 seconds to send its request head (request line plus headers). A connection that opens and then trickles headers is dropped, rather than being held open indefinitely — hyper has no default here, so without it a single host could park unlimited half-open connections on every binding. It is fixed, not configurable, and bounds only the head: a slow body, a long upload and an upgraded WebSocket are all unaffected.

Note this does not cap an idle WebSocket tunnel, which currently has no timeout or connection cap at all; see #62.

Proxy timeout

PROXY_TIMEOUT_SECONDS (default 30) is not a total deadline: it is a connect timeout plus per-direction idle timeouts (send and read) that reset on activity. An actively-transferring large body (e.g. a Docker push) is never capped; a stalled transfer 502/504s. 0 disables all upstream timeouts — the escape hatch for streaming / SSE / long-poll backends. Override per service with proxy_timeout_seconds; the per-service value takes precedence over the global default.

[[services]]
name                  = "slow-export"
target_host           = "export-svc"
target_port           = 8080
routing_hosts         = ["export.example.com"]
routing_paths         = ["/*"]
proxy_timeout_seconds = 300   # 5 minutes for large exports

Forwarding headers

On every proxied request, EdgeRouter injects:

Header Value
X-Forwarded-For Client IP (appended to any existing value)
X-Real-IP Client IP
X-Forwarded-Proto https or http
X-Forwarded-Host Original Host header from the client

The client IP here is the effective client IP: a forwarded CF-Connecting-IP / X-Real-IP is honored only on a listener marked trust_forwarded_for; on any other listener the real TCP peer is used, so a client can't spoof it.

The same gate applies to what EdgeRouter sends. X-Real-IP, X-Forwarded-Proto and X-Forwarded-Host are always EdgeRouter's own values — a client-supplied copy is dropped, never merged. X-Forwarded-For is the one header meant to record every hop, so on a trust_forwarded_for listener EdgeRouter appends its peer to what the upstream proxy wrote; on any other listener it sends only its own peer, because there is no upstream hop to record and a client-supplied chain would otherwise put an attacker-chosen address where a leftmost-wins backend reads it. CF-Connecting-IP and RFC 7239 Forwarded are dropped from client requests and never set.

Upstreams that need the real client IP (rate limiting, geolocation, audit) should read X-Real-IP, or the rightmost X-Forwarded-For entry.

Graceful shutdown

EdgeRouter handles SIGTERM and Ctrl-C: it stops accepting new connections and waits for in-flight requests to complete before exiting (the HTTPS accept loop drains after the HTTP server). In Docker, docker stop sends SIGTERM and waits 10 seconds before SIGKILL — enough for almost all workloads. For long-running requests (video uploads, slow queries), increase stop_grace_period in your compose file.

Self-registration pattern

Services can register themselves at startup and deregister on shutdown. A typical entrypoint:

#!/bin/sh
set -e

# Wait for edge-router
until curl -sf http://edge-router/api/status -H "X-API-Key: $EDGE_ROUTER_KEY" > /dev/null; do
  echo "Waiting for edge-router..."
  sleep 2
done

# Register
curl -sf -X POST http://edge-router/api/services \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $EDGE_ROUTER_KEY" \
  -d "{
    \"name\":         \"$SERVICE_NAME\",
    \"target_host\":  \"$HOSTNAME\",
    \"target_port\":  $PORT,
    \"routing_hosts\": [\"$VIRTUAL_HOST\"],
    \"routing_paths\": [\"/*\"],
    \"health_check_path\": \"/health\"
  }"

# Run the service (this line becomes PID 1)
exec "$@"

Architecture

Component Role
Axum HTTP framework, routing, middleware
SQLx + SQLite Service registry, API keys, request logs, certificate storage
Reqwest Outbound HTTP proxying, health checks, httpreq DNS-01 calls
Hyper + hyper-util WebSocket upgrade tunnelling, manual HTTPS accept loop
tokio-rustls TLS termination
rustls SNI cert resolver, ServerConfig
instant-acme ACME protocol client (Let's Encrypt)
rcgen CSR generation for ACME
x509-parser Certificate expiry extraction
hickory-resolver Confirming a DNS-01 TXT record has propagated before asking the CA to validate
sha2 Hashing the master API key — ER compares, never recovers
mimalloc Global allocator; musl's serialises small allocations under thread contention
Tokio Async runtime

Licence

MIT