eggrd/ docs

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.

MethodPathAuthPurpose
GET/__edgeguard/healthnoneLiveness. Always 200 ok while the process runs; it consults nothing.
GET/__edgeguard/readynoneReadiness. 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/metricsnonePrometheus scrape, text exposition 0.0.4. Every series is listed in Operations.
POST/__edgeguard/csp-reportnoneCSP 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:

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

HeaderValue
X-Forwarded-ForThe 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-Protohttps when eggrd terminates TLS. Otherwise the trusted edge's value, and only with trust_forwarded_for; else http.
X-Request-IdThe 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.
AuthorizationReplaced 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:

HeaderBehaviour
X-Content-Type-Optionsnosniff.
X-Frame-OptionsSet from config.
Referrer-PolicySet from config.
Permissions-PolicySet from config.
Content-Security-PolicyOr -Report-Only, with violations collectable at the CSP sink above.
Strict-Transport-SecuritySet from config. A browser ignores this on a plain-HTTP response, so doctor flags HSTS with TLS off.
Set-CookieValues gain Secure; HttpOnly; SameSite=Lax where missing, under force_secure_cookies.
headers.stripRemoved 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.

StatusStageoutcomeTrigger
404namespace guardnot_foundAn unknown /__edgeguard/* path, including the ops trio on the public port in split mode.
403IP accessip_deniedClient IP denied, or off the [access] allow-list.
431header capheader_too_largeHeader bytes exceed validation.max_header_bytes.
429managed quotaover_quotacontrol_plane.enforce_quota is on and the control plane reports over quota. Carries Retry-After.
429rate limitrate_limitedA per-IP or per-route limit tripped. The scope is on edgeguard_ratelimit_hits_total.
503rate limitlimiter_errorThe shared limiter store errored while ratelimit.fail_open = false. Fails closed.
204CORScors_preflightA browser preflight, answered at the edge before auth.
401authunauthorizedThe basic, API-key or JWT gate failed. Carries a WWW-Authenticate challenge.
429per-key limitrate_limitedThe authenticated principal is over ratelimit.per_key.
405method allow-listmethod_not_allowedMethod not in validation.allow_methods.
401LLM keyvaultunauthorized[[llm.keys]] is configured and no known virtual key was presented. Also applies to WebSocket upgrades.
403LLM keyvaultforbiddenThe 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.
502LLM keyvaultbad_gatewayThe mapped provider key is not a legal header value.
413body cappayload_too_largeRequest body exceeds validation.max_body.
403WAFforbiddenwaf.mode = "block" and a rule matched. Report mode forwards instead; both count in edgeguard_waf_hits_total.
403DLP inboundforbiddenllm.dlp.mode = "block" and the prompt carried PII or secrets. The secret never leaves.
402LLM pricingunpriced_modelllm.on_unpriced_model = "block" and the model is not in [llm.models]. Body: Payment Required: model not in price book.
402LLM budgetover_budgetA unit = "usd" budget denied the reserve. Body: Payment Required: budget exhausted.
429LLM budgetover_budgetA unit = "tokens" budget denied the reserve.
503LLM budgetlimiter_errorThe budget store errored while llm.fail_open = false. Fails closed.
502forwardupstream_errorUpstream 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.
504forwardupstream_timeoutvalidation.upstream_timeout elapsed, on connect, first byte, or the body read on the buffered path.
403DLP outboundokThe 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.
101WS tunnelws_upgradeThe 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.