API & data-plane contract
eggrd adds no application endpoints of its own. Your API is whatever you put behind it, forwarded unchanged. What this page documents is the contract the proxy imposes on that traffic: the one namespace it reserves, the headers it adds in each direction, and every status code it can generate instead of your app.
Anything not listed under status codes is your upstream's own response, passed through.
Internal endpoints
/__edgeguard/* is reserved. It is never forwarded upstream,
and these routes are exempt from auth, rate limiting, the WAF and IP access control — so
that a liveness probe still answers when the gate in front of it is refusing everything.
Restrict them at the network layer, or move the ops trio to a private listener with
server.admin_port.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /__edgeguard/health | none | Liveness. Always 200 ok while the process runs; it consults nothing. |
| GET | /__edgeguard/ready | none | Readiness. 200 only when the default upstream accepts a TCP connection within a 2 s probe, otherwise 503. Per-path [[upstreams]] targets are not probed. |
| GET | /__edgeguard/metrics | none | Prometheus scrape, text exposition 0.0.4. Every series is listed in Operations. |
| POST | /__edgeguard/csp-report | none | CSP violation sink. Counts and logs the violated directive at debug, never the full report, and returns 204. The body is capped at 64 KiB; larger is 413 from the body-limit layer. |
Any other /__edgeguard/… path is 404 from eggrd with outcome
not_found. It is never a leak through to the app.
Public/private split
With server.admin_port set, the ops trio moves off the public port entirely:
- Admin listener (
admin_addr:admin_port, plain HTTP, no auth): health, ready and metrics. No proxy fallback — an unknown path is404. - Public listener: the proxy, plus
/__edgeguard/csp-reportonly, because a browser has to be able to reach the sink. The ops trio on the public port becomes404.
The admin listener carries no authentication by design. Bind it to loopback or a private network; do not publish it.
Headers eggrd adds to the upstream request
| Header | Value |
|---|---|
X-Forwarded-For | The resolved client IP: the peer address, or the first X-Forwarded-For hop when server.trust_forwarded_for = true. Any inbound value is replaced rather than appended, so a client cannot forge its own origin. |
X-Forwarded-Proto | https when eggrd terminates TLS. Otherwise the trusted edge's value, and only with trust_forwarded_for; else http. |
X-Request-Id | The inbound id when it is well formed — 128 printable ASCII characters or fewer — otherwise a fresh UUID v4. The same id is stamped on the response. |
Authorization | Replaced with Bearer <provider_key> when the LLM keyvault resolves a virtual key, so the real provider credential never leaves eggrd. |
Hop-by-hop headers — the RFC 7230 §6.1 set, plus anything named in
Connection — and Host are stripped, and
Content-Length is recomputed. With websocket_passthrough an
upgrade request keeps its Connection: upgrade and Upgrade headers.
Headers eggrd adds or rewrites on the response
Every response — proxied and eggrd-generated errors alike — carries
X-Request-Id, and the Access-Control-* decoration when
[cors] is on and the request carried an allowed Origin.
Proxied responses additionally get the [headers] hardening:
| Header | Behaviour |
|---|---|
X-Content-Type-Options | nosniff. |
X-Frame-Options | Set from config. |
Referrer-Policy | Set from config. |
Permissions-Policy | Set from config. |
Content-Security-Policy | Or -Report-Only, with violations collectable at the CSP sink above. |
Strict-Transport-Security | Set from config. A browser ignores this on a plain-HTTP response, so doctor flags HSTS with TLS off. |
Set-Cookie | Values gain Secure; HttpOnly; SameSite=Lax where missing, under force_secure_cookies. |
headers.strip | Removed outright. Defaults to Server and X-Powered-By. |
A tunneled WebSocket connection is not hardened: there is no buffered response to stamp.
Status codes eggrd generates itself
The proxy pipeline in evaluation order. outcome is the label on the JSON
access log and on edgeguard_requests_total{outcome=…}; the two line
up one to one, so a spike on a dashboard names the exact stage that produced it.
| Status | Stage | outcome | Trigger |
|---|---|---|---|
404 | namespace guard | not_found | An unknown /__edgeguard/* path, including the ops trio on the public port in split mode. |
403 | IP access | ip_denied | Client IP denied, or off the [access] allow-list. |
431 | header cap | header_too_large | Header bytes exceed validation.max_header_bytes. |
429 | managed quota | over_quota | control_plane.enforce_quota is on and the control plane reports over quota. Carries Retry-After. |
429 | rate limit | rate_limited | A per-IP or per-route limit tripped. The scope is on edgeguard_ratelimit_hits_total. |
503 | rate limit | limiter_error | The shared limiter store errored while ratelimit.fail_open = false. Fails closed. |
204 | CORS | cors_preflight | A browser preflight, answered at the edge before auth. |
401 | auth | unauthorized | The basic, API-key or JWT gate failed. Carries a WWW-Authenticate challenge. |
429 | per-key limit | rate_limited | The authenticated principal is over ratelimit.per_key. |
405 | method allow-list | method_not_allowed | Method not in validation.allow_methods. |
401 | LLM keyvault | unauthorized | [[llm.keys]] is configured and no known virtual key was presented. Also applies to WebSocket upgrades. |
403 | LLM keyvault | forbidden | The requested model is off the key's egress allow-list; or a model-restricted key arrived on a WebSocket upgrade, where the model cannot be verified, so it fails closed. |
502 | LLM keyvault | bad_gateway | The mapped provider key is not a legal header value. |
413 | body cap | payload_too_large | Request body exceeds validation.max_body. |
403 | WAF | forbidden | waf.mode = "block" and a rule matched. Report mode forwards instead; both count in edgeguard_waf_hits_total. |
403 | DLP inbound | forbidden | llm.dlp.mode = "block" and the prompt carried PII or secrets. The secret never leaves. |
402 | LLM pricing | unpriced_model | llm.on_unpriced_model = "block" and the model is not in [llm.models]. Body: Payment Required: model not in price book. |
402 | LLM budget | over_budget | A unit = "usd" budget denied the reserve. Body: Payment Required: budget exhausted. |
429 | LLM budget | over_budget | A unit = "tokens" budget denied the reserve. |
503 | LLM budget | limiter_error | The budget store errored while llm.fail_open = false. Fails closed. |
502 | forward | upstream_error | Upstream unreachable, the request build failed, or its body failed mid-read. Related outcomes: bad_gateway, upstream_body_error, and upstream_body_too_large when a buffered response exceeded validation.max_response_body. |
504 | forward | upstream_timeout | validation.upstream_timeout elapsed, on connect, first byte, or the body read on the buffered path. |
403 | DLP outbound | ok | The completion carried PII or secrets under llm.dlp.mode = "block": the status is rewritten and the body withheld, as {"error":"response withheld by DLP policy"}. Counted under edgeguard_llm_dlp_blocked_total; the request outcome stays ok because the upstream call itself succeeded. |
101 | WS tunnel | ws_upgrade | The upstream accepted the upgrade and the connections are spliced. |
Error bodies are one-line text/plain; charset=utf-8, apart from the DLP JSON
above. No HTML error pages and no stack traces.
Where each gate sits, and why it matters
The order in that table is the order of evaluation, and the position of the forward row is the whole point: every refusal above it happens before your upstream is contacted. A request that is denied costs your app nothing, and on the LLM lane it costs the provider nothing, because the call was never placed.
That used to be an assertion in a comment. Since 0.4.0 it is a test: a counting stub stands in for the upstream and each denial path asserts it saw exactly zero requests — unauthenticated, WAF block, IP denied, oversized body, rate limited, limiter outage, over budget, DLP block, off-allow-list model and budget-store outage. A control test asserts an admitted request reaches it exactly once, so a counter that never incremented could not make the rest pass vacuously.
Written by hand from the routers in src/lib.rs and the pipeline in
src/proxy.rs, and checked against the shipped binary. The
configuration reference is generated from the source instead,
because it is far too large to keep honest any other way. Something wrong here is a bug
worth reporting.