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    /// Optional LLM token metering (gateway L0). Off by default; when enabled, OpenAI-compatible
36    /// traffic is parsed to meter tokens + cost (metering only — never blocks). See [`LlmCfg`].
37    pub llm: LlmCfg,
38    /// Optional outbound alerting (gateway L4). Off by default; when enabled with a webhook, the
39    /// gateway fires a Slack-compatible alert when a hard budget nears its limit. See [`AlertsCfg`].
40    pub alerts: AlertsCfg,
41}
42
43/// Outbound alerting (`[alerts]`). When `enabled` with a `webhook_url`, EdgeGuard POSTs a
44/// Slack-compatible alert (`{ "text": … }`) when a hard-budget's consumed ratio (`used/limit`)
45/// crosses `budget_consumed_threshold` — cost-regression alerting entirely in your own VPC (no SaaS
46/// alerting plane; the confirmed Phoenix gap of gating alerting behind a paid cloud). Fire-and-forget
47/// and **edge-triggered** (one alert per crossing into the alert zone, not one per request). Off by
48/// default. A first cut on budget breaches; latency-percentile / error-rate / eval-drift rules follow.
49#[derive(Debug, Clone, Deserialize)]
50#[serde(default)]
51pub struct AlertsCfg {
52    /// Master switch. Default false.
53    pub enabled: bool,
54    /// Slack incoming-webhook URL (or any endpoint accepting `{ "text": … }`). Required when enabled.
55    pub webhook_url: String,
56    /// Fire when a budget's consumed ratio (`used/limit`) reaches this (`0.0`–`1.0+`). Default `0.9`.
57    pub budget_consumed_threshold: f64,
58    /// Per-emit timeout for the background POST, in milliseconds. Default 2000.
59    pub timeout_ms: u64,
60}
61
62impl Default for AlertsCfg {
63    fn default() -> Self {
64        AlertsCfg {
65            enabled: false,
66            webhook_url: String::new(),
67            budget_consumed_threshold: 0.9,
68            timeout_ms: 2000,
69        }
70    }
71}
72
73/// LLM token-metering settings (`[llm]`). When `enabled`, the proxy parses OpenAI-compatible
74/// request/response bodies to count tokens (from the upstream's `usage` object) and, for any model
75/// listed in `[llm.models]`, the cost. Metering is observe-only: it never blocks or alters traffic.
76/// An unmapped model still has its tokens counted (cost is simply omitted).
77#[derive(Debug, Clone, Deserialize)]
78#[serde(default)]
79pub struct LlmCfg {
80    pub enabled: bool,
81    /// Wire format. Only `"openai"` is understood today (the default).
82    pub api_style: String,
83    /// Per-model price book, keyed by the `model` string clients send. Prices are USD per
84    /// 1,000,000 tokens. Example:
85    /// `[llm.models."gpt-4o"]` `input_per_1m = 2.5` / `output_per_1m = 10.0`.
86    pub models: BTreeMap<String, ModelPrice>,
87    /// What to do with a request whose `model` is **not** in `[llm.models]`: `"count"` (default —
88    /// meter tokens, omit cost, forward the request) or `"block"` (reject `402` before it reaches the
89    /// upstream, so an unpriced model is never served at a silent `$0`). `"block"` only bites when a
90    /// price book is configured — a metering-only deployment (empty `[llm.models]`) never rejects.
91    pub on_unpriced_model: String,
92    /// Hard token/cost budgets (gateway L1). Empty by default (no enforcement — L0 metering only).
93    /// Each `[[llm.budgets]]` is a ceiling enforced fail-closed via reserve→reconcile. See [`BudgetCfg`].
94    pub budgets: Vec<BudgetCfg>,
95    /// Budget store backend: `"memory"` (single replica / default) or `"redis"` (shared across
96    /// replicas — required for a true fleet-wide cap). `"local"` is treated as `"memory"`.
97    pub store: String,
98    /// Redis URL when `store = "redis"`, e.g. `redis://127.0.0.1:6379`. Note: `EDGEGUARD_REDIS_URL`
99    /// only overrides `ratelimit.redis_url`, not this key — set it here (or via pushed policy).
100    pub redis_url: String,
101    /// Key prefix for budget keys in Redis (namespacing a shared server). Defaults to `edgeguard`.
102    pub redis_prefix: String,
103    /// On a budget-store error, allow the request (`true`) or reject it `503` (`false`, the default
104    /// — fail-closed, so an outage can't silently uncap spend).
105    pub fail_open: bool,
106    /// Completion tokens to assume when a request omits `max_tokens`, used only for the *reserve*
107    /// estimate (the reservation is reconciled to actual usage afterward). Default 1024.
108    pub default_max_tokens: u64,
109    /// Request header carrying the **team / tag** a request is attributed to, for the per-team budget
110    /// scope and team chargeback. Case-insensitive; default `x-edgeguard-team`. A request without it
111    /// falls into the shared `_none` team bucket.
112    pub team_header: String,
113    /// BYO-key vault + egress governance (gateway L2). Empty by default (no vault). Each
114    /// `[[llm.keys]]` maps a client-facing **virtual key** to a real **provider key** (injected
115    /// upstream, never returned to the client) plus an optional per-key model egress allowlist.
116    /// When any key is configured, every proxied request must present a known virtual key. See
117    /// [`KeyEntryCfg`].
118    pub keys: Vec<KeyEntryCfg>,
119    /// Edge DLP — PII / secret detection + redaction (gateway L3). Off by default. See [`DlpCfg`].
120    pub dlp: DlpCfg,
121    /// OTLP span emission (gateway L4) — SDK-free tracing to an OTel-native store. Off by default.
122    /// See [`TelemetryCfg`].
123    pub telemetry: TelemetryCfg,
124}
125
126/// OTLP span emission (`[llm.telemetry]`). When `enabled`, the gateway emits one OpenInference/OTLP
127/// span per metered LLM request to `endpoint` (an OTLP/HTTP `/v1/traces` receiver — e.g. evald),
128/// carrying the model, per-tier tokens, computed cost, and server-side TTFT/TPOT/latency already
129/// attached. Because the proxy sits in the request path, this needs **no** client SDK and is immune
130/// to the import-order / per-framework instrumentor drift that plagues in-process instrumentation.
131/// Emission is fire-and-forget — it never blocks or fails the client response. Off by default.
132#[derive(Debug, Clone, Deserialize)]
133#[serde(default)]
134pub struct TelemetryCfg {
135    /// Master switch. Default false.
136    pub enabled: bool,
137    /// OTLP/HTTP traces endpoint, e.g. `http://127.0.0.1:4318/v1/traces`. Required when `enabled`.
138    pub endpoint: String,
139    /// Fraction of LLM requests to emit a span for, `0.0`–`1.0` (deterministic per-trace sampling —
140    /// the same trace always gets the same verdict). Default `1.0` (all).
141    pub sample_rate: f64,
142    /// `service.name` resource attribute on emitted spans. Default `edgeguard`.
143    pub service_name: String,
144    /// Capture the (DLP-redacted) prompt/response as `input.value`/`output.value` on the span. Off by
145    /// default — content leaves the gateway only when this is explicitly enabled, and when an
146    /// `[llm.dlp]` engine is configured the captured content is redacted before it is emitted.
147    pub capture_content: bool,
148    /// Cap on each captured content field in bytes (truncated past this). Default 8192.
149    pub max_content_bytes: usize,
150    /// Per-emit timeout for the background POST, in milliseconds. Default 2000.
151    pub timeout_ms: u64,
152}
153
154impl Default for TelemetryCfg {
155    fn default() -> Self {
156        TelemetryCfg {
157            enabled: false,
158            endpoint: String::new(),
159            sample_rate: 1.0,
160            service_name: "edgeguard".into(),
161            capture_content: false,
162            max_content_bytes: 8192,
163            timeout_ms: 2000,
164        }
165    }
166}
167
168/// Edge-DLP settings (`[llm.dlp]`). When `mode` is not `off`, request and/or response bodies are
169/// scanned for PII and secrets; the `mode` decides what happens on a finding (report / block /
170/// redact). See [`crate::dlp`].
171#[derive(Debug, Clone, Deserialize)]
172#[serde(default)]
173pub struct DlpCfg {
174    /// `off` | `report` | `block` | `redact`. Default `off`.
175    pub mode: String,
176    /// How a span is rewritten in `redact` mode: `full` (`[REDACTED:<cat>]`, default) | `mask`
177    /// (keep last 4) | `hash` (stable opaque token). See [`crate::dlp::RedactStyle`].
178    pub redact_style: String,
179    /// Scan the inbound request body (the prompt). Default true.
180    pub scan_request: bool,
181    /// Scan the (buffered) response body and, in report mode, streamed frames. Default true.
182    pub scan_response: bool,
183    /// In `redact` mode, also rewrite *streamed* SSE frames (not just buffered bodies). Deterministic
184    /// detectors only — NER never runs on the stream. Off by default: streaming redaction can only
185    /// rewrite spans the carry buffer fully contains, so enable it deliberately. See [`crate::dlp`].
186    pub stream_redact: bool,
187    /// **Reversible masking** (`redact` mode only). When on, an inbound finding is replaced with a
188    /// stable placeholder token (`<edgeguard-<cat>-<n>>`) instead of an irreversible `[REDACTED]`
189    /// tag, and the placeholder→original map is kept for the request so the **response is unmasked**
190    /// (buffered *and* streamed) back to the original value. The provider never sees the PII; the
191    /// client gets its own data back — the round-trip `litellm#22821` gets wrong. Off by default.
192    /// When on, the response is unmasked rather than re-scanned/redacted (restore, not detect).
193    pub reversible: bool,
194    /// Built-in detectors.
195    pub detect_email: bool,
196    pub detect_credit_card: bool,
197    /// Require the Luhn checksum before flagging a digit run as a card (cuts false positives).
198    /// Default true.
199    pub luhn_validate_credit_card: bool,
200    /// AWS keys, provider-style `xx-…` keys, and private-key blocks.
201    pub detect_secrets: bool,
202    /// US SSN (`NNN-NN-NNNN`). Default true.
203    pub detect_ssn: bool,
204    /// Phone numbers. Off by default — false-positives on ordinary numeric runs.
205    pub detect_phone: bool,
206    /// IBAN account numbers. Off by default — false-positives on uppercase+digit tokens.
207    pub detect_iban: bool,
208    /// High-entropy token sweep (catch-all). Off by default — can false-positive.
209    pub detect_high_entropy: bool,
210    /// Prompt-injection / jailbreak heuristics for agent traffic (a small, high-precision built-in
211    /// deny set — "ignore previous instructions", "reveal your system prompt", etc.), reported under
212    /// the `prompt_injection` category. Off by default (opt-in, report-first) since instructions to a
213    /// model are legitimate traffic; enable and watch the counter before moving to `block`.
214    pub detect_prompt_injection: bool,
215    /// Minimum token length the entropy sweep considers.
216    pub entropy_min_len: usize,
217    /// Per-character Shannon-entropy threshold (bits) for the entropy sweep.
218    pub entropy_threshold: f64,
219    /// Dictionary deny-list: literal terms matched case-insensitively (Aho-Corasick), reported under
220    /// the `gazetteer` category. The fast, many-term path for known names / codenames / identifiers.
221    pub gazetteer_terms: Vec<String>,
222    /// Extra regexes (linear-time `regex` syntax), all reported under the `custom` category.
223    pub custom_patterns: Vec<String>,
224    /// Optional ML NER family (`[llm.dlp.ner]`). Requires the `ner` cargo feature; catches
225    /// person/address/org spans regex can't. See [`NerCfg`].
226    pub ner: NerCfg,
227}
228
229impl Default for DlpCfg {
230    fn default() -> Self {
231        DlpCfg {
232            mode: "off".into(),
233            redact_style: "full".into(),
234            scan_request: true,
235            scan_response: true,
236            stream_redact: false,
237            reversible: false,
238            detect_email: true,
239            detect_credit_card: true,
240            luhn_validate_credit_card: true,
241            detect_secrets: true,
242            detect_ssn: true,
243            detect_phone: false,
244            detect_iban: false,
245            detect_high_entropy: false,
246            detect_prompt_injection: false,
247            entropy_min_len: 24,
248            entropy_threshold: 4.0,
249            gazetteer_terms: Vec::new(),
250            custom_patterns: Vec::new(),
251            ner: NerCfg::default(),
252        }
253    }
254}
255
256/// ML NER settings (`[llm.dlp.ner]`). Off by default. When `enabled`, the proxy must be built with
257/// `--features ner`; otherwise startup fails with a clear error rather than running regex-only while
258/// the operator believes ML coverage is active. The model is an ONNX token-classification (BIO) NER
259/// network run through the pure-Rust [`edgeguard_ner`] crate.
260#[derive(Debug, Clone, Deserialize)]
261#[serde(default)]
262pub struct NerCfg {
263    /// Turn the NER family on. Requires the `ner` feature.
264    pub enabled: bool,
265    /// Path to the ONNX model file.
266    pub model_path: String,
267    /// Path to the HuggingFace `tokenizer.json`.
268    pub tokenizer_path: String,
269    /// Per-class label list in model id order (e.g. `["O","B-PER","I-PER","B-LOC", …]`). Used to map
270    /// argmax class ids back to entity labels.
271    pub labels: Vec<String>,
272    /// Confidence floor in `[0.0, 1.0]`; spans below it are dropped. Default 0.5.
273    pub threshold: f32,
274    /// Max tokens fed to the model per scan (longer inputs are truncated). Default 256.
275    pub max_seq_len: usize,
276}
277
278impl Default for NerCfg {
279    fn default() -> Self {
280        NerCfg {
281            enabled: false,
282            model_path: String::new(),
283            tokenizer_path: String::new(),
284            labels: Vec::new(),
285            threshold: 0.5,
286            max_seq_len: 256,
287        }
288    }
289}
290
291impl Default for LlmCfg {
292    fn default() -> Self {
293        LlmCfg {
294            enabled: false,
295            api_style: "openai".into(),
296            models: BTreeMap::new(),
297            on_unpriced_model: "count".into(),
298            budgets: Vec::new(),
299            store: "memory".into(),
300            redis_url: String::new(),
301            redis_prefix: "edgeguard".into(),
302            fail_open: false,
303            default_max_tokens: 1024,
304            team_header: "x-edgeguard-team".into(),
305            keys: Vec::new(),
306            dlp: DlpCfg::default(),
307            telemetry: TelemetryCfg::default(),
308        }
309    }
310}
311
312/// One vault entry (`[[llm.keys]]`): a client-facing virtual key mapped to a real provider key and
313/// an optional model egress allowlist. The provider key is injected into the upstream `Authorization`
314/// and is **never** sent back to the client; the client only ever holds the virtual key.
315#[derive(Debug, Clone, Default, Deserialize)]
316#[serde(default)]
317pub struct KeyEntryCfg {
318    /// The secret the client presents (`Authorization: Bearer <virtual_key>`). Required.
319    pub virtual_key: String,
320    /// The real upstream provider secret injected on the way out. Required. Prefer sourcing this
321    /// from a pushed control-plane policy / secret store rather than committing it.
322    pub provider_key: String,
323    /// Allowed model names for this key (egress allowlist). Empty = unrestricted; non-empty = only
324    /// these models may be requested (others get `403`).
325    pub allowed_models: Vec<String>,
326    /// Optional label for logs/metrics/audit (never the secret). Defaults to a positional id.
327    pub label: String,
328}
329
330/// One hard budget (`[[llm.budgets]]`): a ceiling of `limit` (in `unit`) over `window`, keyed by
331/// `scope`. Enforced fail-closed before the request reaches the upstream.
332#[derive(Debug, Clone, Deserialize)]
333#[serde(default)]
334pub struct BudgetCfg {
335    /// Identifier (also the metric/log label and part of the store key). Required, non-empty.
336    pub name: String,
337    /// Keying dimension: `"global"`, `"key"` (per authenticated principal), or `"model"`.
338    pub scope: String,
339    /// `"tokens"` (prompt + completion) or `"usd"` (cost via the price book).
340    pub unit: String,
341    /// The ceiling, in `unit`: a token count, or — for `unit = "usd"` — dollars (e.g. `25.0`).
342    pub limit: f64,
343    /// Reset window, e.g. `"1h"`, `"24h"`, `"30d"`. The budget resets at each window boundary.
344    pub window: String,
345}
346
347impl Default for BudgetCfg {
348    fn default() -> Self {
349        BudgetCfg {
350            name: String::new(),
351            scope: "global".into(),
352            unit: "tokens".into(),
353            limit: 0.0,
354            window: "24h".into(),
355        }
356    }
357}
358
359/// One model's price, in USD per 1,000,000 tokens (input and output billed separately, matching
360/// provider pricing). Compiled to integer micro-dollars at load (see [`crate::llm`]).
361///
362/// `cached_per_1m` prices the cached-prompt subset (`prompt_tokens_details.cached_tokens`, usually a
363/// steep discount) and `reasoning_per_1m` the reasoning subset (`completion_tokens_details.
364/// reasoning_tokens`). Both default to `0.0`, which means **inherit the base input/output rate** —
365/// so an existing book prices exactly as before; set them only to apply a provider's separate
366/// cached/reasoning rate.
367#[derive(Debug, Clone, Copy, Default, Deserialize)]
368#[serde(default)]
369pub struct ModelPrice {
370    pub input_per_1m: f64,
371    pub output_per_1m: f64,
372    /// USD per 1M cached prompt tokens. `0.0` = inherit `input_per_1m`.
373    pub cached_per_1m: f64,
374    /// USD per 1M reasoning tokens. `0.0` = inherit `output_per_1m`.
375    pub reasoning_per_1m: f64,
376}
377
378/// Managed-mode settings: when `enabled`, the edge pulls its policy from a remote control plane
379/// (and hot-reloads it), reports metric deltas, and forwards CSP reports. The policy the control
380/// plane pushes is the *policy subset* (auth/ratelimit/validation/headers/waf) — the edge keeps
381/// its own local `server`/`tls`. The edge token is a secret, so prefer `EDGEGUARD_CP_EDGE_TOKEN`.
382#[derive(Debug, Clone, Deserialize)]
383#[serde(default)]
384pub struct ControlPlaneCfg {
385    pub enabled: bool,
386    /// Base URL of the control plane, e.g. `https://cp.example`.
387    pub url: String,
388    /// This edge's tenant id at the control plane.
389    pub tenant_id: String,
390    /// Per-tenant edge token (Bearer). Prefer `EDGEGUARD_CP_EDGE_TOKEN`.
391    pub edge_token: String,
392    /// How often to poll for policy, e.g. `"30s"`.
393    pub poll_interval: String,
394    /// How often to flush a metrics delta, e.g. `"60s"`.
395    pub report_interval: String,
396    /// Forward received CSP reports to the control plane (default true).
397    pub forward_csp: bool,
398    /// Enforce the configured quota as a **hard stop**: poll the control plane's
399    /// `/v3/edge/{id}/quota` and, while the edge is over its quota, reject the edge's
400    /// traffic with `429` (a `Retry-After` reset hint). Off by default — opt in to turn the
401    /// rate signal into a hard cap. Prefer `EDGEGUARD_CP_QUOTA_ENFORCE`.
402    pub enforce_quota: bool,
403    /// How often to poll the quota verdict, e.g. `"30s"`. A failed poll keeps the last verdict, so
404    /// a control-plane blip neither over- nor under-enforces.
405    pub quota_poll_interval: String,
406}
407
408impl Default for ControlPlaneCfg {
409    fn default() -> Self {
410        ControlPlaneCfg {
411            enabled: false,
412            url: String::new(),
413            tenant_id: String::new(),
414            edge_token: String::new(),
415            poll_interval: "30s".into(),
416            report_interval: "60s".into(),
417            forward_csp: true,
418            enforce_quota: false,
419            quota_poll_interval: "30s".into(),
420        }
421    }
422}
423
424#[derive(Debug, Clone, Deserialize)]
425#[serde(default)]
426pub struct ServerCfg {
427    /// Public listen port. Overridden by the `PORT` env var.
428    pub port: u16,
429    /// Internal port the wrapped/upstream app listens on. Overridden by `APP_PORT`.
430    pub app_port: u16,
431    /// Full upstream base URL. Overridden by `UPSTREAM`. If empty, derived from app_port.
432    pub upstream: String,
433    /// Trust the `X-Forwarded-For` header for client identity. Enable ONLY when
434    /// EdgeGuard sits behind a trusted proxy/load balancer that sets it (e.g. a PaaS
435    /// edge). When false (default) the peer socket address is used, so clients can't
436    /// spoof their IP to defeat per-IP rate limiting or forge access-log entries.
437    pub trust_forwarded_for: bool,
438    /// Private listener port for the internal `/__edgeguard/*` ops endpoints (health,
439    /// readiness, metrics). `0` (default) keeps them on the public port. When non-zero,
440    /// EdgeGuard binds a second, plain-HTTP listener on `admin_addr:admin_port` that serves
441    /// those endpoints, and the public port serves only the proxy (plus the browser-facing CSP
442    /// report sink) — so metrics/health aren't exposed on the internet. Overridden by
443    /// `ADMIN_PORT`. (Point your platform's health check at this port when you enable it.)
444    pub admin_port: u16,
445    /// Address the private admin listener binds when `admin_port` is set. Defaults to
446    /// `127.0.0.1` (same-host only — e.g. a sidecar scraper); set to `0.0.0.0` to expose it on
447    /// a private network interface (rely on your network policy to keep it off the internet).
448    pub admin_addr: String,
449}
450
451#[derive(Debug, Clone, Deserialize)]
452#[serde(default)]
453pub struct AuthCfg {
454    /// "none" | "basic" | "apikey" | "jwt". Selects the gate applied to every proxied
455    /// request; the internal `/__edgeguard/*` endpoints are always exempt.
456    pub mode: String,
457    pub realm: String,
458    /// username -> password. Value may be plaintext (dev) or a `$argon2...` PHC hash.
459    /// Used when `mode = "basic"`.
460    pub users: BTreeMap<String, String>,
461    /// Accepted API keys (compared in constant time). Used when `mode = "apikey"`. A request
462    /// may present a key either as `Authorization: Bearer <key>` or in `api_key_header`.
463    /// Overridable from the env via `EDGEGUARD_API_KEYS` (comma-separated) so keys need not
464    /// live in the config file.
465    pub api_keys: Vec<String>,
466    /// Header carrying the API key (in addition to `Authorization: Bearer`), default
467    /// `X-API-Key`. Used when `mode = "apikey"`.
468    pub api_key_header: String,
469    /// JWT verification policy. Used when `mode = "jwt"`.
470    pub jwt: JwtCfg,
471}
472
473/// JWT bearer-token verification. Either a symmetric `secret` (HS*) or an asymmetric key
474/// (RS*/ES*/PS*) supplied as a static `public_key_pem` or fetched from `jwks_url`.
475#[derive(Debug, Clone, Deserialize)]
476#[serde(default)]
477pub struct JwtCfg {
478    /// Expected signature algorithm, e.g. "HS256", "RS256", "ES256". The token's own `alg`
479    /// header must match this (we never trust the token to pick its own algorithm — that is
480    /// the classic JWT downgrade/`alg=none` foot-gun).
481    pub algorithm: String,
482    /// Shared secret for HS* algorithms. Prefer the `EDGEGUARD_JWT_SECRET` env var over
483    /// putting it in the config file.
484    pub secret: String,
485    /// Static PEM public key (SPKI or PKCS#1) for RS*/ES*/PS* verification, as an
486    /// alternative to `jwks_url`.
487    pub public_key_pem: String,
488    /// JWKS endpoint to fetch verification keys from (RS*/ES*/PS*). Keys are cached and
489    /// selected by the token's `kid`.
490    pub jwks_url: String,
491    /// How long (seconds) to cache a fetched JWKS before refetching. Default 300.
492    pub jwks_cache_secs: u64,
493    /// If set, the token's `iss` claim must equal this.
494    pub issuer: String,
495    /// If set, the token's `aud` claim must contain this.
496    pub audience: String,
497    /// Clock-skew leeway (seconds) applied to `exp`/`nbf` validation. Default 60.
498    pub leeway_secs: u64,
499}
500
501#[derive(Debug, Clone, Deserialize)]
502#[serde(default)]
503pub struct RateLimitCfg {
504    pub enabled: bool,
505    /// Default per-client-IP limit, e.g. "60/min", "10/sec", "1000/hour".
506    pub rate: String,
507    pub burst: u32,
508    /// Per-route overrides. A request whose path starts with `path` uses that route's limit
509    /// (still keyed per client IP) instead of the global one; the longest matching prefix
510    /// wins, so `/api/admin/` can be stricter than `/api/`.
511    pub routes: Vec<RouteRateLimit>,
512    /// An additional limit keyed by the authenticated principal (API-key id or JWT subject)
513    /// rather than IP, so a single credential can't fan out across many IPs. Only applies to
514    /// authenticated requests.
515    pub per_key: PerKeyRateLimit,
516    /// Where limiter state lives: `"local"` (default) is the in-process `governor` limiter (fast,
517    /// no dependency, but per-replica). `"redis"` shares GCRA state across replicas via a Redis
518    /// store, so N instances enforce one global limit. `"memory"` uses the same shared-store code
519    /// path backed by an in-process map (a single-replica/testing backend). All three honor the
520    /// same `rate`/`burst`/route/per-key settings above.
521    pub store: String,
522    /// Redis connection URL for `store = "redis"`, e.g. `redis://host:6379` or (TLS)
523    /// `rediss://host:6379`. Prefer the `EDGEGUARD_REDIS_URL` env var over this file.
524    pub redis_url: String,
525    /// Key prefix/namespace for the shared store, so multiple EdgeGuard deployments can share one
526    /// Redis without colliding. Keys look like `<prefix>:ip:<addr>`.
527    pub redis_prefix: String,
528    /// What to do when the shared store is unreachable. `false` (default) fails **closed** — a
529    /// store error returns `503`, so an outage can't silently disable rate limiting. `true` fails
530    /// **open** — a store error allows the request (favor availability over strict limiting).
531    /// Only relevant for `store = "redis"`.
532    pub fail_open: bool,
533}
534
535/// A per-route rate-limit override (matched by path prefix).
536#[derive(Debug, Clone, Deserialize)]
537#[serde(default)]
538pub struct RouteRateLimit {
539    /// Path prefix this limit applies to, e.g. "/api/".
540    pub path: String,
541    pub rate: String,
542    pub burst: u32,
543}
544
545impl Default for RouteRateLimit {
546    fn default() -> Self {
547        RouteRateLimit {
548            path: String::new(),
549            rate: "60/min".into(),
550            burst: 20,
551        }
552    }
553}
554
555/// Per-principal rate limit (keyed by API-key id / JWT subject).
556#[derive(Debug, Clone, Deserialize)]
557#[serde(default)]
558pub struct PerKeyRateLimit {
559    pub enabled: bool,
560    pub rate: String,
561    pub burst: u32,
562}
563
564impl Default for PerKeyRateLimit {
565    fn default() -> Self {
566        PerKeyRateLimit {
567            enabled: false,
568            rate: "1000/hour".into(),
569            burst: 100,
570        }
571    }
572}
573
574#[derive(Debug, Clone, Deserialize)]
575#[serde(default)]
576pub struct ValidationCfg {
577    /// e.g. "2MiB". Requests with a larger body are rejected with 413.
578    pub max_body: String,
579    /// Cap on the upstream response body EdgeGuard buffers, e.g. "16MiB". "0" disables
580    /// the cap (unbounded). Protects against an upstream OOM-ing the proxy; raise it if
581    /// you proxy large downloads.
582    pub max_response_body: String,
583    /// Max time to wait for the upstream response and to read its body, e.g. "30s",
584    /// "500ms", "2m". "0" disables the timeout. Bounds a stalled upstream so it can't pin a
585    /// handler task indefinitely; on elapse the proxy returns 504.
586    pub upstream_timeout: String,
587    /// Cap on the total size of incoming request headers (sum of name + value bytes), e.g.
588    /// "32KiB". "0" disables the cap (default). Requests over the limit get `431`. This is a
589    /// policy limit enforced by EdgeGuard on top of hyper's own transport-level header cap.
590    pub max_header_bytes: String,
591    /// Allowed HTTP methods; empty list means allow all.
592    pub allow_methods: Vec<String>,
593    /// Stream (don't buffer) responses whose `Content-Type` is `text/event-stream`. Off by
594    /// default: the proxy normally buffers the whole upstream body so it can cap size
595    /// (`max_response_body`) and account exact egress bytes. That buffering defeats Server-Sent
596    /// Events / chunked streaming — the client only sees the body once the upstream finishes.
597    /// Turn this on to forward SSE responses frame-by-frame as they arrive (preserving
598    /// time-to-first-byte). When a response is streamed this way the `max_response_body` cap and
599    /// the body-read deadline don't apply (the connect/first-byte `upstream_timeout` still
600    /// does); egress bytes are tallied as frames flow. Non-SSE responses are unaffected.
601    pub stream_passthrough: bool,
602    /// Tunnel WebSocket (and other `Upgrade`) connections through to the upstream. Off by
603    /// default: the normal path strips the hop-by-hop `Upgrade`/`Connection` headers, so an
604    /// upgrade request would be forwarded as a plain HTTP request and the handshake would fail.
605    /// When on, an authenticated, rate-limited upgrade request is forwarded *with* its upgrade
606    /// headers and, on the upstream's `101 Switching Protocols`, EdgeGuard splices the two
607    /// connections into a raw bidirectional tunnel. Response hardening / WAF body inspection
608    /// don't apply to a tunneled connection (there is no buffered response). Non-upgrade requests
609    /// are unaffected.
610    pub websocket_passthrough: bool,
611    /// gzip-compress responses for clients that send `Accept-Encoding: gzip`. Off by default.
612    /// Skips already-compressed content types and (always) `text/event-stream`, so SSE streaming
613    /// is never buffered by the compressor. Applied at the listener, so toggling it needs a
614    /// restart (it is not part of the hot-reloadable policy).
615    pub compress_responses: bool,
616}
617
618#[derive(Debug, Clone, Deserialize)]
619#[serde(default)]
620pub struct HeadersCfg {
621    pub hsts: bool,
622    pub csp: String,
623    /// Send the CSP as `Content-Security-Policy-Report-Only` instead of enforcing it. Lets
624    /// you roll out / tighten a policy by collecting violations first without breaking the
625    /// page.
626    pub csp_report_only: bool,
627    /// If set, a `report-uri <value>` directive is appended to the CSP so browsers POST
628    /// violation reports there. Point it at EdgeGuard's own sink ("/__edgeguard/csp-report")
629    /// to have them logged, or at any external collector.
630    pub csp_report_uri: String,
631    pub referrer_policy: String,
632    pub permissions_policy: String,
633    pub frame_options: String,
634    pub force_secure_cookies: bool,
635    /// Add `HttpOnly` to `Set-Cookie` responses that lack it. On by default. Turn off (or use
636    /// `httponly_cookie_exempt`) for apps that intentionally expose a cookie to JavaScript —
637    /// e.g. a double-submit CSRF token the frontend must read from `document.cookie`.
638    pub httponly_cookies: bool,
639    /// Cookie NAMES that must never get `HttpOnly`, even when `httponly_cookies` is on. The
640    /// surgical exemption for a readable double-submit CSRF cookie, e.g. `["doneyet_csrf"]`.
641    /// Names match exactly (cookies are case-sensitive).
642    pub httponly_cookie_exempt: Vec<String>,
643    /// Response headers to strip (case-insensitive), e.g. ["Server", "X-Powered-By"].
644    pub strip: Vec<String>,
645}
646
647impl Default for ServerCfg {
648    fn default() -> Self {
649        ServerCfg {
650            port: 8080,
651            app_port: 3000,
652            upstream: String::new(),
653            trust_forwarded_for: false,
654            admin_port: 0,
655            admin_addr: "127.0.0.1".into(),
656        }
657    }
658}
659
660impl Default for AuthCfg {
661    fn default() -> Self {
662        AuthCfg {
663            mode: "none".into(),
664            realm: "EdgeGuard".into(),
665            users: BTreeMap::new(),
666            api_keys: vec![],
667            api_key_header: "X-API-Key".into(),
668            jwt: JwtCfg::default(),
669        }
670    }
671}
672
673impl Default for JwtCfg {
674    fn default() -> Self {
675        JwtCfg {
676            algorithm: "HS256".into(),
677            secret: String::new(),
678            public_key_pem: String::new(),
679            jwks_url: String::new(),
680            jwks_cache_secs: 300,
681            issuer: String::new(),
682            audience: String::new(),
683            leeway_secs: 60,
684        }
685    }
686}
687
688impl Default for RateLimitCfg {
689    fn default() -> Self {
690        RateLimitCfg {
691            enabled: true,
692            rate: "60/min".into(),
693            burst: 20,
694            routes: vec![],
695            per_key: PerKeyRateLimit::default(),
696            store: "local".into(),
697            redis_url: "redis://127.0.0.1:6379".into(),
698            redis_prefix: "edgeguard".into(),
699            fail_open: false,
700        }
701    }
702}
703
704impl Default for ValidationCfg {
705    fn default() -> Self {
706        ValidationCfg {
707            max_body: "2MiB".into(),
708            max_response_body: "0".into(),
709            upstream_timeout: "30s".into(),
710            max_header_bytes: "0".into(),
711            allow_methods: vec![],
712            stream_passthrough: false,
713            websocket_passthrough: false,
714            compress_responses: false,
715        }
716    }
717}
718
719impl Default for HeadersCfg {
720    fn default() -> Self {
721        HeadersCfg {
722            hsts: true,
723            csp: "default-src 'self'".into(),
724            csp_report_only: false,
725            csp_report_uri: String::new(),
726            referrer_policy: "no-referrer".into(),
727            permissions_policy: "geolocation=(), microphone=(), camera=()".into(),
728            frame_options: "DENY".into(),
729            force_secure_cookies: true,
730            httponly_cookies: true,
731            httponly_cookie_exempt: Vec::new(),
732            strip: vec!["Server".into(), "X-Powered-By".into()],
733        }
734    }
735}
736
737/// TLS termination. When `enabled`, EdgeGuard serves HTTPS on the public port using a
738/// certificate either loaded from `cert_path`/`key_path` or obtained automatically via ACME.
739/// All-default fields (disabled, empty paths, default ACME) so `Default` is derivable.
740#[derive(Debug, Clone, Default, Deserialize)]
741#[serde(default)]
742pub struct TlsCfg {
743    pub enabled: bool,
744    /// PEM certificate chain (leaf first). When ACME is enabled this is where the obtained
745    /// certificate is written/read.
746    pub cert_path: String,
747    /// PEM private key (PKCS#8/PKCS#1/SEC1).
748    pub key_path: String,
749    pub acme: AcmeCfg,
750}
751
752/// Automatic certificate management (ACME / Let's Encrypt) via the HTTP-01 challenge. The
753/// obtained certificate is written to `TlsCfg::cert_path`/`key_path` and served by the TLS
754/// listener. Issuance runs at startup only when no certificate exists at `cert_path`;
755/// there is **no automatic renewal yet** (see docs/ROADMAP.md) — delete the cert/key files
756/// and restart to re-issue.
757#[derive(Debug, Clone, Deserialize)]
758#[serde(default)]
759pub struct AcmeCfg {
760    pub enabled: bool,
761    /// Domains to request a certificate for (the first is the primary CN).
762    pub domains: Vec<String>,
763    /// Contact email for the ACME account (registration + expiry notices).
764    pub email: String,
765    /// ACME directory URL. Defaults to Let's Encrypt **staging** so a misconfiguration can't
766    /// burn the strict production rate limits; switch to production explicitly.
767    pub directory_url: String,
768    /// Directory for the cached ACME account key (so renewals reuse the same account).
769    pub cache_dir: String,
770    /// You must set this to `true` to signify acceptance of the ACME provider's Terms of
771    /// Service; EdgeGuard refuses to register otherwise.
772    pub accept_tos: bool,
773}
774
775impl Default for AcmeCfg {
776    fn default() -> Self {
777        AcmeCfg {
778            enabled: false,
779            domains: vec![],
780            email: String::new(),
781            // Let's Encrypt staging — safe default; see the field doc.
782            directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".into(),
783            cache_dir: "./acme".into(),
784            accept_tos: false,
785        }
786    }
787}
788
789/// WAF-lite input inspection (Phase 4 / v2). Screens a request for common attack signatures
790/// before it is forwarded, using built-in heuristic rulesets (SQLi/XSS/path-traversal) plus
791/// any operator-defined deny patterns. Disabled by default — these are heuristics, so the
792/// intended rollout is `report` (log + count matches without blocking) until the operator is
793/// confident, then `block` (return `403`). Compiled into a `crate::waf::WafEngine`.
794#[derive(Debug, Clone, Deserialize)]
795#[serde(default)]
796pub struct WafCfg {
797    /// "off" (default) | "report" | "block". `report` evaluates rules and logs/counts matches
798    /// but forwards the request anyway; `block` rejects a matching request with `403`.
799    pub mode: String,
800    /// Enable the built-in SQL-injection heuristic ruleset.
801    pub sqli: bool,
802    /// Enable the built-in cross-site-scripting heuristic ruleset.
803    pub xss: bool,
804    /// Enable the built-in path-traversal heuristic ruleset.
805    pub path_traversal: bool,
806    /// Inspect the request path + query string (matched raw and percent-decoded). Default true.
807    pub inspect_path: bool,
808    /// Inspect request header values. Off by default: header bytes (cookies, tokens, opaque
809    /// blobs) are noisy and prone to false positives.
810    pub inspect_headers: bool,
811    /// Inspect the request body (already capped by `validation.max_body`). Off by default.
812    pub inspect_body: bool,
813    /// Operator-defined deny patterns, evaluated alongside the enabled built-in rulesets.
814    pub rules: Vec<WafRule>,
815}
816
817/// A single operator-defined WAF deny pattern (a `[[waf.rules]]` entry).
818#[derive(Debug, Clone, Deserialize)]
819#[serde(default)]
820pub struct WafRule {
821    /// Identifier reported in logs/metrics when this rule matches (defaults to `custom-<n>`).
822    pub id: String,
823    /// Regular expression (RE2 syntax: linear-time, no backreferences/lookaround, so it can't
824    /// ReDoS the proxy). A request matching it in any targeted location is treated as a hit.
825    pub pattern: String,
826    /// Request location to match against: "path" (path+query, default), "headers", "body", or
827    /// "all". A location is only examined when its `inspect_*` flag above is also enabled.
828    pub target: String,
829}
830
831impl Default for WafCfg {
832    fn default() -> Self {
833        WafCfg {
834            mode: "off".into(),
835            sqli: true,
836            xss: true,
837            path_traversal: true,
838            inspect_path: true,
839            inspect_headers: false,
840            inspect_body: false,
841            rules: vec![],
842        }
843    }
844}
845
846impl Default for WafRule {
847    fn default() -> Self {
848        WafRule {
849            id: String::new(),
850            pattern: String::new(),
851            target: "path".into(),
852        }
853    }
854}
855
856/// A per-path-prefix upstream override (a `[[upstreams]]` entry). Requests whose path starts with
857/// `path` are forwarded to `target` instead of the default `server.upstream`; the longest matching
858/// prefix wins. This is deliberately a *static prefix map* for the common "static frontend + `/api`
859/// backend" shape — not a gateway: no service discovery, load balancing, health-based routing, or
860/// request rewriting (the path is forwarded unchanged). For those, put EdgeGuard behind a real
861/// gateway/mesh.
862#[derive(Debug, Clone, Default, Deserialize)]
863#[serde(default)]
864pub struct UpstreamRoute {
865    /// Path prefix this upstream applies to, e.g. `/api/`.
866    pub path: String,
867    /// Upstream base URL for this prefix, e.g. `http://api.internal:4000`.
868    pub target: String,
869}
870
871/// IP allow/deny lists, matched against the resolved client IP (the same IP rate limiting keys
872/// on — so behind a trusted proxy, set `server.trust_forwarded_for` for this to see the real
873/// client). Both lists accept plain IPs (`203.0.113.7`, `::1`) and CIDR ranges
874/// (`10.0.0.0/8`, `2001:db8::/32`). `deny` wins over `allow`; a non-empty `allow` means
875/// "only these may connect". Both empty (the default) = allow all. Compiled into a
876/// `crate::access::AccessPolicy`; an unparseable entry fails at startup/reload.
877#[derive(Debug, Clone, Default, Deserialize)]
878#[serde(default)]
879pub struct AccessCfg {
880    /// CIDRs/IPs allowed in. Empty = allow all (subject to `deny`).
881    pub allow: Vec<String>,
882    /// CIDRs/IPs always rejected (takes precedence over `allow`).
883    pub deny: Vec<String>,
884}
885
886/// Cross-Origin Resource Sharing policy. A drop-in front door commonly sits in front of an app
887/// whose browser frontend is served from a *different* origin (a separate static host, a
888/// preview URL, `localhost:5173` in dev); without CORS those `fetch` calls are blocked by the
889/// browser. When `enabled`, EdgeGuard answers preflight `OPTIONS` requests itself (before auth —
890/// preflights carry no credentials) and adds the matching `Access-Control-*` headers to actual
891/// responses. Off by default: opening cross-origin access is a deliberate choice. Compiled into
892/// a `crate::cors::CorsPolicy`.
893#[derive(Debug, Clone, Deserialize)]
894#[serde(default)]
895pub struct CorsCfg {
896    pub enabled: bool,
897    /// Allowed request origins, matched exactly (scheme + host + port), e.g.
898    /// `["https://app.example.com"]`. The single entry `["*"]` allows any origin — but a
899    /// wildcard cannot be combined with `allow_credentials = true` (the Fetch spec forbids it),
900    /// so that combination is rejected at startup.
901    pub allow_origins: Vec<String>,
902    /// Methods advertised in the preflight `Access-Control-Allow-Methods`. Empty = a sensible
903    /// default set (`GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD`).
904    pub allow_methods: Vec<String>,
905    /// Request headers advertised in `Access-Control-Allow-Headers`. Empty = reflect whatever the
906    /// browser asks for in `Access-Control-Request-Headers` (the common, permissive default).
907    pub allow_headers: Vec<String>,
908    /// Response headers the browser is allowed to read, advertised in
909    /// `Access-Control-Expose-Headers`. Empty = none beyond the CORS-safelisted set.
910    pub expose_headers: Vec<String>,
911    /// Send `Access-Control-Allow-Credentials: true` so the browser may send cookies / HTTP auth.
912    /// Requires explicit `allow_origins` (no `"*"`).
913    pub allow_credentials: bool,
914    /// How long a browser may cache the preflight result, e.g. `"600s"`, `"1h"`. `"0"` omits the
915    /// `Access-Control-Max-Age` header (the browser uses its own short default).
916    pub max_age: String,
917}
918
919impl Default for CorsCfg {
920    fn default() -> Self {
921        CorsCfg {
922            enabled: false,
923            allow_origins: vec![],
924            allow_methods: vec![],
925            allow_headers: vec![],
926            expose_headers: vec![],
927            allow_credentials: false,
928            max_age: "600s".into(),
929        }
930    }
931}
932
933impl Config {
934    /// Load defaults, overlay an optional TOML file, then apply env overrides.
935    pub fn load(path: Option<&str>) -> Result<Config> {
936        let mut cfg = if let Some(p) = path {
937            let raw =
938                std::fs::read_to_string(p).with_context(|| format!("reading config file {p}"))?;
939            toml::from_str::<Config>(&raw).with_context(|| format!("parsing config file {p}"))?
940        } else {
941            Config::default()
942        };
943
944        if let Ok(p) = env::var("PORT") {
945            if let Ok(v) = p.parse() {
946                cfg.server.port = v;
947            }
948        }
949        if let Ok(p) = env::var("APP_PORT") {
950            if let Ok(v) = p.parse() {
951                cfg.server.app_port = v;
952            }
953        }
954        if let Ok(p) = env::var("ADMIN_PORT") {
955            if let Ok(v) = p.parse() {
956                cfg.server.admin_port = v;
957            }
958        }
959        if let Ok(u) = env::var("UPSTREAM") {
960            if !u.is_empty() {
961                cfg.server.upstream = u;
962            }
963        }
964        // Keep secrets out of the config file: let the environment supply them, either directly
965        // (`EDGEGUARD_JWT_SECRET`) or from a file (`EDGEGUARD_JWT_SECRET_FILE`) for Docker/K8s
966        // secret mounts. The direct variable wins when both are set; see `env_or_file`.
967        if let Some(s) = env_or_file("EDGEGUARD_JWT_SECRET")? {
968            cfg.auth.jwt.secret = s;
969        }
970        if let Some(u) = env_or_file("EDGEGUARD_REDIS_URL")? {
971            cfg.ratelimit.redis_url = u;
972        }
973        if let Some(keys) = env_or_file("EDGEGUARD_API_KEYS")? {
974            let keys: Vec<String> = keys
975                .split(',')
976                .map(|k| k.trim().to_string())
977                .filter(|k| !k.is_empty())
978                .collect();
979            if !keys.is_empty() {
980                cfg.auth.api_keys = keys;
981            }
982        }
983        if let Some(t) = env_or_file("EDGEGUARD_CP_EDGE_TOKEN")? {
984            cfg.control_plane.edge_token = t;
985        }
986        if let Some(u) = env_or_file("EDGEGUARD_CP_URL")? {
987            cfg.control_plane.url = u;
988        }
989        if let Ok(v) = env::var("EDGEGUARD_CP_QUOTA_ENFORCE") {
990            // Only an explicit, recognized value overrides the file config; an empty value is a
991            // no-op and a typo is a hard error rather than silently disabling a security control.
992            match v.trim().to_ascii_lowercase().as_str() {
993                "" => {}
994                "1" | "true" | "yes" | "on" => cfg.control_plane.enforce_quota = true,
995                "0" | "false" | "no" | "off" => cfg.control_plane.enforce_quota = false,
996                other => anyhow::bail!(
997                    "invalid EDGEGUARD_CP_QUOTA_ENFORCE value {other:?}; expected 1/true/yes/on or 0/false/no/off"
998                ),
999            }
1000        }
1001        Ok(cfg)
1002    }
1003
1004    /// Produce an effective config by overlaying a control-plane-pushed *policy* document onto
1005    /// this (local) config: the policy sections
1006    /// (`auth`/`ratelimit`/`validation`/`headers`/`waf`/`access`/`cors`) come from the pushed TOML;
1007    /// `server`/`tls`/`upstreams`/`telemetry`/`control_plane` stay local (the control plane manages
1008    /// security policy, not this edge's listener/plumbing/topology). The result feeds the normal
1009    /// `build_runtime` + hot-swap path, so a malformed policy is rejected like any bad reload.
1010    pub fn with_policy_from(&self, policy_toml: &str) -> Result<Config> {
1011        let p: Config =
1012            toml::from_str(policy_toml).context("parsing control-plane policy document")?;
1013        Ok(Config {
1014            server: self.server.clone(),
1015            tls: self.tls.clone(),
1016            control_plane: self.control_plane.clone(),
1017            // Upstream topology is edge-local (like `server`), not pushed policy.
1018            upstreams: self.upstreams.clone(),
1019            auth: p.auth,
1020            ratelimit: p.ratelimit,
1021            validation: p.validation,
1022            headers: p.headers,
1023            waf: p.waf,
1024            access: p.access,
1025            cors: p.cors,
1026            // LLM metering is a policy section (the control plane can push a fleet-wide price
1027            // book) — except `telemetry`, which (like `alerts` below) is edge-local operational
1028            // config, not fleet-pushed policy: preserve it from the edge rather than letting a
1029            // pushed policy silently repoint `endpoint`/`capture_content`/`sample_rate`.
1030            llm: LlmCfg {
1031                telemetry: self.llm.telemetry.clone(),
1032                ..p.llm
1033            },
1034            // Alerting is edge-local operational config (its webhook is a local secret/endpoint), not
1035            // fleet-pushed policy — carry it from the edge, like `server`/`tls`.
1036            alerts: self.alerts.clone(),
1037        })
1038    }
1039
1040    /// The upstream base URL EdgeGuard forwards to, e.g. "http://127.0.0.1:3000".
1041    pub fn upstream_base(&self) -> String {
1042        if self.server.upstream.is_empty() {
1043            format!("http://127.0.0.1:{}", self.server.app_port)
1044        } else {
1045            self.server.upstream.trim_end_matches('/').to_string()
1046        }
1047    }
1048
1049    /// The `(host, port)` EdgeGuard probes for readiness, mirroring [`Self::upstream_base`]:
1050    /// co-process mode probes `127.0.0.1:app_port`; an explicit upstream URL is parsed,
1051    /// defaulting the port from the scheme. Returns `None` if the URL carries no usable
1052    /// host, so the readiness check reports "not ready" rather than panicking.
1053    pub fn upstream_probe_addr(&self) -> Option<(String, u16)> {
1054        if self.server.upstream.is_empty() {
1055            Some(("127.0.0.1".to_string(), self.server.app_port))
1056        } else {
1057            parse_host_port(&self.server.upstream)
1058        }
1059    }
1060}
1061
1062/// Extract `(host, port)` from an upstream URL like `http://host:3000/base`. Only the
1063/// scheme (for the default port), host, and port are needed — any path is ignored. Handles
1064/// bracketed IPv6 literals (`http://[::1]:3000`). This is deliberately small rather than a
1065/// full URL parser; the proxy itself is HTTP-only in v0.
1066fn parse_host_port(url: &str) -> Option<(String, u16)> {
1067    let (default_port, rest) = if let Some(r) = url.strip_prefix("http://") {
1068        (80u16, r)
1069    } else if let Some(r) = url.strip_prefix("https://") {
1070        (443u16, r)
1071    } else {
1072        (80u16, url)
1073    };
1074    // Authority is everything up to the first '/'; drop any `user:pass@` userinfo.
1075    let authority = rest.split('/').next().unwrap_or(rest);
1076    let authority = authority.rsplit('@').next().unwrap_or(authority);
1077    if authority.is_empty() {
1078        return None;
1079    }
1080    // Bracketed IPv6 literal: `[::1]` or `[::1]:port`.
1081    if let Some(after) = authority.strip_prefix('[') {
1082        let (host, tail) = after.split_once(']')?;
1083        let port = match tail.strip_prefix(':') {
1084            Some(p) => p.parse().ok()?,
1085            None => default_port,
1086        };
1087        return Some((host.to_string(), port));
1088    }
1089    match authority.rsplit_once(':') {
1090        // Reject an empty host (e.g. `http://:3000`) rather than deferring the failure to a
1091        // connect call — the "usable host" contract is checked here.
1092        Some((host, port)) if !host.is_empty() => Some((host.to_string(), port.parse().ok()?)),
1093        Some(_) => None,
1094        None => Some((authority.to_string(), default_port)),
1095    }
1096}
1097
1098/// Resolve a secret from the environment, supporting a `*_FILE` indirection for Docker/K8s
1099/// secret mounts (`EDGEGUARD_JWT_SECRET` *or* `EDGEGUARD_JWT_SECRET_FILE` pointing at a file
1100/// whose contents are the secret). The direct variable takes precedence when both are set; a
1101/// `*_FILE` that can't be read is a hard error (a misconfigured secret mount must fail loudly,
1102/// not silently fall back to no secret). A trailing newline (the common `echo`/editor artifact)
1103/// is trimmed. Returns `None` when neither is set / both are empty, so the caller keeps the
1104/// file/default value.
1105fn env_or_file(name: &str) -> Result<Option<String>> {
1106    if let Ok(v) = env::var(name) {
1107        if !v.is_empty() {
1108            return Ok(Some(v));
1109        }
1110    }
1111    let file_var = format!("{name}_FILE");
1112    if let Ok(path) = env::var(&file_var) {
1113        if !path.is_empty() {
1114            let content = std::fs::read_to_string(&path)
1115                .with_context(|| format!("reading {file_var} ({path})"))?;
1116            let trimmed = content.trim_end_matches(['\n', '\r']);
1117            if !trimmed.is_empty() {
1118                return Ok(Some(trimmed.to_string()));
1119            }
1120        }
1121    }
1122    Ok(None)
1123}
1124
1125/// Parse a human size like "2MiB", "512KB", "1048576" into bytes.
1126pub fn parse_size(s: &str) -> Result<usize> {
1127    let s = s.trim();
1128    let (num, mult): (&str, usize) = if let Some(n) = s.strip_suffix("GiB") {
1129        (n, 1024 * 1024 * 1024)
1130    } else if let Some(n) = s.strip_suffix("MiB") {
1131        (n, 1024 * 1024)
1132    } else if let Some(n) = s.strip_suffix("KiB") {
1133        (n, 1024)
1134    } else if let Some(n) = s.strip_suffix("GB") {
1135        (n, 1_000_000_000)
1136    } else if let Some(n) = s.strip_suffix("MB") {
1137        (n, 1_000_000)
1138    } else if let Some(n) = s.strip_suffix("KB") {
1139        (n, 1_000)
1140    } else if let Some(n) = s.strip_suffix('B') {
1141        (n, 1)
1142    } else {
1143        (s, 1)
1144    };
1145    let n: usize = num
1146        .trim()
1147        .parse()
1148        .with_context(|| format!("invalid size: {s}"))?;
1149    n.checked_mul(mult)
1150        .with_context(|| format!("size too large: {s}"))
1151}
1152
1153/// Parse a rate like "60/min" into (count, period).
1154pub fn parse_rate(s: &str) -> Result<(u32, Duration)> {
1155    let (n, unit) = s
1156        .split_once('/')
1157        .with_context(|| format!("invalid rate (expected N/unit): {s}"))?;
1158    let count: u32 = n
1159        .trim()
1160        .parse()
1161        .with_context(|| format!("invalid rate count: {s}"))?;
1162    let period = match unit.trim() {
1163        "s" | "sec" | "second" => Duration::from_secs(1),
1164        "m" | "min" | "minute" => Duration::from_secs(60),
1165        "h" | "hour" => Duration::from_secs(3600),
1166        other => anyhow::bail!("unsupported rate unit: {other}"),
1167    };
1168    Ok((count, period))
1169}
1170
1171/// Parse a timeout like "30s", "500ms", "2m", or a bare number of seconds ("45"). "0"
1172/// yields a zero duration, which callers treat as "disabled".
1173pub fn parse_duration(s: &str) -> Result<Duration> {
1174    let s = s.trim();
1175    // Order matters: check "ms" before the single-char "s"/"m" suffixes.
1176    if let Some(n) = s.strip_suffix("ms") {
1177        let ms: u64 = n
1178            .trim()
1179            .parse()
1180            .with_context(|| format!("invalid duration: {s}"))?;
1181        Ok(Duration::from_millis(ms))
1182    } else if let Some(n) = s.strip_suffix('s') {
1183        let secs: u64 = n
1184            .trim()
1185            .parse()
1186            .with_context(|| format!("invalid duration: {s}"))?;
1187        Ok(Duration::from_secs(secs))
1188    } else if let Some(n) = s.strip_suffix('m') {
1189        let mins: u64 = n
1190            .trim()
1191            .parse()
1192            .with_context(|| format!("invalid duration: {s}"))?;
1193        let secs = mins
1194            .checked_mul(60)
1195            .with_context(|| format!("duration too large: {s}"))?;
1196        Ok(Duration::from_secs(secs))
1197    } else if let Some(n) = s.strip_suffix('h') {
1198        let hours: u64 = n
1199            .trim()
1200            .parse()
1201            .with_context(|| format!("invalid duration: {s}"))?;
1202        let secs = hours
1203            .checked_mul(3_600)
1204            .with_context(|| format!("duration too large: {s}"))?;
1205        Ok(Duration::from_secs(secs))
1206    } else if let Some(n) = s.strip_suffix('d') {
1207        let days: u64 = n
1208            .trim()
1209            .parse()
1210            .with_context(|| format!("invalid duration: {s}"))?;
1211        let secs = days
1212            .checked_mul(86_400)
1213            .with_context(|| format!("duration too large: {s}"))?;
1214        Ok(Duration::from_secs(secs))
1215    } else {
1216        let secs: u64 = s
1217            .parse()
1218            .with_context(|| format!("invalid duration: {s}"))?;
1219        Ok(Duration::from_secs(secs))
1220    }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225    use super::*;
1226
1227    #[test]
1228    fn parse_size_units_and_plain_bytes() {
1229        assert_eq!(parse_size("0").unwrap(), 0);
1230        assert_eq!(parse_size("1048576").unwrap(), 1_048_576);
1231        assert_eq!(parse_size("512B").unwrap(), 512);
1232        assert_eq!(parse_size("1KB").unwrap(), 1_000);
1233        assert_eq!(parse_size("1KiB").unwrap(), 1_024);
1234        assert_eq!(parse_size("2MiB").unwrap(), 2 * 1024 * 1024);
1235        assert_eq!(parse_size("16MiB").unwrap(), 16 * 1024 * 1024);
1236        assert_eq!(parse_size("1GiB").unwrap(), 1024 * 1024 * 1024);
1237        // surrounding / internal whitespace is tolerated
1238        assert_eq!(parse_size("  4 MiB ").unwrap(), 4 * 1024 * 1024);
1239    }
1240
1241    #[test]
1242    fn parse_size_rejects_garbage_and_overflow() {
1243        assert!(parse_size("abc").is_err());
1244        assert!(parse_size("MiB").is_err());
1245        // would overflow usize -> Err, not a silent wrap
1246        assert!(parse_size("99999999999999999999GiB").is_err());
1247    }
1248
1249    #[test]
1250    fn parse_rate_counts_and_units() {
1251        assert_eq!(parse_rate("60/min").unwrap(), (60, Duration::from_secs(60)));
1252        assert_eq!(parse_rate("10/sec").unwrap(), (10, Duration::from_secs(1)));
1253        assert_eq!(
1254            parse_rate("1000/hour").unwrap(),
1255            (1000, Duration::from_secs(3600))
1256        );
1257        // short and long unit spellings, plus tolerated whitespace
1258        assert_eq!(parse_rate(" 5 / m ").unwrap(), (5, Duration::from_secs(60)));
1259    }
1260
1261    #[test]
1262    fn parse_rate_rejects_garbage() {
1263        assert!(parse_rate("60").is_err()); // no unit
1264        assert!(parse_rate("x/min").is_err()); // bad count
1265        assert!(parse_rate("60/year").is_err()); // bad unit
1266    }
1267
1268    #[test]
1269    fn probe_addr_defaults_to_app_port_in_coprocess_mode() {
1270        let cfg = Config::default();
1271        assert_eq!(
1272            cfg.upstream_probe_addr(),
1273            Some(("127.0.0.1".to_string(), cfg.server.app_port))
1274        );
1275    }
1276
1277    #[test]
1278    fn parse_host_port_handles_schemes_paths_and_ipv6() {
1279        assert_eq!(
1280            parse_host_port("http://127.0.0.1:3000"),
1281            Some(("127.0.0.1".to_string(), 3000))
1282        );
1283        // a trailing path is ignored
1284        assert_eq!(
1285            parse_host_port("http://app.internal:8080/health"),
1286            Some(("app.internal".to_string(), 8080))
1287        );
1288        // port defaults from the scheme
1289        assert_eq!(
1290            parse_host_port("https://example.com"),
1291            Some(("example.com".to_string(), 443))
1292        );
1293        assert_eq!(
1294            parse_host_port("http://example.com"),
1295            Some(("example.com".to_string(), 80))
1296        );
1297        // bracketed IPv6 literal, with and without an explicit port
1298        assert_eq!(
1299            parse_host_port("http://[::1]:3000"),
1300            Some(("::1".to_string(), 3000))
1301        );
1302        assert_eq!(
1303            parse_host_port("http://[2001:db8::1]"),
1304            Some(("2001:db8::1".to_string(), 80))
1305        );
1306    }
1307
1308    #[test]
1309    fn parse_host_port_rejects_empty_or_unusable_host() {
1310        // empty host (port only) is not a usable probe target
1311        assert_eq!(parse_host_port("http://:3000"), None);
1312        // non-numeric port
1313        assert_eq!(parse_host_port("http://host:notaport"), None);
1314    }
1315
1316    #[test]
1317    fn parse_duration_units_and_bare_seconds() {
1318        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
1319        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
1320        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
1321        assert_eq!(parse_duration("3h").unwrap(), Duration::from_secs(10_800));
1322        assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800));
1323        assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
1324        // "0" disables (zero duration); callers map it to "no timeout"
1325        assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
1326        assert_eq!(parse_duration("  10s ").unwrap(), Duration::from_secs(10));
1327    }
1328
1329    #[test]
1330    fn with_policy_from_keeps_local_plumbing_takes_policy() {
1331        let mut local = Config::default();
1332        local.server.port = 9999;
1333        local.server.upstream = "http://up:1".into();
1334        local.control_plane.enabled = true;
1335        // A pushed policy that changes auth + disables rate limiting.
1336        let policy = "[auth]\nmode = \"apikey\"\n\n[ratelimit]\nenabled = false\n";
1337        let merged = local.with_policy_from(policy).unwrap();
1338        // Local server / control-plane settings are preserved...
1339        assert_eq!(merged.server.port, 9999);
1340        assert_eq!(merged.server.upstream, "http://up:1");
1341        assert!(merged.control_plane.enabled);
1342        // ...while the policy sections are taken from the pushed document.
1343        assert_eq!(merged.auth.mode, "apikey");
1344        assert!(!merged.ratelimit.enabled);
1345    }
1346
1347    #[test]
1348    fn with_policy_from_keeps_llm_telemetry_edge_local() {
1349        // Regression: `llm: p.llm` used to take the pushed policy's `llm.telemetry` wholesale,
1350        // silently repointing `endpoint`/`capture_content`/`sample_rate` even though telemetry
1351        // is documented as edge-local operational config (like `alerts`), not fleet-pushed
1352        // policy — a pushed policy could redirect span data to an attacker-controlled endpoint.
1353        let mut local = Config::default();
1354        local.llm.telemetry.enabled = true;
1355        local.llm.telemetry.endpoint = "http://local-collector:4318/v1/traces".into();
1356        local.llm.telemetry.capture_content = false;
1357        // A pushed policy that tries to repoint telemetry AND legitimately updates the price book.
1358        let policy = "[llm]\non_unpriced_model = \"block\"\n\n[llm.telemetry]\nenabled = true\nendpoint = \"http://evil:4318/v1/traces\"\ncapture_content = true\n";
1359        let merged = local.with_policy_from(policy).unwrap();
1360        // Telemetry stayed exactly as configured at the edge...
1361        assert_eq!(
1362            merged.llm.telemetry.endpoint,
1363            "http://local-collector:4318/v1/traces"
1364        );
1365        assert!(!merged.llm.telemetry.capture_content);
1366        // ...while the rest of `llm` still took the pushed policy.
1367        assert_eq!(merged.llm.on_unpriced_model, "block");
1368    }
1369
1370    #[test]
1371    fn with_policy_from_rejects_bad_toml() {
1372        assert!(Config::default()
1373            .with_policy_from("not = valid = toml")
1374            .is_err());
1375    }
1376
1377    #[test]
1378    fn parse_duration_rejects_garbage() {
1379        assert!(parse_duration("abc").is_err());
1380        assert!(parse_duration("10x").is_err());
1381        assert!(parse_duration("s").is_err());
1382    }
1383
1384    #[test]
1385    fn env_or_file_reads_file_trims_newline_and_prefers_direct() {
1386        // A uniquely-named var so this doesn't collide with any real config key or another test.
1387        let name = "EDGEGUARD_TEST_SECRET_QZX";
1388        let file_var = format!("{name}_FILE");
1389        let path = std::env::temp_dir().join("edgeguard_test_secret_qzx.txt");
1390        std::fs::write(&path, "s3cr3t\n").unwrap();
1391
1392        // No direct var, only *_FILE -> read the file (trailing newline trimmed).
1393        std::env::remove_var(name);
1394        std::env::set_var(&file_var, &path);
1395        assert_eq!(env_or_file(name).unwrap().as_deref(), Some("s3cr3t"));
1396
1397        // Direct var set -> it wins over the file.
1398        std::env::set_var(name, "direct");
1399        assert_eq!(env_or_file(name).unwrap().as_deref(), Some("direct"));
1400
1401        // Neither set -> None (caller keeps the file/default value).
1402        std::env::remove_var(name);
1403        std::env::remove_var(&file_var);
1404        assert_eq!(env_or_file(name).unwrap(), None);
1405
1406        // A *_FILE pointing at a missing path is a hard error, not a silent fallback.
1407        std::env::set_var(&file_var, "/nonexistent/edgeguard/secret");
1408        assert!(env_or_file(name).is_err());
1409        std::env::remove_var(&file_var);
1410
1411        let _ = std::fs::remove_file(&path);
1412    }
1413}