eggrd 0.2.0

A drop-in Rust edge proxy that gives any app a secure front door: auth, rate limiting, and hardened response headers, with zero changes to the upstream app.
Documentation
# EdgeGuard configuration (TOML).
#
# Everything here is OPTIONAL — EdgeGuard runs with secure defaults and reads
# PORT / APP_PORT / UPSTREAM from the environment. This file layers richer policy
# on top. Env vars (PORT, APP_PORT, UPSTREAM) override the [server] values below.

[server]
# Public port EdgeGuard listens on. Usually injected by the platform as $PORT.
port = 8080
# Internal port your app listens on (EdgeGuard sets PORT=<app_port> for the child).
app_port = 3000
# Or point at an external upstream instead of a wrapped child (separate-service mode):
# upstream = "http://app.internal:3000"
# EdgeGuard fails closed: an unreachable upstream returns 502 (it never serves forged or
# stale content). A configurable fail-open mode returns alongside the distributed limiter
# (see docs/ROADMAP.md, Phase 4), where it actually has a backing store that can fail.
# Trust X-Forwarded-For for the client IP. Enable ONLY behind a trusted proxy/LB
# (e.g. a PaaS edge); otherwise a directly reachable client can spoof its IP and
# defeat per-IP rate limiting. Default false => use the real peer address.
# This also governs X-Forwarded-Proto sent upstream: if EdgeGuard terminates TLS ([tls]
# below) it forwards "https"; otherwise, when this is true the edge's incoming
# X-Forwarded-Proto is preserved, and when false a plain-HTTP hop forwards "http".
trust_forwarded_for = false
# Public/private split (optional). When admin_port is non-zero, EdgeGuard serves the internal
# ops endpoints (/__edgeguard/health, /ready, /metrics) on a SECOND, plain-HTTP listener at
# admin_addr:admin_port instead of the public port — so metrics/health aren't exposed publicly.
# The public port then serves only the proxy + the browser-facing CSP sink. Point your platform
# health check at admin_port when enabled, and keep it on a trusted network (it has no auth).
admin_port = 0            # 0 = keep internal endpoints on the public port (default)
admin_addr = "127.0.0.1"  # loopback (same-host scraper); "0.0.0.0" for a private interface

# --- Per-path upstream overrides (optional; single upstream by default) ---
# Everything goes to server.upstream/app_port unless a prefix below matches. The common use is a
# static frontend + an /api backend. Longest matching prefix wins; no match falls back to the
# default upstream. This is a STATIC PREFIX MAP, not a gateway — no service discovery, load
# balancing, or request rewriting (the path is forwarded unchanged).
# [[upstreams]]
# path = "/api/"
# target = "http://api.internal:4000"
# [[upstreams]]
# path = "/auth/"
# target = "http://auth.internal:5000"

[auth]
# Gate applied to every proxied request (the internal /__edgeguard/* endpoints are exempt):
#   "none"   — no authentication
#   "basic"  — HTTP Basic against [auth].users
#   "apikey" — a static API key / bearer token from [auth].api_keys
#   "jwt"    — a verified JWT bearer token per [auth.jwt]
mode = "basic"
realm = "EdgeGuard"
# username -> password. A value starting with "$argon2" is verified as a PHC hash; anything
# else is treated as a LITERAL plaintext password (dev only). The placeholder below is an
# intentionally invalid "$argon2" string, so the shipped config can NEVER authenticate anyone
# — REPLACE it before exposing anything. Generate a real hash with the built-in helper:
#   echo -n 'your-password' | edgeguard --hash
# (or any argon2 PHC tool, e.g. `echo -n 'pw' | argon2 "$(openssl rand -base64 16)" -id -e`)
users = { admin = "$argon2id$REPLACE_ME$run-edgeguard---hash-to-generate-a-real-value" }

# --- API-key gate (mode = "apikey") ---
# Keys are compared in constant time and may be presented as `Authorization: Bearer <key>`
# or in the `api_key_header`. Prefer the EDGEGUARD_API_KEYS env var (comma-separated) over
# committing keys to this file.
# api_keys = ["sk_live_xxx", "sk_live_yyy"]
# api_key_header = "X-API-Key"

# --- JWT gate (mode = "jwt") ---
[auth.jwt]
# Expected algorithm; the token's own `alg` must match (no alg-substitution/`alg=none`).
algorithm = "HS256"   # HS256/384/512, RS256/384/512, PS*, ES256/384, EdDSA
# HS*: shared secret. Prefer EDGEGUARD_JWT_SECRET over this file.
secret = ""
# RS*/ES*/PS*: a static PEM public key, OR a JWKS endpoint (keys cached + selected by `kid`).
public_key_pem = ""
jwks_url = ""
jwks_cache_secs = 300
# Optional claim checks (empty = not enforced) and clock-skew leeway.
issuer = ""
audience = ""
leeway_secs = 60

