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}