edgeguard/config.rs
1//! Configuration. Env-first so EdgeGuard drops into any PaaS that injects `$PORT`
2//! with zero edits; an optional TOML file layers richer policy on top.
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::env;
8use std::time::Duration;
9
10#[derive(Debug, Clone, Default, Deserialize)]
11#[serde(default)]
12pub struct Config {
13 pub server: ServerCfg,
14 pub auth: AuthCfg,
15 pub ratelimit: RateLimitCfg,
16 pub validation: ValidationCfg,
17 pub headers: HeadersCfg,
18 pub tls: TlsCfg,
19 pub waf: WafCfg,
20 /// Optional per-path-prefix upstream overrides. Empty by default (everything goes to the
21 /// single `server.upstream`/`app_port`). A common use: `/api` → a backend, everything else →
22 /// a static frontend. Longest matching prefix wins; no match falls back to the default
23 /// upstream. This is a static prefix map, not a service mesh — see [`UpstreamRoute`].
24 pub upstreams: Vec<UpstreamRoute>,
25 /// IP allow/deny lists (CIDR). Empty by default (allow all); when set, requests are gated by
26 /// client IP before auth/rate-limit. See [`AccessCfg`].
27 pub access: AccessCfg,
28 /// Cross-Origin Resource Sharing policy. Off by default; when enabled, EdgeGuard answers
29 /// browser preflights and decorates responses so a separate-origin frontend can call the
30 /// app it fronts. See [`CorsCfg`].
31 pub cors: CorsCfg,
32 /// Optional "managed mode": pull policy from / report metrics to a remote control plane. Off
33 /// by default; the edge is a standalone proxy unless this is configured.
34 pub control_plane: ControlPlaneCfg,
35}
36
37/// Managed-mode settings: when `enabled`, the edge pulls its policy from a remote control plane
38/// (and hot-reloads it), reports metric deltas, and forwards CSP reports. The policy the control
39/// plane pushes is the *policy subset* (auth/ratelimit/validation/headers/waf) — the edge keeps
40/// its own local `server`/`tls`. The edge token is a secret, so prefer `EDGEGUARD_CP_EDGE_TOKEN`.
41#[derive(Debug, Clone, Deserialize)]
42#[serde(default)]
43pub struct ControlPlaneCfg {
44 pub enabled: bool,
45 /// Base URL of the control plane, e.g. `https://cp.example`.
46 pub url: String,
47 /// This edge's tenant id at the control plane.
48 pub tenant_id: String,
49 /// Per-tenant edge token (Bearer). Prefer `EDGEGUARD_CP_EDGE_TOKEN`.
50 pub edge_token: String,
51 /// How often to poll for policy, e.g. `"30s"`.
52 pub poll_interval: String,
53 /// How often to flush a metrics delta, e.g. `"60s"`.
54 pub report_interval: String,
55 /// Forward received CSP reports to the control plane (default true).
56 pub forward_csp: bool,
57 /// Enforce the configured quota as a **hard stop**: poll the control plane's
58 /// `/v3/edge/{id}/quota` and, while the edge is over its quota, reject the edge's
59 /// traffic with `429` (a `Retry-After` reset hint). Off by default — opt in to turn the
60 /// rate signal into a hard cap. Prefer `EDGEGUARD_CP_QUOTA_ENFORCE`.
61 pub enforce_quota: bool,
62 /// How often to poll the quota verdict, e.g. `"30s"`. A failed poll keeps the last verdict, so
63 /// a control-plane blip neither over- nor under-enforces.
64 pub quota_poll_interval: String,
65}
66
67impl Default for ControlPlaneCfg {
68 fn default() -> Self {
69 ControlPlaneCfg {
70 enabled: false,
71 url: String::new(),
72 tenant_id: String::new(),
73 edge_token: String::new(),
74 poll_interval: "30s".into(),
75 report_interval: "60s".into(),
76 forward_csp: true,
77 enforce_quota: false,
78 quota_poll_interval: "30s".into(),
79 }
80 }
81}
82
83#[derive(Debug, Clone, Deserialize)]
84#[serde(default)]
85pub struct ServerCfg {
86 /// Public listen port. Overridden by the `PORT` env var.
87 pub port: u16,
88 /// Internal port the wrapped/upstream app listens on. Overridden by `APP_PORT`.
89 pub app_port: u16,
90 /// Full upstream base URL. Overridden by `UPSTREAM`. If empty, derived from app_port.
91 pub upstream: String,
92 /// Trust the `X-Forwarded-For` header for client identity. Enable ONLY when
93 /// EdgeGuard sits behind a trusted proxy/load balancer that sets it (e.g. a PaaS
94 /// edge). When false (default) the peer socket address is used, so clients can't
95 /// spoof their IP to defeat per-IP rate limiting or forge access-log entries.
96 pub trust_forwarded_for: bool,
97 /// Private listener port for the internal `/__edgeguard/*` ops endpoints (health,
98 /// readiness, metrics). `0` (default) keeps them on the public port. When non-zero,
99 /// EdgeGuard binds a second, plain-HTTP listener on `admin_addr:admin_port` that serves
100 /// those endpoints, and the public port serves only the proxy (plus the browser-facing CSP
101 /// report sink) — so metrics/health aren't exposed on the internet. Overridden by
102 /// `ADMIN_PORT`. (Point your platform's health check at this port when you enable it.)
103 pub admin_port: u16,
104 /// Address the private admin listener binds when `admin_port` is set. Defaults to
105 /// `127.0.0.1` (same-host only — e.g. a sidecar scraper); set to `0.0.0.0` to expose it on
106 /// a private network interface (rely on your network policy to keep it off the internet).
107 pub admin_addr: String,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111#[serde(default)]
112pub struct AuthCfg {
113 /// "none" | "basic" | "apikey" | "jwt". Selects the gate applied to every proxied
114 /// request; the internal `/__edgeguard/*` endpoints are always exempt.
115 pub mode: String,
116 pub realm: String,
117 /// username -> password. Value may be plaintext (dev) or a `$argon2...` PHC hash.
118 /// Used when `mode = "basic"`.
119 pub users: BTreeMap<String, String>,
120 /// Accepted API keys (compared in constant time). Used when `mode = "apikey"`. A request
121 /// may present a key either as `Authorization: Bearer <key>` or in `api_key_header`.
122 /// Overridable from the env via `EDGEGUARD_API_KEYS` (comma-separated) so keys need not
123 /// live in the config file.
124 pub api_keys: Vec<String>,
125 /// Header carrying the API key (in addition to `Authorization: Bearer`), default
126 /// `X-API-Key`. Used when `mode = "apikey"`.
127 pub api_key_header: String,
128 /// JWT verification policy. Used when `mode = "jwt"`.
129 pub jwt: JwtCfg,
130}
131
132/// JWT bearer-token verification. Either a symmetric `secret` (HS*) or an asymmetric key
133/// (RS*/ES*/PS*) supplied as a static `public_key_pem` or fetched from `jwks_url`.
134#[derive(Debug, Clone, Deserialize)]
135#[serde(default)]
136pub struct JwtCfg {
137 /// Expected signature algorithm, e.g. "HS256", "RS256", "ES256". The token's own `alg`
138 /// header must match this (we never trust the token to pick its own algorithm — that is
139 /// the classic JWT downgrade/`alg=none` foot-gun).
140 pub algorithm: String,
141 /// Shared secret for HS* algorithms. Prefer the `EDGEGUARD_JWT_SECRET` env var over
142 /// putting it in the config file.
143 pub secret: String,
144 /// Static PEM public key (SPKI or PKCS#1) for RS*/ES*/PS* verification, as an
145 /// alternative to `jwks_url`.
146 pub public_key_pem: String,
147 /// JWKS endpoint to fetch verification keys from (RS*/ES*/PS*). Keys are cached and
148 /// selected by the token's `kid`.
149 pub jwks_url: String,
150 /// How long (seconds) to cache a fetched JWKS before refetching. Default 300.
151 pub jwks_cache_secs: u64,
152 /// If set, the token's `iss` claim must equal this.
153 pub issuer: String,
154 /// If set, the token's `aud` claim must contain this.
155 pub audience: String,
156 /// Clock-skew leeway (seconds) applied to `exp`/`nbf` validation. Default 60.
157 pub leeway_secs: u64,
158}
159
160#[derive(Debug, Clone, Deserialize)]
161#[serde(default)]
162pub struct RateLimitCfg {
163 pub enabled: bool,
164 /// Default per-client-IP limit, e.g. "60/min", "10/sec", "1000/hour".
165 pub rate: String,
166 pub burst: u32,
167 /// Per-route overrides. A request whose path starts with `path` uses that route's limit
168 /// (still keyed per client IP) instead of the global one; the longest matching prefix
169 /// wins, so `/api/admin/` can be stricter than `/api/`.
170 pub routes: Vec<RouteRateLimit>,
171 /// An additional limit keyed by the authenticated principal (API-key id or JWT subject)
172 /// rather than IP, so a single credential can't fan out across many IPs. Only applies to
173 /// authenticated requests.
174 pub per_key: PerKeyRateLimit,
175 /// Where limiter state lives: `"local"` (default) is the in-process `governor` limiter (fast,
176 /// no dependency, but per-replica). `"redis"` shares GCRA state across replicas via a Redis
177 /// store, so N instances enforce one global limit. `"memory"` uses the same shared-store code
178 /// path backed by an in-process map (a single-replica/testing backend). All three honor the
179 /// same `rate`/`burst`/route/per-key settings above.
180 pub store: String,
181 /// Redis connection URL for `store = "redis"`, e.g. `redis://host:6379` or (TLS)
182 /// `rediss://host:6379`. Prefer the `EDGEGUARD_REDIS_URL` env var over this file.
183 pub redis_url: String,
184 /// Key prefix/namespace for the shared store, so multiple EdgeGuard deployments can share one
185 /// Redis without colliding. Keys look like `<prefix>:ip:<addr>`.
186 pub redis_prefix: String,
187 /// What to do when the shared store is unreachable. `false` (default) fails **closed** — a
188 /// store error returns `503`, so an outage can't silently disable rate limiting. `true` fails
189 /// **open** — a store error allows the request (favor availability over strict limiting).
190 /// Only relevant for `store = "redis"`.
191 pub fail_open: bool,
192}
193
194/// A per-route rate-limit override (matched by path prefix).
195#[derive(Debug, Clone, Deserialize)]
196#[serde(default)]
197pub struct RouteRateLimit {
198 /// Path prefix this limit applies to, e.g. "/api/".
199 pub path: String,
200 pub rate: String,
201 pub burst: u32,
202}
203
204impl Default for RouteRateLimit {
205 fn default() -> Self {
206 RouteRateLimit {
207 path: String::new(),
208 rate: "60/min".into(),
209 burst: 20,
210 }
211 }
212}
213
214/// Per-principal rate limit (keyed by API-key id / JWT subject).
215#[derive(Debug, Clone, Deserialize)]
216#[serde(default)]
217pub struct PerKeyRateLimit {
218 pub enabled: bool,
219 pub rate: String,
220 pub burst: u32,
221}
222
223impl Default for PerKeyRateLimit {
224 fn default() -> Self {
225 PerKeyRateLimit {
226 enabled: false,
227 rate: "1000/hour".into(),
228 burst: 100,
229 }
230 }
231}
232
233#[derive(Debug, Clone, Deserialize)]
234#[serde(default)]
235pub struct ValidationCfg {
236 /// e.g. "2MiB". Requests with a larger body are rejected with 413.
237 pub max_body: String,
238 /// Cap on the upstream response body EdgeGuard buffers, e.g. "16MiB". "0" disables
239 /// the cap (unbounded). Protects against an upstream OOM-ing the proxy; raise it if
240 /// you proxy large downloads.
241 pub max_response_body: String,
242 /// Max time to wait for the upstream response and to read its body, e.g. "30s",
243 /// "500ms", "2m". "0" disables the timeout. Bounds a stalled upstream so it can't pin a
244 /// handler task indefinitely; on elapse the proxy returns 504.
245 pub upstream_timeout: String,
246 /// Cap on the total size of incoming request headers (sum of name + value bytes), e.g.
247 /// "32KiB". "0" disables the cap (default). Requests over the limit get `431`. This is a
248 /// policy limit enforced by EdgeGuard on top of hyper's own transport-level header cap.
249 pub max_header_bytes: String,
250 /// Allowed HTTP methods; empty list means allow all.
251 pub allow_methods: Vec<String>,
252 /// Stream (don't buffer) responses whose `Content-Type` is `text/event-stream`. Off by
253 /// default: the proxy normally buffers the whole upstream body so it can cap size
254 /// (`max_response_body`) and account exact egress bytes. That buffering defeats Server-Sent
255 /// Events / chunked streaming — the client only sees the body once the upstream finishes.
256 /// Turn this on to forward SSE responses frame-by-frame as they arrive (preserving
257 /// time-to-first-byte). When a response is streamed this way the `max_response_body` cap and
258 /// the body-read deadline don't apply (the connect/first-byte `upstream_timeout` still
259 /// does); egress bytes are tallied as frames flow. Non-SSE responses are unaffected.
260 pub stream_passthrough: bool,
261 /// Tunnel WebSocket (and other `Upgrade`) connections through to the upstream. Off by
262 /// default: the normal path strips the hop-by-hop `Upgrade`/`Connection` headers, so an
263 /// upgrade request would be forwarded as a plain HTTP request and the handshake would fail.
264 /// When on, an authenticated, rate-limited upgrade request is forwarded *with* its upgrade
265 /// headers and, on the upstream's `101 Switching Protocols`, EdgeGuard splices the two
266 /// connections into a raw bidirectional tunnel. Response hardening / WAF body inspection
267 /// don't apply to a tunneled connection (there is no buffered response). Non-upgrade requests
268 /// are unaffected.
269 pub websocket_passthrough: bool,
270 /// gzip-compress responses for clients that send `Accept-Encoding: gzip`. Off by default.
271 /// Skips already-compressed content types and (always) `text/event-stream`, so SSE streaming
272 /// is never buffered by the compressor. Applied at the listener, so toggling it needs a
273 /// restart (it is not part of the hot-reloadable policy).
274 pub compress_responses: bool,
275}
276
277#[derive(Debug, Clone, Deserialize)]
278#[serde(default)]
279pub struct HeadersCfg {
280 pub hsts: bool,
281 pub csp: String,
282 /// Send the CSP as `Content-Security-Policy-Report-Only` instead of enforcing it. Lets
283 /// you roll out / tighten a policy by collecting violations first without breaking the
284 /// page.
285 pub csp_report_only: bool,
286 /// If set, a `report-uri <value>` directive is appended to the CSP so browsers POST
287 /// violation reports there. Point it at EdgeGuard's own sink ("/__edgeguard/csp-report")
288 /// to have them logged, or at any external collector.
289 pub csp_report_uri: String,
290 pub referrer_policy: String,
291 pub permissions_policy: String,
292 pub frame_options: String,
293 pub force_secure_cookies: bool,
294 /// Response headers to strip (case-insensitive), e.g. ["Server", "X-Powered-By"].
295 pub strip: Vec<String>,
296}
297
298impl Default for ServerCfg {
299 fn default() -> Self {
300 ServerCfg {
301 port: 8080,
302 app_port: 3000,
303 upstream: String::new(),
304 trust_forwarded_for: false,
305 admin_port: 0,
306 admin_addr: "127.0.0.1".into(),
307 }
308 }
309}
310
311impl Default for AuthCfg {
312 fn default() -> Self {
313 AuthCfg {
314 mode: "none".into(),
315 realm: "EdgeGuard".into(),
316 users: BTreeMap::new(),
317 api_keys: vec![],
318 api_key_header: "X-API-Key".into(),
319 jwt: JwtCfg::default(),
320 }
321 }
322}
323
324impl Default for JwtCfg {
325 fn default() -> Self {
326 JwtCfg {
327 algorithm: "HS256".into(),
328 secret: String::new(),
329 public_key_pem: String::new(),
330 jwks_url: String::new(),
331 jwks_cache_secs: 300,
332 issuer: String::new(),
333 audience: String::new(),
334 leeway_secs: 60,
335 }
336 }
337}
338
339impl Default for RateLimitCfg {
340 fn default() -> Self {
341 RateLimitCfg {
342 enabled: true,
343 rate: "60/min".into(),
344 burst: 20,
345 routes: vec![],
346 per_key: PerKeyRateLimit::default(),
347 store: "local".into(),
348 redis_url: "redis://127.0.0.1:6379".into(),
349 redis_prefix: "edgeguard".into(),
350 fail_open: false,
351 }
352 }
353}
354
355impl Default for ValidationCfg {
356 fn default() -> Self {
357 ValidationCfg {
358 max_body: "2MiB".into(),
359 max_response_body: "0".into(),
360 upstream_timeout: "30s".into(),
361 max_header_bytes: "0".into(),
362 allow_methods: vec![],
363 stream_passthrough: false,
364 websocket_passthrough: false,
365 compress_responses: false,
366 }
367 }
368}
369
370impl Default for HeadersCfg {
371 fn default() -> Self {
372 HeadersCfg {
373 hsts: true,
374 csp: "default-src 'self'".into(),
375 csp_report_only: false,
376 csp_report_uri: String::new(),
377 referrer_policy: "no-referrer".into(),
378 permissions_policy: "geolocation=(), microphone=(), camera=()".into(),
379 frame_options: "DENY".into(),
380 force_secure_cookies: true,
381 strip: vec!["Server".into(), "X-Powered-By".into()],
382 }
383 }
384}
385
386/// TLS termination. When `enabled`, EdgeGuard serves HTTPS on the public port using a
387/// certificate either loaded from `cert_path`/`key_path` or obtained automatically via ACME.
388/// All-default fields (disabled, empty paths, default ACME) so `Default` is derivable.
389#[derive(Debug, Clone, Default, Deserialize)]
390#[serde(default)]
391pub struct TlsCfg {
392 pub enabled: bool,
393 /// PEM certificate chain (leaf first). When ACME is enabled this is where the obtained
394 /// certificate is written/read.
395 pub cert_path: String,
396 /// PEM private key (PKCS#8/PKCS#1/SEC1).
397 pub key_path: String,
398 pub acme: AcmeCfg,
399}
400
401/// Automatic certificate management (ACME / Let's Encrypt) via the HTTP-01 challenge. The
402/// obtained certificate is written to `TlsCfg::cert_path`/`key_path` and served by the TLS
403/// listener; a background task renews it before expiry.
404#[derive(Debug, Clone, Deserialize)]
405#[serde(default)]
406pub struct AcmeCfg {
407 pub enabled: bool,
408 /// Domains to request a certificate for (the first is the primary CN).
409 pub domains: Vec<String>,
410 /// Contact email for the ACME account (registration + expiry notices).
411 pub email: String,
412 /// ACME directory URL. Defaults to Let's Encrypt **staging** so a misconfiguration can't
413 /// burn the strict production rate limits; switch to production explicitly.
414 pub directory_url: String,
415 /// Directory for the cached ACME account key (so renewals reuse the same account).
416 pub cache_dir: String,
417 /// You must set this to `true` to signify acceptance of the ACME provider's Terms of
418 /// Service; EdgeGuard refuses to register otherwise.
419 pub accept_tos: bool,
420}
421
422impl Default for AcmeCfg {
423 fn default() -> Self {
424 AcmeCfg {
425 enabled: false,
426 domains: vec![],
427 email: String::new(),
428 // Let's Encrypt staging — safe default; see the field doc.
429 directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".into(),
430 cache_dir: "./acme".into(),
431 accept_tos: false,
432 }
433 }
434}
435
436/// WAF-lite input inspection (Phase 4 / v2). Screens a request for common attack signatures
437/// before it is forwarded, using built-in heuristic rulesets (SQLi/XSS/path-traversal) plus
438/// any operator-defined deny patterns. Disabled by default — these are heuristics, so the
439/// intended rollout is `report` (log + count matches without blocking) until the operator is
440/// confident, then `block` (return `403`). Compiled into a `crate::waf::WafEngine`.
441#[derive(Debug, Clone, Deserialize)]
442#[serde(default)]
443pub struct WafCfg {
444 /// "off" (default) | "report" | "block". `report` evaluates rules and logs/counts matches
445 /// but forwards the request anyway; `block` rejects a matching request with `403`.
446 pub mode: String,
447 /// Enable the built-in SQL-injection heuristic ruleset.
448 pub sqli: bool,
449 /// Enable the built-in cross-site-scripting heuristic ruleset.
450 pub xss: bool,
451 /// Enable the built-in path-traversal heuristic ruleset.
452 pub path_traversal: bool,
453 /// Inspect the request path + query string (matched raw and percent-decoded). Default true.
454 pub inspect_path: bool,
455 /// Inspect request header values. Off by default: header bytes (cookies, tokens, opaque
456 /// blobs) are noisy and prone to false positives.
457 pub inspect_headers: bool,
458 /// Inspect the request body (already capped by `validation.max_body`). Off by default.
459 pub inspect_body: bool,
460 /// Operator-defined deny patterns, evaluated alongside the enabled built-in rulesets.
461 pub rules: Vec<WafRule>,
462}
463
464/// A single operator-defined WAF deny pattern (a `[[waf.rules]]` entry).
465#[derive(Debug, Clone, Deserialize)]
466#[serde(default)]
467pub struct WafRule {
468 /// Identifier reported in logs/metrics when this rule matches (defaults to `custom-<n>`).
469 pub id: String,
470 /// Regular expression (RE2 syntax: linear-time, no backreferences/lookaround, so it can't
471 /// ReDoS the proxy). A request matching it in any targeted location is treated as a hit.
472 pub pattern: String,
473 /// Request location to match against: "path" (path+query, default), "headers", "body", or
474 /// "all". A location is only examined when its `inspect_*` flag above is also enabled.
475 pub target: String,
476}
477
478impl Default for WafCfg {
479 fn default() -> Self {
480 WafCfg {
481 mode: "off".into(),
482 sqli: true,
483 xss: true,
484 path_traversal: true,
485 inspect_path: true,
486 inspect_headers: false,
487 inspect_body: false,
488 rules: vec![],
489 }
490 }
491}
492
493impl Default for WafRule {
494 fn default() -> Self {
495 WafRule {
496 id: String::new(),
497 pattern: String::new(),
498 target: "path".into(),
499 }
500 }
501}
502
503/// A per-path-prefix upstream override (a `[[upstreams]]` entry). Requests whose path starts with
504/// `path` are forwarded to `target` instead of the default `server.upstream`; the longest matching
505/// prefix wins. This is deliberately a *static prefix map* for the common "static frontend + `/api`
506/// backend" shape — not a gateway: no service discovery, load balancing, health-based routing, or
507/// request rewriting (the path is forwarded unchanged). For those, put EdgeGuard behind a real
508/// gateway/mesh.
509#[derive(Debug, Clone, Default, Deserialize)]
510#[serde(default)]
511pub struct UpstreamRoute {
512 /// Path prefix this upstream applies to, e.g. `/api/`.
513 pub path: String,
514 /// Upstream base URL for this prefix, e.g. `http://api.internal:4000`.
515 pub target: String,
516}
517
518/// IP allow/deny lists, matched against the resolved client IP (the same IP rate limiting keys
519/// on — so behind a trusted proxy, set `server.trust_forwarded_for` for this to see the real
520/// client). Both lists accept plain IPs (`203.0.113.7`, `::1`) and CIDR ranges
521/// (`10.0.0.0/8`, `2001:db8::/32`). `deny` wins over `allow`; a non-empty `allow` means
522/// "only these may connect". Both empty (the default) = allow all. Compiled into a
523/// `crate::access::AccessPolicy`; an unparseable entry fails at startup/reload.
524#[derive(Debug, Clone, Default, Deserialize)]
525#[serde(default)]
526pub struct AccessCfg {
527 /// CIDRs/IPs allowed in. Empty = allow all (subject to `deny`).
528 pub allow: Vec<String>,
529 /// CIDRs/IPs always rejected (takes precedence over `allow`).
530 pub deny: Vec<String>,
531}
532
533/// Cross-Origin Resource Sharing policy. A drop-in front door commonly sits in front of an app
534/// whose browser frontend is served from a *different* origin (a separate static host, a
535/// preview URL, `localhost:5173` in dev); without CORS those `fetch` calls are blocked by the
536/// browser. When `enabled`, EdgeGuard answers preflight `OPTIONS` requests itself (before auth —
537/// preflights carry no credentials) and adds the matching `Access-Control-*` headers to actual
538/// responses. Off by default: opening cross-origin access is a deliberate choice. Compiled into
539/// a `crate::cors::CorsPolicy`.
540#[derive(Debug, Clone, Deserialize)]
541#[serde(default)]
542pub struct CorsCfg {
543 pub enabled: bool,
544 /// Allowed request origins, matched exactly (scheme + host + port), e.g.
545 /// `["https://app.example.com"]`. The single entry `["*"]` allows any origin — but a
546 /// wildcard cannot be combined with `allow_credentials = true` (the Fetch spec forbids it),
547 /// so that combination is rejected at startup.
548 pub allow_origins: Vec<String>,
549 /// Methods advertised in the preflight `Access-Control-Allow-Methods`. Empty = a sensible
550 /// default set (`GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD`).
551 pub allow_methods: Vec<String>,
552 /// Request headers advertised in `Access-Control-Allow-Headers`. Empty = reflect whatever the
553 /// browser asks for in `Access-Control-Request-Headers` (the common, permissive default).
554 pub allow_headers: Vec<String>,
555 /// Response headers the browser is allowed to read, advertised in
556 /// `Access-Control-Expose-Headers`. Empty = none beyond the CORS-safelisted set.
557 pub expose_headers: Vec<String>,
558 /// Send `Access-Control-Allow-Credentials: true` so the browser may send cookies / HTTP auth.
559 /// Requires explicit `allow_origins` (no `"*"`).
560 pub allow_credentials: bool,
561 /// How long a browser may cache the preflight result, e.g. `"600s"`, `"1h"`. `"0"` omits the
562 /// `Access-Control-Max-Age` header (the browser uses its own short default).
563 pub max_age: String,
564}
565
566impl Default for CorsCfg {
567 fn default() -> Self {
568 CorsCfg {
569 enabled: false,
570 allow_origins: vec![],
571 allow_methods: vec![],
572 allow_headers: vec![],
573 expose_headers: vec![],
574 allow_credentials: false,
575 max_age: "600s".into(),
576 }
577 }
578}
579
580impl Config {
581 /// Load defaults, overlay an optional TOML file, then apply env overrides.
582 pub fn load(path: Option<&str>) -> Result<Config> {
583 let mut cfg = if let Some(p) = path {
584 let raw =
585 std::fs::read_to_string(p).with_context(|| format!("reading config file {p}"))?;
586 toml::from_str::<Config>(&raw).with_context(|| format!("parsing config file {p}"))?
587 } else {
588 Config::default()
589 };
590
591 if let Ok(p) = env::var("PORT") {
592 if let Ok(v) = p.parse() {
593 cfg.server.port = v;
594 }
595 }
596 if let Ok(p) = env::var("APP_PORT") {
597 if let Ok(v) = p.parse() {
598 cfg.server.app_port = v;
599 }
600 }
601 if let Ok(p) = env::var("ADMIN_PORT") {
602 if let Ok(v) = p.parse() {
603 cfg.server.admin_port = v;
604 }
605 }
606 if let Ok(u) = env::var("UPSTREAM") {
607 if !u.is_empty() {
608 cfg.server.upstream = u;
609 }
610 }
611 // Keep secrets out of the config file: let the environment supply them, either directly
612 // (`EDGEGUARD_JWT_SECRET`) or from a file (`EDGEGUARD_JWT_SECRET_FILE`) for Docker/K8s
613 // secret mounts. The direct variable wins when both are set; see `env_or_file`.
614 if let Some(s) = env_or_file("EDGEGUARD_JWT_SECRET")? {
615 cfg.auth.jwt.secret = s;
616 }
617 if let Some(u) = env_or_file("EDGEGUARD_REDIS_URL")? {
618 cfg.ratelimit.redis_url = u;
619 }
620 if let Some(keys) = env_or_file("EDGEGUARD_API_KEYS")? {
621 let keys: Vec<String> = keys
622 .split(',')
623 .map(|k| k.trim().to_string())
624 .filter(|k| !k.is_empty())
625 .collect();
626 if !keys.is_empty() {
627 cfg.auth.api_keys = keys;
628 }
629 }
630 if let Some(t) = env_or_file("EDGEGUARD_CP_EDGE_TOKEN")? {
631 cfg.control_plane.edge_token = t;
632 }
633 if let Some(u) = env_or_file("EDGEGUARD_CP_URL")? {
634 cfg.control_plane.url = u;
635 }
636 if let Ok(v) = env::var("EDGEGUARD_CP_QUOTA_ENFORCE") {
637 // Only an explicit, recognized value overrides the file config; an empty value is a
638 // no-op and a typo is a hard error rather than silently disabling a security control.
639 match v.trim().to_ascii_lowercase().as_str() {
640 "" => {}
641 "1" | "true" | "yes" | "on" => cfg.control_plane.enforce_quota = true,
642 "0" | "false" | "no" | "off" => cfg.control_plane.enforce_quota = false,
643 other => anyhow::bail!(
644 "invalid EDGEGUARD_CP_QUOTA_ENFORCE value {other:?}; expected 1/true/yes/on or 0/false/no/off"
645 ),
646 }
647 }
648 Ok(cfg)
649 }
650
651 /// Produce an effective config by overlaying a control-plane-pushed *policy* document onto
652 /// this (local) config: the policy sections
653 /// (`auth`/`ratelimit`/`validation`/`headers`/`waf`/`access`/`cors`) come from the pushed TOML;
654 /// `server`/`tls`/`upstreams`/`telemetry`/`control_plane` stay local (the control plane manages
655 /// security policy, not this edge's listener/plumbing/topology). The result feeds the normal
656 /// `build_runtime` + hot-swap path, so a malformed policy is rejected like any bad reload.
657 pub fn with_policy_from(&self, policy_toml: &str) -> Result<Config> {
658 let p: Config =
659 toml::from_str(policy_toml).context("parsing control-plane policy document")?;
660 Ok(Config {
661 server: self.server.clone(),
662 tls: self.tls.clone(),
663 control_plane: self.control_plane.clone(),
664 // Upstream topology is edge-local (like `server`), not pushed policy.
665 upstreams: self.upstreams.clone(),
666 auth: p.auth,
667 ratelimit: p.ratelimit,
668 validation: p.validation,
669 headers: p.headers,
670 waf: p.waf,
671 access: p.access,
672 cors: p.cors,
673 })
674 }
675
676 /// The upstream base URL EdgeGuard forwards to, e.g. "http://127.0.0.1:3000".
677 pub fn upstream_base(&self) -> String {
678 if self.server.upstream.is_empty() {
679 format!("http://127.0.0.1:{}", self.server.app_port)
680 } else {
681 self.server.upstream.trim_end_matches('/').to_string()
682 }
683 }
684
685 /// The `(host, port)` EdgeGuard probes for readiness, mirroring [`Self::upstream_base`]:
686 /// co-process mode probes `127.0.0.1:app_port`; an explicit upstream URL is parsed,
687 /// defaulting the port from the scheme. Returns `None` if the URL carries no usable
688 /// host, so the readiness check reports "not ready" rather than panicking.
689 pub fn upstream_probe_addr(&self) -> Option<(String, u16)> {
690 if self.server.upstream.is_empty() {
691 Some(("127.0.0.1".to_string(), self.server.app_port))
692 } else {
693 parse_host_port(&self.server.upstream)
694 }
695 }
696}
697
698/// Extract `(host, port)` from an upstream URL like `http://host:3000/base`. Only the
699/// scheme (for the default port), host, and port are needed — any path is ignored. Handles
700/// bracketed IPv6 literals (`http://[::1]:3000`). This is deliberately small rather than a
701/// full URL parser; the proxy itself is HTTP-only in v0.
702fn parse_host_port(url: &str) -> Option<(String, u16)> {
703 let (default_port, rest) = if let Some(r) = url.strip_prefix("http://") {
704 (80u16, r)
705 } else if let Some(r) = url.strip_prefix("https://") {
706 (443u16, r)
707 } else {
708 (80u16, url)
709 };
710 // Authority is everything up to the first '/'; drop any `user:pass@` userinfo.
711 let authority = rest.split('/').next().unwrap_or(rest);
712 let authority = authority.rsplit('@').next().unwrap_or(authority);
713 if authority.is_empty() {
714 return None;
715 }
716 // Bracketed IPv6 literal: `[::1]` or `[::1]:port`.
717 if let Some(after) = authority.strip_prefix('[') {
718 let (host, tail) = after.split_once(']')?;
719 let port = match tail.strip_prefix(':') {
720 Some(p) => p.parse().ok()?,
721 None => default_port,
722 };
723 return Some((host.to_string(), port));
724 }
725 match authority.rsplit_once(':') {
726 // Reject an empty host (e.g. `http://:3000`) rather than deferring the failure to a
727 // connect call — the "usable host" contract is checked here.
728 Some((host, port)) if !host.is_empty() => Some((host.to_string(), port.parse().ok()?)),
729 Some(_) => None,
730 None => Some((authority.to_string(), default_port)),
731 }
732}
733
734/// Resolve a secret from the environment, supporting a `*_FILE` indirection for Docker/K8s
735/// secret mounts (`EDGEGUARD_JWT_SECRET` *or* `EDGEGUARD_JWT_SECRET_FILE` pointing at a file
736/// whose contents are the secret). The direct variable takes precedence when both are set; a
737/// `*_FILE` that can't be read is a hard error (a misconfigured secret mount must fail loudly,
738/// not silently fall back to no secret). A trailing newline (the common `echo`/editor artifact)
739/// is trimmed. Returns `None` when neither is set / both are empty, so the caller keeps the
740/// file/default value.
741fn env_or_file(name: &str) -> Result<Option<String>> {
742 if let Ok(v) = env::var(name) {
743 if !v.is_empty() {
744 return Ok(Some(v));
745 }
746 }
747 let file_var = format!("{name}_FILE");
748 if let Ok(path) = env::var(&file_var) {
749 if !path.is_empty() {
750 let content = std::fs::read_to_string(&path)
751 .with_context(|| format!("reading {file_var} ({path})"))?;
752 let trimmed = content.trim_end_matches(['\n', '\r']);
753 if !trimmed.is_empty() {
754 return Ok(Some(trimmed.to_string()));
755 }
756 }
757 }
758 Ok(None)
759}
760
761/// Parse a human size like "2MiB", "512KB", "1048576" into bytes.
762pub fn parse_size(s: &str) -> Result<usize> {
763 let s = s.trim();
764 let (num, mult): (&str, usize) = if let Some(n) = s.strip_suffix("GiB") {
765 (n, 1024 * 1024 * 1024)
766 } else if let Some(n) = s.strip_suffix("MiB") {
767 (n, 1024 * 1024)
768 } else if let Some(n) = s.strip_suffix("KiB") {
769 (n, 1024)
770 } else if let Some(n) = s.strip_suffix("GB") {
771 (n, 1_000_000_000)
772 } else if let Some(n) = s.strip_suffix("MB") {
773 (n, 1_000_000)
774 } else if let Some(n) = s.strip_suffix("KB") {
775 (n, 1_000)
776 } else if let Some(n) = s.strip_suffix('B') {
777 (n, 1)
778 } else {
779 (s, 1)
780 };
781 let n: usize = num
782 .trim()
783 .parse()
784 .with_context(|| format!("invalid size: {s}"))?;
785 n.checked_mul(mult)
786 .with_context(|| format!("size too large: {s}"))
787}
788
789/// Parse a rate like "60/min" into (count, period).
790pub fn parse_rate(s: &str) -> Result<(u32, Duration)> {
791 let (n, unit) = s
792 .split_once('/')
793 .with_context(|| format!("invalid rate (expected N/unit): {s}"))?;
794 let count: u32 = n
795 .trim()
796 .parse()
797 .with_context(|| format!("invalid rate count: {s}"))?;
798 let period = match unit.trim() {
799 "s" | "sec" | "second" => Duration::from_secs(1),
800 "m" | "min" | "minute" => Duration::from_secs(60),
801 "h" | "hour" => Duration::from_secs(3600),
802 other => anyhow::bail!("unsupported rate unit: {other}"),
803 };
804 Ok((count, period))
805}
806
807/// Parse a timeout like "30s", "500ms", "2m", or a bare number of seconds ("45"). "0"
808/// yields a zero duration, which callers treat as "disabled".
809pub fn parse_duration(s: &str) -> Result<Duration> {
810 let s = s.trim();
811 // Order matters: check "ms" before the single-char "s"/"m" suffixes.
812 if let Some(n) = s.strip_suffix("ms") {
813 let ms: u64 = n
814 .trim()
815 .parse()
816 .with_context(|| format!("invalid duration: {s}"))?;
817 Ok(Duration::from_millis(ms))
818 } else if let Some(n) = s.strip_suffix('s') {
819 let secs: u64 = n
820 .trim()
821 .parse()
822 .with_context(|| format!("invalid duration: {s}"))?;
823 Ok(Duration::from_secs(secs))
824 } else if let Some(n) = s.strip_suffix('m') {
825 let mins: u64 = n
826 .trim()
827 .parse()
828 .with_context(|| format!("invalid duration: {s}"))?;
829 let secs = mins
830 .checked_mul(60)
831 .with_context(|| format!("duration too large: {s}"))?;
832 Ok(Duration::from_secs(secs))
833 } else {
834 let secs: u64 = s
835 .parse()
836 .with_context(|| format!("invalid duration: {s}"))?;
837 Ok(Duration::from_secs(secs))
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844
845 #[test]
846 fn parse_size_units_and_plain_bytes() {
847 assert_eq!(parse_size("0").unwrap(), 0);
848 assert_eq!(parse_size("1048576").unwrap(), 1_048_576);
849 assert_eq!(parse_size("512B").unwrap(), 512);
850 assert_eq!(parse_size("1KB").unwrap(), 1_000);
851 assert_eq!(parse_size("1KiB").unwrap(), 1_024);
852 assert_eq!(parse_size("2MiB").unwrap(), 2 * 1024 * 1024);
853 assert_eq!(parse_size("16MiB").unwrap(), 16 * 1024 * 1024);
854 assert_eq!(parse_size("1GiB").unwrap(), 1024 * 1024 * 1024);
855 // surrounding / internal whitespace is tolerated
856 assert_eq!(parse_size(" 4 MiB ").unwrap(), 4 * 1024 * 1024);
857 }
858
859 #[test]
860 fn parse_size_rejects_garbage_and_overflow() {
861 assert!(parse_size("abc").is_err());
862 assert!(parse_size("MiB").is_err());
863 // would overflow usize -> Err, not a silent wrap
864 assert!(parse_size("99999999999999999999GiB").is_err());
865 }
866
867 #[test]
868 fn parse_rate_counts_and_units() {
869 assert_eq!(parse_rate("60/min").unwrap(), (60, Duration::from_secs(60)));
870 assert_eq!(parse_rate("10/sec").unwrap(), (10, Duration::from_secs(1)));
871 assert_eq!(
872 parse_rate("1000/hour").unwrap(),
873 (1000, Duration::from_secs(3600))
874 );
875 // short and long unit spellings, plus tolerated whitespace
876 assert_eq!(parse_rate(" 5 / m ").unwrap(), (5, Duration::from_secs(60)));
877 }
878
879 #[test]
880 fn parse_rate_rejects_garbage() {
881 assert!(parse_rate("60").is_err()); // no unit
882 assert!(parse_rate("x/min").is_err()); // bad count
883 assert!(parse_rate("60/year").is_err()); // bad unit
884 }
885
886 #[test]
887 fn probe_addr_defaults_to_app_port_in_coprocess_mode() {
888 let cfg = Config::default();
889 assert_eq!(
890 cfg.upstream_probe_addr(),
891 Some(("127.0.0.1".to_string(), cfg.server.app_port))
892 );
893 }
894
895 #[test]
896 fn parse_host_port_handles_schemes_paths_and_ipv6() {
897 assert_eq!(
898 parse_host_port("http://127.0.0.1:3000"),
899 Some(("127.0.0.1".to_string(), 3000))
900 );
901 // a trailing path is ignored
902 assert_eq!(
903 parse_host_port("http://app.internal:8080/health"),
904 Some(("app.internal".to_string(), 8080))
905 );
906 // port defaults from the scheme
907 assert_eq!(
908 parse_host_port("https://example.com"),
909 Some(("example.com".to_string(), 443))
910 );
911 assert_eq!(
912 parse_host_port("http://example.com"),
913 Some(("example.com".to_string(), 80))
914 );
915 // bracketed IPv6 literal, with and without an explicit port
916 assert_eq!(
917 parse_host_port("http://[::1]:3000"),
918 Some(("::1".to_string(), 3000))
919 );
920 assert_eq!(
921 parse_host_port("http://[2001:db8::1]"),
922 Some(("2001:db8::1".to_string(), 80))
923 );
924 }
925
926 #[test]
927 fn parse_host_port_rejects_empty_or_unusable_host() {
928 // empty host (port only) is not a usable probe target
929 assert_eq!(parse_host_port("http://:3000"), None);
930 // non-numeric port
931 assert_eq!(parse_host_port("http://host:notaport"), None);
932 }
933
934 #[test]
935 fn parse_duration_units_and_bare_seconds() {
936 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
937 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
938 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
939 assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
940 // "0" disables (zero duration); callers map it to "no timeout"
941 assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
942 assert_eq!(parse_duration(" 10s ").unwrap(), Duration::from_secs(10));
943 }
944
945 #[test]
946 fn with_policy_from_keeps_local_plumbing_takes_policy() {
947 let mut local = Config::default();
948 local.server.port = 9999;
949 local.server.upstream = "http://up:1".into();
950 local.control_plane.enabled = true;
951 // A pushed policy that changes auth + disables rate limiting.
952 let policy = "[auth]\nmode = \"apikey\"\n\n[ratelimit]\nenabled = false\n";
953 let merged = local.with_policy_from(policy).unwrap();
954 // Local server / control-plane settings are preserved...
955 assert_eq!(merged.server.port, 9999);
956 assert_eq!(merged.server.upstream, "http://up:1");
957 assert!(merged.control_plane.enabled);
958 // ...while the policy sections are taken from the pushed document.
959 assert_eq!(merged.auth.mode, "apikey");
960 assert!(!merged.ratelimit.enabled);
961 }
962
963 #[test]
964 fn with_policy_from_rejects_bad_toml() {
965 assert!(Config::default()
966 .with_policy_from("not = valid = toml")
967 .is_err());
968 }
969
970 #[test]
971 fn parse_duration_rejects_garbage() {
972 assert!(parse_duration("abc").is_err());
973 assert!(parse_duration("10x").is_err());
974 assert!(parse_duration("s").is_err());
975 }
976
977 #[test]
978 fn env_or_file_reads_file_trims_newline_and_prefers_direct() {
979 // A uniquely-named var so this doesn't collide with any real config key or another test.
980 let name = "EDGEGUARD_TEST_SECRET_QZX";
981 let file_var = format!("{name}_FILE");
982 let path = std::env::temp_dir().join("edgeguard_test_secret_qzx.txt");
983 std::fs::write(&path, "s3cr3t\n").unwrap();
984
985 // No direct var, only *_FILE -> read the file (trailing newline trimmed).
986 std::env::remove_var(name);
987 std::env::set_var(&file_var, &path);
988 assert_eq!(env_or_file(name).unwrap().as_deref(), Some("s3cr3t"));
989
990 // Direct var set -> it wins over the file.
991 std::env::set_var(name, "direct");
992 assert_eq!(env_or_file(name).unwrap().as_deref(), Some("direct"));
993
994 // Neither set -> None (caller keeps the file/default value).
995 std::env::remove_var(name);
996 std::env::remove_var(&file_var);
997 assert_eq!(env_or_file(name).unwrap(), None);
998
999 // A *_FILE pointing at a missing path is a hard error, not a silent fallback.
1000 std::env::set_var(&file_var, "/nonexistent/edgeguard/secret");
1001 assert!(env_or_file(name).is_err());
1002 std::env::remove_var(&file_var);
1003
1004 let _ = std::fs::remove_file(&path);
1005 }
1006}