[ratelimit]
enabled = true
rate = "60/min"   # default per-client-IP limit; supports N/sec, N/min, N/hour
burst = 20
# Where limiter state lives. "local" (default) is the fast in-process governor limiter, but it
# counts PER REPLICA — N instances behind a load balancer allow N× the limit. For multi-replica
# deployments use "redis" to share one global GCRA limit across instances. "memory" uses the
# same shared-store code path with an in-process map (single-replica / testing). Same rate/burst
# /routes/per_key settings apply regardless of store.
store = "local"   # "local" | "redis" | "memory"
# Redis connection for store = "redis" (redis:// or, for TLS, rediss://). Prefer the
# EDGEGUARD_REDIS_URL env var over committing it here.
redis_url = "redis://127.0.0.1:6379"
redis_prefix = "edgeguard"   # key namespace, so multiple deployments can share one Redis
# On a store error: false (default) fails CLOSED (503, so an outage can't silently disable rate
# limiting); true fails OPEN (allow the request — availability over strict limiting).
fail_open = false
# Per-route overrides (longest matching path prefix wins; still keyed per client IP).
# A matching route's limit REPLACES the global one for that path.
# [[ratelimit.routes]]
# path = "/api/"
# rate = "10/sec"
# burst = 20
# [[ratelimit.routes]]
# path = "/api/admin/"
# rate = "1/sec"
# burst = 5

# Extra limit keyed by the authenticated principal (API-key id / JWT `sub`) rather than IP,
# so one credential can't fan out across many IPs. Only applies to authenticated requests.
[ratelimit.per_key]
enabled = false
rate = "1000/hour"
burst = 100

[validation]
max_body = "2MiB"
# Cap on the upstream response body EdgeGuard buffers ("0" = unbounded). Set a limit
# (e.g. "16MiB") to stop a huge upstream response from OOM-ing the proxy; raise it if
# you proxy large downloads.
max_response_body = "0"
# Max time to wait for the upstream response and to read its body, e.g. "30s", "500ms",
# "2m". "0" disables it. A stalled upstream returns 504 instead of pinning the request.
upstream_timeout = "30s"
# Cap on total request header bytes (sum of name + value), e.g. "32KiB". "0" disables it.
# Requests over the limit get 431. A policy limit on top of hyper's transport-level cap.
max_header_bytes = "0"
# Empty list = allow all methods.
allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
# Forward `text/event-stream` (SSE) responses unbuffered, frame-by-frame as they arrive,
# instead of buffering the whole body first. Off by default (the proxy buffers so it can apply
# `max_response_body` and count exact bytes). Turn on for Server-Sent Events / streaming
# backends so clients see events immediately (time-to-first-byte is preserved). When a response
# streams this way, `max_response_body` and the body-read timeout don't apply (the
# connect/first-byte `upstream_timeout` still does). Non-SSE responses are unaffected.
stream_passthrough = false
# Tunnel WebSocket (and other `Upgrade`) connections to the upstream. Off by default: the normal
# path strips the hop-by-hop Upgrade/Connection headers, so a WebSocket handshake would be
# forwarded as a plain HTTP request and fail. Turn this on to proxy WS apps (chat, live
# dashboards, dev HMR): an authenticated, rate-limited upgrade request is forwarded intact and,
# on the upstream's 101, EdgeGuard splices the two connections into a raw bidirectional tunnel.
# Response hardening / WAF body inspection don't apply to a tunneled connection.
websocket_passthrough = false
# gzip-compress responses for clients that send `Accept-Encoding: gzip`. Off by default. Skips
# already-compressed content types and (always) `text/event-stream`, so SSE streaming is never
# held back by the compressor. Applied at the listener, so toggling it needs a restart.
compress_responses = false

[headers]
hsts = true
csp = "default-src 'self'"
# Send the CSP as Content-Security-Policy-Report-Only (collect violations without enforcing).
csp_report_only = false
# If set, a `report-uri <value>` directive is appended to the CSP. Point it at EdgeGuard's own
# sink to have reports logged + counted, or at any external collector.
# csp_report_uri = "/__edgeguard/csp-report"
referrer_policy = "no-referrer"
permissions_policy = "geolocation=(), microphone=(), camera=()"
frame_options = "DENY"
force_secure_cookies = true
strip = ["Server", "X-Powered-By"]

# --- WAF-lite input inspection (optional; OFF by default) ---
# Screens requests for common attack signatures before forwarding. These are heuristics, so it
# ships off and report-first: run "report" to log + count matches (edgeguard_waf_hits_total)
# without blocking, watch for false positives, then switch to "block" (returns 403). It runs
# after auth and the size/method checks; the internal /__edgeguard/* endpoints are never seen.
[waf]
mode = "off"            # "off" | "report" | "block"
sqli = true             # built-in SQL-injection heuristics
xss = true              # built-in cross-site-scripting heuristics
path_traversal = true   # built-in path-traversal heuristics
inspect_path = true     # inspect the URL path + query (matched raw AND percent-decoded)
inspect_headers = false # inspect header values (noisy: cookies/tokens) — opt in
inspect_body = false    # inspect the (size-capped) request body — opt in
# Operator-defined deny patterns, matched in addition to the built-ins. `pattern` is an RE2
# regex (linear-time, ReDoS-safe — no backreferences/lookaround). `target` is one of "path"
# (default), "headers", "body", "all"; a location is only inspected when its inspect_* flag
# above is on. A pattern that fails to compile is rejected on load (or kept-previous on reload).
# [[waf.rules]]
# id = "block-wp-probes"
# pattern = "(?i)/wp-(admin|login)"
# target = "path"

