Skip to main content

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}