Routing
Zion routes requests using a radix tree (via the matchit crate -- the same engine used by Axum). Route lookup is ~15ns regardless of route count.
Route patterns
Routes are defined as [[route]] entries in zion.toml. Order does not matter -- the radix tree handles priority automatically.
[[route]]
path = "/api/v1/events/stream"
upstream = "api"
mode = "sse_stream"
[[route]]
path = "/api/{*rest}"
upstream = "api"
waf_profile = "strict"
[[route]]
path = "/_next/static/{*rest}"
upstream = "frontend"
cache_profile = "immutable"
[[route]]
path = "/{*rest}"
upstream = "frontend"Path syntax
| Pattern | Matches | Example |
|---|---|---|
/exact | Exact path only | /metrics |
/prefix/{*rest} | Prefix + any suffix | /api/{*rest} matches /api/v1/users |
/{param} | Single path segment | /{id} matches /123 but not /a/b |
More specific routes take priority over wildcards. /api/v1/events/stream matches before /api/{*rest}.
Path normalization
The request path is normalized before anything decides from it (RFC 3986 §6.2.2), and the normalized form is what is routed, checked against internal_only / WAF / auth profiles, used as the cache key, and sent upstream:
%2e%2eand the like: percent-encodings of unreserved characters (A-Z a-z 0-9 - . _ ~) are decoded; every other escape keeps its bytes with upper-case hex (%2f→%2F). An encoded slash stays data, never a separator.- Dot segments are removed, never climbing above the root (
/a/b/../c→/a/c). - Runs of
/collapse to one (//a///b→/a/b). A trailing slash is kept. - The query string is left exactly as written.
- A path that one pass of this does not bring to a canonical form is refused with
400instead of being routed: the upstream decodes the path once more, and zion must not route one reading while the upstream serves another. Nothing a client writes correctly is affected.
Without this, /open/../internal/x matches /open/{*rest} while an upstream that resolves .. serves /internal/x, and //admin or /%61dmin slip past a /admin route. Every spelling of a path now meets the same route and the same policy, on :443 and on the plaintext :80 listener (whose ACME-challenge fallback routes on its own).
Headers that are not passed on from clients
Some request headers tell an application or a cache where a request "really" goes or came from. Frameworks that honour them (IIS and Symfony take the path from X-Original-URL / X-Rewrite-URL; others take the host, scheme or prefix from the rest) can be steered behind zion's routing, internal_only, WAF and auth, and a cache keyed on the real request can be poisoned. Zion drops these from any client that is not a configured trusted proxy: by a gate before routing on :443, and just before forwarding on the plaintext :80 listener's ACME fallback (which picks its route first and forwards on its own):
X-Original-URL, X-Rewrite-URL, Forwarded (zion sends X-Forwarded-*, never Forwarded), X-Forwarded-Server, X-Forwarded-Scheme, X-Forwarded-Prefix, X-Host, X-HTTP-Host-Override, X-Original-Host.
A peer listed in [server] trusted_proxies (a CDN or load balancer in front of zion) keeps its values, as it does for X-Forwarded-For.
Headers only zion may set
An upstream reads some headers as zion's word about the request. A copy sent by anyone else, a trusted proxy included, is dropped; zion then sets the real value, or none:
| Header | What it states | Set by |
|---|---|---|
X-Client-Cert-Fingerprint, X-Client-Cert-DN | the client certificate the TLS handshake verified | the HTTPS listener |
X-Client-TLS-JA4, X-Client-TLS-Allowlisted | the client's TLS fingerprint and whether it is on the allowlist | the HTTPS listener |
X-Auth-Subject, X-Auth-Email | the identity an auth_profile verified | the auth gate |
X-Zion-Mesh-Score | the mesh's reputation for the client IP | the mesh gate |
All of these and the list above come from one table in the code (src/reserved_headers.rs), and one test walks that table through the real HTTPS listener. X-Forwarded-Host is always overwritten with the request's own host (the Host header, or the URI authority for HTTP/2), and dropped when there is none. The upstream's Host is its own authority, or the client's host when the upstream sets preserve_host.
Host-based routing (virtual hosting)
Bind a route to one or more hosts to serve different backends for different domains on the same listener — the Host header (HTTP/1) or :authority (HTTP/2) selects the route. A route without hosts is shared: it matches every host and acts as a fallback.
# api.example.com → API backend
[[route]]
hosts = ["api.example.com"]
path = "/{*rest}"
upstream = "api"
# app.example.com and any *.example.com subdomain → frontend
[[route]]
hosts = ["app.example.com", "*.example.com"]
path = "/{*rest}"
upstream = "frontend"
# Shared: no `hosts`, reachable on every domain
[[route]]
path = "/status"
upstream = "api"
internal_only = trueThe same path now serves different backends per host — api.example.com/ and app.example.com/ route independently, which a path-only router cannot express.
Matching precedence
For a request authority, Zion resolves in this order:
- Exact host — matches a route bound to exactly that host.
- Wildcard —
*.example.commatches any subdomain (foo.example.com,a.b.example.com) but not the apexexample.com. The most specific wildcard wins (*.api.example.combefore*.example.com). - Shared — routes with no
hosts; also the fallback when the selected host tree has no matching path.
An exact host beats a wildcard, and a matched host never falls through to another host's routes — only to the shared layer.
Host normalization
Authorities are normalized before matching: lowercased, port stripped (api.example.com:8443 → api.example.com), and a trailing FQDN dot removed. Config hosts entries must be canonical bare hostnames — no scheme, path, port, or trailing dot (uppercase is folded). Only leading-label wildcards (*.example.com) are supported.
Relationship to TLS SNI
hosts (L7 routing) is independent of [[tls.sni]] (which certificate to present). SNI picks the cert during the TLS handshake; hosts picks the backend from the decrypted request. A direct-IP or unknown-SNI client still routes via hosts (or the shared layer) using the Host header.
Performance
Host routing is opt-in and zero-cost when unused: with no hosts anywhere, lookups skip authority extraction and behave exactly as the path-only router. When active, a lookup adds one hash-map probe plus authority normalization, and the thread-local route cache is keyed on (host, path) so different domains never share a cache slot.
Route modes
| Mode | Behavior |
|---|---|
standard | Standard reverse proxy with connection pooling |
sse_stream | Adds Cache-Control: no-cache and X-Accel-Buffering: no to response |
static_cache | Serves from in-memory cache on hit; fetches and caches on miss |
static | Serves files from disk under serve_dir (no upstream) behind a hardened path-safety core; GET/HEAD only. Emits a weak ETag + Last-Modified and answers If-None-Match / If-Modified-Since with 304 Not Modified; supports byte-range requests (206 Partial Content + Accept-Ranges: bytes, 416 when unsatisfiable); files above 64 MiB stream frame-by-frame with bounded memory (no size limit). spa_fallback = true serves index.html for an unmatched path; precompressed = true serves a .br/.gz sidecar (Brotli preferred) when the client's Accept-Encoding allows it, with Vary: Accept-Encoding. See ADR-0015 / ADR-0019 / ADR-0020 / ADR-0021 / ADR-0022. |
websocket | Explicit WebSocket mode (also auto-detected via Upgrade: websocket header on any route) |
Upstream resolution
Routes reference upstreams by name. Two formats are supported:
# Named upstream (recommended) -- with connection tuning
[upstream.api]
url = "http://127.0.0.1:8000"
connect_timeout_ms = 3000
keepalive = 64
# Legacy flat format -- URL only
[upstreams]
api = "http://127.0.0.1:8000"If both formats define the same name, the [upstream.<name>] format takes precedence.
At startup, all upstream URLs are pre-parsed into Scheme + Authority. No URI parsing occurs on the hot path.
Internal-only routes
[[route]]
path = "/internal/{*rest}"
upstream = "internal"
internal_only = trueWhen internal_only = true, requests from non-private IPs receive 403 Forbidden. Private IPs include:
127.0.0.0/8(loopback)10.0.0.0/8,172.16.0.0/12,192.168.0.0/16(RFC 1918)169.254.0.0/16(link-local)::1(IPv6 loopback)
Content-Security-Policy (per-route)
You can set a per-route Content-Security-Policy header:
[[route]]
path = "/admin/{*rest}"
upstream = "api"
csp = "default-src 'self'; script-src 'self'"
[[route]]
path = "/{*rest}"
upstream = "frontend"
# No csp — frontend controls its own CSP via response headers| Behavior | Description |
|---|---|
csp set | Zion injects the Content-Security-Policy header, overriding any upstream CSP |
csp not set | Upstream's CSP header (if any) passes through unchanged |
The CSP string is pre-parsed into a HeaderValue at startup — zero cost on the hot path (just a header clone).
When to use
- Admin panels / internal tools: Lock down with a strict CSP (
default-src 'self') - SPA frontends: Leave unset and let the frontend's own CSP pass through
- APIs: Usually not needed (no HTML rendering)
Startup validation
Zion validates all routes at boot:
- Every route must reference a defined upstream
- Every
waf_profilereference must point to an existing[waf_profile.<name>] - Every
cache_profilereference must point to an existing[cache_profile.<name>] - All upstream URLs must be valid URIs
- Path patterns must be valid radix tree patterns
- Every
hostsentry must be a canonical bare hostname or a*.<domain>wildcard
If validation fails, Zion prints all errors and exits with code 2 (the config-error category — see Exit codes).