# --- IP access control (optional; allow-all by default) ---
# Coarse network gate by client IP, evaluated before auth and rate limiting. Both lists accept
# plain IPs and CIDR ranges (IPv4 and IPv6). `deny` wins over `allow`; a non-empty `allow` means
# "only these may connect". Both empty (the default) = allow all. A bad entry fails at startup.
# NB: keys on the resolved client IP — behind a trusted proxy/LB, set server.trust_forwarded_for
# so this sees the real client rather than the proxy.
[access]
allow = []   # e.g. ["10.0.0.0/8", "203.0.113.7"]  — lock the app to an office/VPN range
deny = []    # e.g. ["198.51.100.0/24"]            — drop an abusive subnet (wins over allow)

# --- CORS (optional; OFF by default) ---
# Cross-Origin Resource Sharing. Turn this on when the app's browser frontend is served from a
# DIFFERENT origin than EdgeGuard (a separate static host, a preview URL, localhost:5173 in dev) —
# otherwise the browser blocks those cross-origin fetch/XHR calls. EdgeGuard answers preflight
# OPTIONS requests itself (before auth — a preflight carries no credentials) and adds the matching
# Access-Control-* headers to actual responses.
[cors]
enabled = false
# Allowed origins, matched exactly (scheme + host + port). The single entry ["*"] allows any
# origin, but a wildcard CANNOT be combined with allow_credentials = true (rejected at startup).
allow_origins = []                # e.g. ["https://app.example.com", "http://localhost:5173"]
# Methods advertised in the preflight. [] = a sensible default (GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD).
allow_methods = []
# Request headers advertised in Access-Control-Allow-Headers. [] = reflect what the browser asks
# for in Access-Control-Request-Headers (the common, permissive default).
allow_headers = []
# Response headers the browser page is allowed to read. [] = only the CORS-safelisted set.
expose_headers = []
# Send Access-Control-Allow-Credentials: true so the browser may include cookies / HTTP auth.
# Requires an explicit allow_origins list (no "*").
allow_credentials = false
# How long the browser may cache the preflight result. "0" omits Access-Control-Max-Age.
max_age = "600s"

# --- TLS termination (optional) ---
# When enabled, EdgeGuard serves HTTPS on the public port. Provide a certificate via
# cert_path/key_path, or have ACME obtain one automatically (see [tls.acme]).
[tls]
enabled = false
cert_path = ""   # PEM certificate chain (leaf first)
key_path = ""    # PEM private key (PKCS#8/PKCS#1/SEC1)

# Automatic certificates via ACME / Let's Encrypt (HTTP-01 challenge on port 80). The issued
# certificate is written to the cert_path/key_path above. NOTE: requires a public domain and
# inbound :80; the default directory is Let's Encrypt STAGING so a first run can't burn the
# strict production rate limits — switch to production explicitly when ready.
[tls.acme]
enabled = false
domains = []          # e.g. ["app.example.com"]
email = ""            # ACME account contact
directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
cache_dir = "./acme"  # stores the ACME account key for renewals
accept_tos = false    # must be true to accept the provider's Terms of Service

# ── Managed mode (optional) ───────────────────────────────────────────────────────────────
# Pull this edge's policy from a remote control plane and report operational metrics to it. OFF
# by default — leave disabled to run as a standalone proxy. When enabled, the control plane pushes
# the policy sections (auth/ratelimit/validation/headers/waf); this edge keeps its own [server]/[tls].
# The edge token is a secret — prefer the EDGEGUARD_CP_EDGE_TOKEN env var over this file.
[control_plane]
enabled = false
url = ""               # control-plane base URL, e.g. "https://cp.example"
tenant_id = ""         # this edge's id at the control plane
edge_token = ""        # edge bearer token (prefer EDGEGUARD_CP_EDGE_TOKEN)
poll_interval = "30s"  # how often to pull policy
report_interval = "60s" # how often to flush a metrics delta
forward_csp = true     # forward received CSP reports to the control plane
# Enforce the configured quota as a 429 hard-stop (turns the rate signal into a hard cap). OFF by
# default. When on, the edge polls the control plane's quota verdict and rejects traffic with
# 429 + Retry-After while the edge is over its quota; the internal /__edgeguard/* endpoints stay
# served. Prefer EDGEGUARD_CP_QUOTA_ENFORCE.
enforce_quota = false
quota_poll_interval = "30s"  # how often to poll the quota verdict