1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# 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.
[]
# Public port EdgeGuard listens on. Usually injected by the platform as $PORT.
= 8080
# Internal port your app listens on (EdgeGuard sets PORT=<app_port> for the child).
= 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".
= 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).
= 0 # 0 = keep internal endpoints on the public port (default)
= "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"
[]
# 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]
= "basic"
= "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`)
= { = "$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") ---
[]
# Expected algorithm; the token's own `alg` must match (no alg-substitution/`alg=none`).
= "HS256" # HS256/384/512, RS256/384/512, PS*, ES256/384, EdDSA
# HS*: shared secret. Prefer EDGEGUARD_JWT_SECRET over this file.
= ""
# RS*/ES*/PS*: a static PEM public key, OR a JWKS endpoint (keys cached + selected by `kid`).
= ""
= ""
= 300
# Optional claim checks (empty = not enforced) and clock-skew leeway.
= ""
= ""
= 60
[]
= true
= "60/min" # default per-client-IP limit; supports N/sec, N/min, N/hour
= 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.
= "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://127.0.0.1:6379"
= "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).
= 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.
[]
= false
= "1000/hour"
= 100
[]
= "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.
= "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.
= "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.
= "0"
# Empty list = allow all 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.
= 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.
= 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.
= false
[]
= true
= "default-src 'self'"
# Send the CSP as Content-Security-Policy-Report-Only (collect violations without enforcing).
= 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"
= "no-referrer"
= "geolocation=(), microphone=(), camera=()"
= "DENY"
= true
= ["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.
[]
= "off" # "off" | "report" | "block"
= true # built-in SQL-injection heuristics
= true # built-in cross-site-scripting heuristics
= true # built-in path-traversal heuristics
= true # inspect the URL path + query (matched raw AND percent-decoded)
= false # inspect header values (noisy: cookies/tokens) — opt in
= 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.
[]
= [] # e.g. ["10.0.0.0/8", "203.0.113.7"] — lock the app to an office/VPN range
= [] # 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.
[]
= 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).
= [] # e.g. ["https://app.example.com", "http://localhost:5173"]
# Methods advertised in the preflight. [] = a sensible default (GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD).
= []
# Request headers advertised in Access-Control-Allow-Headers. [] = reflect what the browser asks
# for in Access-Control-Request-Headers (the common, permissive default).
= []
# Response headers the browser page is allowed to read. [] = only the CORS-safelisted set.
= []
# Send Access-Control-Allow-Credentials: true so the browser may include cookies / HTTP auth.
# Requires an explicit allow_origins list (no "*").
= false
# How long the browser may cache the preflight result. "0" omits Access-Control-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]).
[]
= false
= "" # PEM certificate chain (leaf first)
= "" # 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.
[]
= false
= [] # e.g. ["app.example.com"]
= "" # ACME account contact
= "https://acme-staging-v02.api.letsencrypt.org/directory"
= "./acme" # stores the ACME account key for renewals
= 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.
[]
= false
= "" # control-plane base URL, e.g. "https://cp.example"
= "" # this edge's id at the control plane
= "" # edge bearer token (prefer EDGEGUARD_CP_EDGE_TOKEN)
= "30s" # how often to pull policy
= "60s" # how often to flush a metrics delta
= 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.
= false
= "30s" # how often to poll the quota verdict