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