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 "managed mode": pull policy from / report usage to a remote control plane. Off
21 /// by default; the edge is a standalone proxy unless this is configured.
22 pub control_plane: ControlPlaneCfg,
23}
24
25/// Managed-mode settings: when `enabled`, the edge pulls its policy from a remote control plane
26/// (and hot-reloads it), reports usage deltas, and forwards CSP reports. The policy the control
27/// plane pushes is the *policy subset* (auth/ratelimit/validation/headers/waf) — the edge keeps
28/// its own local `server`/`tls`. The edge token is a secret, so prefer `EDGEGUARD_CP_EDGE_TOKEN`.
29#[derive(Debug, Clone, Deserialize)]
30#[serde(default)]
31pub struct ControlPlaneCfg {
32 pub enabled: bool,
33 /// Base URL of the control plane, e.g. `https://cp.example`.
34 pub url: String,
35 /// This edge's tenant id at the control plane.
36 pub tenant_id: String,
37 /// Per-tenant edge token (Bearer). Prefer `EDGEGUARD_CP_EDGE_TOKEN`.
38 pub edge_token: String,
39 /// How often to poll for policy, e.g. `"30s"`.
40 pub poll_interval: String,
41 /// How often to flush a usage delta, e.g. `"60s"`.
42 pub report_interval: String,
43 /// Forward received CSP reports to the control plane (default true).
44 pub forward_csp: bool,
45}
46
47impl Default for ControlPlaneCfg {
48 fn default() -> Self {
49 ControlPlaneCfg {
50 enabled: false,
51 url: String::new(),
52 tenant_id: String::new(),
53 edge_token: String::new(),
54 poll_interval: "30s".into(),
55 report_interval: "60s".into(),
56 forward_csp: true,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Deserialize)]
62#[serde(default)]
63pub struct ServerCfg {
64 /// Public listen port. Overridden by the `PORT` env var.
65 pub port: u16,
66 /// Internal port the wrapped/upstream app listens on. Overridden by `APP_PORT`.
67 pub app_port: u16,
68 /// Full upstream base URL. Overridden by `UPSTREAM`. If empty, derived from app_port.
69 pub upstream: String,
70 /// Trust the `X-Forwarded-For` header for client identity. Enable ONLY when
71 /// EdgeGuard sits behind a trusted proxy/load balancer that sets it (e.g. a PaaS
72 /// edge). When false (default) the peer socket address is used, so clients can't
73 /// spoof their IP to defeat per-IP rate limiting or forge access-log entries.
74 pub trust_forwarded_for: bool,
75 /// Private listener port for the internal `/__edgeguard/*` ops endpoints (health,
76 /// readiness, metrics). `0` (default) keeps them on the public port. When non-zero,
77 /// EdgeGuard binds a second, plain-HTTP listener on `admin_addr:admin_port` that serves
78 /// those endpoints, and the public port serves only the proxy (plus the browser-facing CSP
79 /// report sink) — so metrics/health aren't exposed on the internet. Overridden by
80 /// `ADMIN_PORT`. (Point your platform's health check at this port when you enable it.)
81 pub admin_port: u16,
82 /// Address the private admin listener binds when `admin_port` is set. Defaults to
83 /// `127.0.0.1` (same-host only — e.g. a sidecar scraper); set to `0.0.0.0` to expose it on
84 /// a private network interface (rely on your network policy to keep it off the internet).
85 pub admin_addr: String,
86}
87
88#[derive(Debug, Clone, Deserialize)]
89#[serde(default)]
90pub struct AuthCfg {
91 /// "none" | "basic" | "apikey" | "jwt". Selects the gate applied to every proxied
92 /// request; the internal `/__edgeguard/*` endpoints are always exempt.
93 pub mode: String,
94 pub realm: String,
95 /// username -> password. Value may be plaintext (dev) or a `$argon2...` PHC hash.
96 /// Used when `mode = "basic"`.
97 pub users: BTreeMap<String, String>,
98 /// Accepted API keys (compared in constant time). Used when `mode = "apikey"`. A request
99 /// may present a key either as `Authorization: Bearer <key>` or in `api_key_header`.
100 /// Overridable from the env via `EDGEGUARD_API_KEYS` (comma-separated) so keys need not
101 /// live in the config file.
102 pub api_keys: Vec<String>,
103 /// Header carrying the API key (in addition to `Authorization: Bearer`), default
104 /// `X-API-Key`. Used when `mode = "apikey"`.
105 pub api_key_header: String,
106 /// JWT verification policy. Used when `mode = "jwt"`.
107 pub jwt: JwtCfg,
108}
109
110/// JWT bearer-token verification. Either a symmetric `secret` (HS*) or an asymmetric key
111/// (RS*/ES*/PS*) supplied as a static `public_key_pem` or fetched from `jwks_url`.
112#[derive(Debug, Clone, Deserialize)]
113#[serde(default)]
114pub struct JwtCfg {
115 /// Expected signature algorithm, e.g. "HS256", "RS256", "ES256". The token's own `alg`
116 /// header must match this (we never trust the token to pick its own algorithm — that is
117 /// the classic JWT downgrade/`alg=none` foot-gun).
118 pub algorithm: String,
119 /// Shared secret for HS* algorithms. Prefer the `EDGEGUARD_JWT_SECRET` env var over
120 /// putting it in the config file.
121 pub secret: String,
122 /// Static PEM public key (SPKI or PKCS#1) for RS*/ES*/PS* verification, as an
123 /// alternative to `jwks_url`.
124 pub public_key_pem: String,
125 /// JWKS endpoint to fetch verification keys from (RS*/ES*/PS*). Keys are cached and
126 /// selected by the token's `kid`.
127 pub jwks_url: String,
128 /// How long (seconds) to cache a fetched JWKS before refetching. Default 300.
129 pub jwks_cache_secs: u64,
130 /// If set, the token's `iss` claim must equal this.
131 pub issuer: String,
132 /// If set, the token's `aud` claim must contain this.
133 pub audience: String,
134 /// Clock-skew leeway (seconds) applied to `exp`/`nbf` validation. Default 60.
135 pub leeway_secs: u64,
136}
137
138#[derive(Debug, Clone, Deserialize)]
139#[serde(default)]
140pub struct RateLimitCfg {
141 pub enabled: bool,
142 /// Default per-client-IP limit, e.g. "60/min", "10/sec", "1000/hour".
143 pub rate: String,
144 pub burst: u32,
145 /// Per-route overrides. A request whose path starts with `path` uses that route's limit
146 /// (still keyed per client IP) instead of the global one; the longest matching prefix
147 /// wins, so `/api/admin/` can be stricter than `/api/`.
148 pub routes: Vec<RouteRateLimit>,
149 /// An additional limit keyed by the authenticated principal (API-key id or JWT subject)
150 /// rather than IP, so a single credential can't fan out across many IPs. Only applies to
151 /// authenticated requests.
152 pub per_key: PerKeyRateLimit,
153 /// Where limiter state lives: `"local"` (default) is the in-process `governor` limiter (fast,
154 /// no dependency, but per-replica). `"redis"` shares GCRA state across replicas via a Redis
155 /// store, so N instances enforce one global limit. `"memory"` uses the same shared-store code
156 /// path backed by an in-process map (a single-replica/testing backend). All three honor the
157 /// same `rate`/`burst`/route/per-key settings above.
158 pub store: String,
159 /// Redis connection URL for `store = "redis"`, e.g. `redis://host:6379` or (TLS)
160 /// `rediss://host:6379`. Prefer the `EDGEGUARD_REDIS_URL` env var over this file.
161 pub redis_url: String,
162 /// Key prefix/namespace for the shared store, so multiple EdgeGuard deployments can share one
163 /// Redis without colliding. Keys look like `<prefix>:ip:<addr>`.
164 pub redis_prefix: String,
165 /// What to do when the shared store is unreachable. `false` (default) fails **closed** — a
166 /// store error returns `503`, so an outage can't silently disable rate limiting. `true` fails
167 /// **open** — a store error allows the request (favor availability over strict limiting).
168 /// Only relevant for `store = "redis"`.
169 pub fail_open: bool,
170}
171
172/// A per-route rate-limit override (matched by path prefix).
173#[derive(Debug, Clone, Deserialize)]
174#[serde(default)]
175pub struct RouteRateLimit {
176 /// Path prefix this limit applies to, e.g. "/api/".
177 pub path: String,
178 pub rate: String,
179 pub burst: u32,
180}
181
182impl Default for RouteRateLimit {
183 fn default() -> Self {
184 RouteRateLimit {
185 path: String::new(),
186 rate: "60/min".into(),
187 burst: 20,
188 }
189 }
190}
191
192/// Per-principal rate limit (keyed by API-key id / JWT subject).
193#[derive(Debug, Clone, Deserialize)]
194#[serde(default)]
195pub struct PerKeyRateLimit {
196 pub enabled: bool,
197 pub rate: String,
198 pub burst: u32,
199}
200
201impl Default for PerKeyRateLimit {
202 fn default() -> Self {
203 PerKeyRateLimit {
204 enabled: false,
205 rate: "1000/hour".into(),
206 burst: 100,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Deserialize)]
212#[serde(default)]
213pub struct ValidationCfg {
214 /// e.g. "2MiB". Requests with a larger body are rejected with 413.
215 pub max_body: String,
216 /// Cap on the upstream response body EdgeGuard buffers, e.g. "16MiB". "0" disables
217 /// the cap (unbounded). Protects against an upstream OOM-ing the proxy; raise it if
218 /// you proxy large downloads.
219 pub max_response_body: String,
220 /// Max time to wait for the upstream response and to read its body, e.g. "30s",
221 /// "500ms", "2m". "0" disables the timeout. Bounds a stalled upstream so it can't pin a
222 /// handler task indefinitely; on elapse the proxy returns 504.
223 pub upstream_timeout: String,
224 /// Cap on the total size of incoming request headers (sum of name + value bytes), e.g.
225 /// "32KiB". "0" disables the cap (default). Requests over the limit get `431`. This is a
226 /// policy limit enforced by EdgeGuard on top of hyper's own transport-level header cap.
227 pub max_header_bytes: String,
228 /// Allowed HTTP methods; empty list means allow all.
229 pub allow_methods: Vec<String>,
230}
231
232#[derive(Debug, Clone, Deserialize)]
233#[serde(default)]
234pub struct HeadersCfg {
235 pub hsts: bool,
236 pub csp: String,
237 /// Send the CSP as `Content-Security-Policy-Report-Only` instead of enforcing it. Lets
238 /// you roll out / tighten a policy by collecting violations first without breaking the
239 /// page.
240 pub csp_report_only: bool,
241 /// If set, a `report-uri <value>` directive is appended to the CSP so browsers POST
242 /// violation reports there. Point it at EdgeGuard's own sink ("/__edgeguard/csp-report")
243 /// to have them logged, or at any external collector.
244 pub csp_report_uri: String,
245 pub referrer_policy: String,
246 pub permissions_policy: String,
247 pub frame_options: String,
248 pub force_secure_cookies: bool,
249 /// Response headers to strip (case-insensitive), e.g. ["Server", "X-Powered-By"].
250 pub strip: Vec<String>,
251}
252
253impl Default for ServerCfg {
254 fn default() -> Self {
255 ServerCfg {
256 port: 8080,
257 app_port: 3000,
258 upstream: String::new(),
259 trust_forwarded_for: false,
260 admin_port: 0,
261 admin_addr: "127.0.0.1".into(),
262 }
263 }
264}
265
266impl Default for AuthCfg {
267 fn default() -> Self {
268 AuthCfg {
269 mode: "none".into(),
270 realm: "EdgeGuard".into(),
271 users: BTreeMap::new(),
272 api_keys: vec![],
273 api_key_header: "X-API-Key".into(),
274 jwt: JwtCfg::default(),
275 }
276 }
277}
278
279impl Default for JwtCfg {
280 fn default() -> Self {
281 JwtCfg {
282 algorithm: "HS256".into(),
283 secret: String::new(),
284 public_key_pem: String::new(),
285 jwks_url: String::new(),
286 jwks_cache_secs: 300,
287 issuer: String::new(),
288 audience: String::new(),
289 leeway_secs: 60,
290 }
291 }
292}
293
294impl Default for RateLimitCfg {
295 fn default() -> Self {
296 RateLimitCfg {
297 enabled: true,
298 rate: "60/min".into(),
299 burst: 20,
300 routes: vec![],
301 per_key: PerKeyRateLimit::default(),
302 store: "local".into(),
303 redis_url: "redis://127.0.0.1:6379".into(),
304 redis_prefix: "edgeguard".into(),
305 fail_open: false,
306 }
307 }
308}
309
310impl Default for ValidationCfg {
311 fn default() -> Self {
312 ValidationCfg {
313 max_body: "2MiB".into(),
314 max_response_body: "0".into(),
315 upstream_timeout: "30s".into(),
316 max_header_bytes: "0".into(),
317 allow_methods: vec![],
318 }
319 }
320}
321
322impl Default for HeadersCfg {
323 fn default() -> Self {
324 HeadersCfg {
325 hsts: true,
326 csp: "default-src 'self'".into(),
327 csp_report_only: false,
328 csp_report_uri: String::new(),
329 referrer_policy: "no-referrer".into(),
330 permissions_policy: "geolocation=(), microphone=(), camera=()".into(),
331 frame_options: "DENY".into(),
332 force_secure_cookies: true,
333 strip: vec!["Server".into(), "X-Powered-By".into()],
334 }
335 }
336}
337
338/// TLS termination. When `enabled`, EdgeGuard serves HTTPS on the public port using a
339/// certificate either loaded from `cert_path`/`key_path` or obtained automatically via ACME.
340/// All-default fields (disabled, empty paths, default ACME) so `Default` is derivable.
341#[derive(Debug, Clone, Default, Deserialize)]
342#[serde(default)]
343pub struct TlsCfg {
344 pub enabled: bool,
345 /// PEM certificate chain (leaf first). When ACME is enabled this is where the obtained
346 /// certificate is written/read.
347 pub cert_path: String,
348 /// PEM private key (PKCS#8/PKCS#1/SEC1).
349 pub key_path: String,
350 pub acme: AcmeCfg,
351}
352
353/// Automatic certificate management (ACME / Let's Encrypt) via the HTTP-01 challenge. The
354/// obtained certificate is written to `TlsCfg::cert_path`/`key_path` and served by the TLS
355/// listener; a background task renews it before expiry.
356#[derive(Debug, Clone, Deserialize)]
357#[serde(default)]
358pub struct AcmeCfg {
359 pub enabled: bool,
360 /// Domains to request a certificate for (the first is the primary CN).
361 pub domains: Vec<String>,
362 /// Contact email for the ACME account (registration + expiry notices).
363 pub email: String,
364 /// ACME directory URL. Defaults to Let's Encrypt **staging** so a misconfiguration can't
365 /// burn the strict production rate limits; switch to production explicitly.
366 pub directory_url: String,
367 /// Directory for the cached ACME account key (so renewals reuse the same account).
368 pub cache_dir: String,
369 /// You must set this to `true` to signify acceptance of the ACME provider's Terms of
370 /// Service; EdgeGuard refuses to register otherwise.
371 pub accept_tos: bool,
372}
373
374impl Default for AcmeCfg {
375 fn default() -> Self {
376 AcmeCfg {
377 enabled: false,
378 domains: vec![],
379 email: String::new(),
380 // Let's Encrypt staging — safe default; see the field doc.
381 directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".into(),
382 cache_dir: "./acme".into(),
383 accept_tos: false,
384 }
385 }
386}
387
388/// WAF-lite input inspection (Phase 4 / v2). Screens a request for common attack signatures
389/// before it is forwarded, using built-in heuristic rulesets (SQLi/XSS/path-traversal) plus
390/// any operator-defined deny patterns. Disabled by default — these are heuristics, so the
391/// intended rollout is `report` (log + count matches without blocking) until the operator is
392/// confident, then `block` (return `403`). Compiled into a `crate::waf::WafEngine`.
393#[derive(Debug, Clone, Deserialize)]
394#[serde(default)]
395pub struct WafCfg {
396 /// "off" (default) | "report" | "block". `report` evaluates rules and logs/counts matches
397 /// but forwards the request anyway; `block` rejects a matching request with `403`.
398 pub mode: String,
399 /// Enable the built-in SQL-injection heuristic ruleset.
400 pub sqli: bool,
401 /// Enable the built-in cross-site-scripting heuristic ruleset.
402 pub xss: bool,
403 /// Enable the built-in path-traversal heuristic ruleset.
404 pub path_traversal: bool,
405 /// Inspect the request path + query string (matched raw and percent-decoded). Default true.
406 pub inspect_path: bool,
407 /// Inspect request header values. Off by default: header bytes (cookies, tokens, opaque
408 /// blobs) are noisy and prone to false positives.
409 pub inspect_headers: bool,
410 /// Inspect the request body (already capped by `validation.max_body`). Off by default.
411 pub inspect_body: bool,
412 /// Operator-defined deny patterns, evaluated alongside the enabled built-in rulesets.
413 pub rules: Vec<WafRule>,
414}
415
416/// A single operator-defined WAF deny pattern (a `[[waf.rules]]` entry).
417#[derive(Debug, Clone, Deserialize)]
418#[serde(default)]
419pub struct WafRule {
420 /// Identifier reported in logs/metrics when this rule matches (defaults to `custom-<n>`).
421 pub id: String,
422 /// Regular expression (RE2 syntax: linear-time, no backreferences/lookaround, so it can't
423 /// ReDoS the proxy). A request matching it in any targeted location is treated as a hit.
424 pub pattern: String,
425 /// Request location to match against: "path" (path+query, default), "headers", "body", or
426 /// "all". A location is only examined when its `inspect_*` flag above is also enabled.
427 pub target: String,
428}
429
430impl Default for WafCfg {
431 fn default() -> Self {
432 WafCfg {
433 mode: "off".into(),
434 sqli: true,
435 xss: true,
436 path_traversal: true,
437 inspect_path: true,
438 inspect_headers: false,
439 inspect_body: false,
440 rules: vec![],
441 }
442 }
443}
444
445impl Default for WafRule {
446 fn default() -> Self {
447 WafRule {
448 id: String::new(),
449 pattern: String::new(),
450 target: "path".into(),
451 }
452 }
453}
454
455impl Config {
456 /// Load defaults, overlay an optional TOML file, then apply env overrides.
457 pub fn load(path: Option<&str>) -> Result<Config> {
458 let mut cfg = if let Some(p) = path {
459 let raw =
460 std::fs::read_to_string(p).with_context(|| format!("reading config file {p}"))?;
461 toml::from_str::<Config>(&raw).with_context(|| format!("parsing config file {p}"))?
462 } else {
463 Config::default()
464 };
465
466 if let Ok(p) = env::var("PORT") {
467 if let Ok(v) = p.parse() {
468 cfg.server.port = v;
469 }
470 }
471 if let Ok(p) = env::var("APP_PORT") {
472 if let Ok(v) = p.parse() {
473 cfg.server.app_port = v;
474 }
475 }
476 if let Ok(p) = env::var("ADMIN_PORT") {
477 if let Ok(v) = p.parse() {
478 cfg.server.admin_port = v;
479 }
480 }
481 if let Ok(u) = env::var("UPSTREAM") {
482 if !u.is_empty() {
483 cfg.server.upstream = u;
484 }
485 }
486 // Keep secrets out of the config file: let the environment supply them.
487 if let Ok(s) = env::var("EDGEGUARD_JWT_SECRET") {
488 if !s.is_empty() {
489 cfg.auth.jwt.secret = s;
490 }
491 }
492 if let Ok(u) = env::var("EDGEGUARD_REDIS_URL") {
493 if !u.is_empty() {
494 cfg.ratelimit.redis_url = u;
495 }
496 }
497 if let Ok(keys) = env::var("EDGEGUARD_API_KEYS") {
498 let keys: Vec<String> = keys
499 .split(',')
500 .map(|k| k.trim().to_string())
501 .filter(|k| !k.is_empty())
502 .collect();
503 if !keys.is_empty() {
504 cfg.auth.api_keys = keys;
505 }
506 }
507 if let Ok(t) = env::var("EDGEGUARD_CP_EDGE_TOKEN") {
508 if !t.is_empty() {
509 cfg.control_plane.edge_token = t;
510 }
511 }
512 if let Ok(u) = env::var("EDGEGUARD_CP_URL") {
513 if !u.is_empty() {
514 cfg.control_plane.url = u;
515 }
516 }
517 Ok(cfg)
518 }
519
520 /// Produce an effective config by overlaying a control-plane-pushed *policy* document onto
521 /// this (local) config: the policy sections (`auth`/`ratelimit`/`validation`/`headers`/`waf`)
522 /// come from the pushed TOML; `server`/`tls`/`control_plane` stay local (the control plane
523 /// manages security policy, not this edge's listener/plumbing). The result feeds the normal
524 /// `build_runtime` + hot-swap path, so a malformed policy is rejected like any bad reload.
525 pub fn with_policy_from(&self, policy_toml: &str) -> Result<Config> {
526 let p: Config =
527 toml::from_str(policy_toml).context("parsing control-plane policy document")?;
528 Ok(Config {
529 server: self.server.clone(),
530 tls: self.tls.clone(),
531 control_plane: self.control_plane.clone(),
532 auth: p.auth,
533 ratelimit: p.ratelimit,
534 validation: p.validation,
535 headers: p.headers,
536 waf: p.waf,
537 })
538 }
539
540 /// The upstream base URL EdgeGuard forwards to, e.g. "http://127.0.0.1:3000".
541 pub fn upstream_base(&self) -> String {
542 if self.server.upstream.is_empty() {
543 format!("http://127.0.0.1:{}", self.server.app_port)
544 } else {
545 self.server.upstream.trim_end_matches('/').to_string()
546 }
547 }
548
549 /// The `(host, port)` EdgeGuard probes for readiness, mirroring [`Self::upstream_base`]:
550 /// co-process mode probes `127.0.0.1:app_port`; an explicit upstream URL is parsed,
551 /// defaulting the port from the scheme. Returns `None` if the URL carries no usable
552 /// host, so the readiness check reports "not ready" rather than panicking.
553 pub fn upstream_probe_addr(&self) -> Option<(String, u16)> {
554 if self.server.upstream.is_empty() {
555 Some(("127.0.0.1".to_string(), self.server.app_port))
556 } else {
557 parse_host_port(&self.server.upstream)
558 }
559 }
560}
561
562/// Extract `(host, port)` from an upstream URL like `http://host:3000/base`. Only the
563/// scheme (for the default port), host, and port are needed — any path is ignored. Handles
564/// bracketed IPv6 literals (`http://[::1]:3000`). This is deliberately small rather than a
565/// full URL parser; the proxy itself is HTTP-only in v0.
566fn parse_host_port(url: &str) -> Option<(String, u16)> {
567 let (default_port, rest) = if let Some(r) = url.strip_prefix("http://") {
568 (80u16, r)
569 } else if let Some(r) = url.strip_prefix("https://") {
570 (443u16, r)
571 } else {
572 (80u16, url)
573 };
574 // Authority is everything up to the first '/'; drop any `user:pass@` userinfo.
575 let authority = rest.split('/').next().unwrap_or(rest);
576 let authority = authority.rsplit('@').next().unwrap_or(authority);
577 if authority.is_empty() {
578 return None;
579 }
580 // Bracketed IPv6 literal: `[::1]` or `[::1]:port`.
581 if let Some(after) = authority.strip_prefix('[') {
582 let (host, tail) = after.split_once(']')?;
583 let port = match tail.strip_prefix(':') {
584 Some(p) => p.parse().ok()?,
585 None => default_port,
586 };
587 return Some((host.to_string(), port));
588 }
589 match authority.rsplit_once(':') {
590 // Reject an empty host (e.g. `http://:3000`) rather than deferring the failure to a
591 // connect call — the "usable host" contract is checked here.
592 Some((host, port)) if !host.is_empty() => Some((host.to_string(), port.parse().ok()?)),
593 Some(_) => None,
594 None => Some((authority.to_string(), default_port)),
595 }
596}
597
598/// Parse a human size like "2MiB", "512KB", "1048576" into bytes.
599pub fn parse_size(s: &str) -> Result<usize> {
600 let s = s.trim();
601 let (num, mult): (&str, usize) = if let Some(n) = s.strip_suffix("GiB") {
602 (n, 1024 * 1024 * 1024)
603 } else if let Some(n) = s.strip_suffix("MiB") {
604 (n, 1024 * 1024)
605 } else if let Some(n) = s.strip_suffix("KiB") {
606 (n, 1024)
607 } else if let Some(n) = s.strip_suffix("GB") {
608 (n, 1_000_000_000)
609 } else if let Some(n) = s.strip_suffix("MB") {
610 (n, 1_000_000)
611 } else if let Some(n) = s.strip_suffix("KB") {
612 (n, 1_000)
613 } else if let Some(n) = s.strip_suffix('B') {
614 (n, 1)
615 } else {
616 (s, 1)
617 };
618 let n: usize = num
619 .trim()
620 .parse()
621 .with_context(|| format!("invalid size: {s}"))?;
622 n.checked_mul(mult)
623 .with_context(|| format!("size too large: {s}"))
624}
625
626/// Parse a rate like "60/min" into (count, period).
627pub fn parse_rate(s: &str) -> Result<(u32, Duration)> {
628 let (n, unit) = s
629 .split_once('/')
630 .with_context(|| format!("invalid rate (expected N/unit): {s}"))?;
631 let count: u32 = n
632 .trim()
633 .parse()
634 .with_context(|| format!("invalid rate count: {s}"))?;
635 let period = match unit.trim() {
636 "s" | "sec" | "second" => Duration::from_secs(1),
637 "m" | "min" | "minute" => Duration::from_secs(60),
638 "h" | "hour" => Duration::from_secs(3600),
639 other => anyhow::bail!("unsupported rate unit: {other}"),
640 };
641 Ok((count, period))
642}
643
644/// Parse a timeout like "30s", "500ms", "2m", or a bare number of seconds ("45"). "0"
645/// yields a zero duration, which callers treat as "disabled".
646pub fn parse_duration(s: &str) -> Result<Duration> {
647 let s = s.trim();
648 // Order matters: check "ms" before the single-char "s"/"m" suffixes.
649 if let Some(n) = s.strip_suffix("ms") {
650 let ms: u64 = n
651 .trim()
652 .parse()
653 .with_context(|| format!("invalid duration: {s}"))?;
654 Ok(Duration::from_millis(ms))
655 } else if let Some(n) = s.strip_suffix('s') {
656 let secs: u64 = n
657 .trim()
658 .parse()
659 .with_context(|| format!("invalid duration: {s}"))?;
660 Ok(Duration::from_secs(secs))
661 } else if let Some(n) = s.strip_suffix('m') {
662 let mins: u64 = n
663 .trim()
664 .parse()
665 .with_context(|| format!("invalid duration: {s}"))?;
666 let secs = mins
667 .checked_mul(60)
668 .with_context(|| format!("duration too large: {s}"))?;
669 Ok(Duration::from_secs(secs))
670 } else {
671 let secs: u64 = s
672 .parse()
673 .with_context(|| format!("invalid duration: {s}"))?;
674 Ok(Duration::from_secs(secs))
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn parse_size_units_and_plain_bytes() {
684 assert_eq!(parse_size("0").unwrap(), 0);
685 assert_eq!(parse_size("1048576").unwrap(), 1_048_576);
686 assert_eq!(parse_size("512B").unwrap(), 512);
687 assert_eq!(parse_size("1KB").unwrap(), 1_000);
688 assert_eq!(parse_size("1KiB").unwrap(), 1_024);
689 assert_eq!(parse_size("2MiB").unwrap(), 2 * 1024 * 1024);
690 assert_eq!(parse_size("16MiB").unwrap(), 16 * 1024 * 1024);
691 assert_eq!(parse_size("1GiB").unwrap(), 1024 * 1024 * 1024);
692 // surrounding / internal whitespace is tolerated
693 assert_eq!(parse_size(" 4 MiB ").unwrap(), 4 * 1024 * 1024);
694 }
695
696 #[test]
697 fn parse_size_rejects_garbage_and_overflow() {
698 assert!(parse_size("abc").is_err());
699 assert!(parse_size("MiB").is_err());
700 // would overflow usize -> Err, not a silent wrap
701 assert!(parse_size("99999999999999999999GiB").is_err());
702 }
703
704 #[test]
705 fn parse_rate_counts_and_units() {
706 assert_eq!(parse_rate("60/min").unwrap(), (60, Duration::from_secs(60)));
707 assert_eq!(parse_rate("10/sec").unwrap(), (10, Duration::from_secs(1)));
708 assert_eq!(
709 parse_rate("1000/hour").unwrap(),
710 (1000, Duration::from_secs(3600))
711 );
712 // short and long unit spellings, plus tolerated whitespace
713 assert_eq!(parse_rate(" 5 / m ").unwrap(), (5, Duration::from_secs(60)));
714 }
715
716 #[test]
717 fn parse_rate_rejects_garbage() {
718 assert!(parse_rate("60").is_err()); // no unit
719 assert!(parse_rate("x/min").is_err()); // bad count
720 assert!(parse_rate("60/year").is_err()); // bad unit
721 }
722
723 #[test]
724 fn probe_addr_defaults_to_app_port_in_coprocess_mode() {
725 let cfg = Config::default();
726 assert_eq!(
727 cfg.upstream_probe_addr(),
728 Some(("127.0.0.1".to_string(), cfg.server.app_port))
729 );
730 }
731
732 #[test]
733 fn parse_host_port_handles_schemes_paths_and_ipv6() {
734 assert_eq!(
735 parse_host_port("http://127.0.0.1:3000"),
736 Some(("127.0.0.1".to_string(), 3000))
737 );
738 // a trailing path is ignored
739 assert_eq!(
740 parse_host_port("http://app.internal:8080/health"),
741 Some(("app.internal".to_string(), 8080))
742 );
743 // port defaults from the scheme
744 assert_eq!(
745 parse_host_port("https://example.com"),
746 Some(("example.com".to_string(), 443))
747 );
748 assert_eq!(
749 parse_host_port("http://example.com"),
750 Some(("example.com".to_string(), 80))
751 );
752 // bracketed IPv6 literal, with and without an explicit port
753 assert_eq!(
754 parse_host_port("http://[::1]:3000"),
755 Some(("::1".to_string(), 3000))
756 );
757 assert_eq!(
758 parse_host_port("http://[2001:db8::1]"),
759 Some(("2001:db8::1".to_string(), 80))
760 );
761 }
762
763 #[test]
764 fn parse_host_port_rejects_empty_or_unusable_host() {
765 // empty host (port only) is not a usable probe target
766 assert_eq!(parse_host_port("http://:3000"), None);
767 // non-numeric port
768 assert_eq!(parse_host_port("http://host:notaport"), None);
769 }
770
771 #[test]
772 fn parse_duration_units_and_bare_seconds() {
773 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
774 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
775 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
776 assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
777 // "0" disables (zero duration); callers map it to "no timeout"
778 assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
779 assert_eq!(parse_duration(" 10s ").unwrap(), Duration::from_secs(10));
780 }
781
782 #[test]
783 fn with_policy_from_keeps_local_plumbing_takes_policy() {
784 let mut local = Config::default();
785 local.server.port = 9999;
786 local.server.upstream = "http://up:1".into();
787 local.control_plane.enabled = true;
788 // A pushed policy that changes auth + disables rate limiting.
789 let policy = "[auth]\nmode = \"apikey\"\n\n[ratelimit]\nenabled = false\n";
790 let merged = local.with_policy_from(policy).unwrap();
791 // Local server / control-plane settings are preserved...
792 assert_eq!(merged.server.port, 9999);
793 assert_eq!(merged.server.upstream, "http://up:1");
794 assert!(merged.control_plane.enabled);
795 // ...while the policy sections are taken from the pushed document.
796 assert_eq!(merged.auth.mode, "apikey");
797 assert!(!merged.ratelimit.enabled);
798 }
799
800 #[test]
801 fn with_policy_from_rejects_bad_toml() {
802 assert!(Config::default()
803 .with_policy_from("not = valid = toml")
804 .is_err());
805 }
806
807 #[test]
808 fn parse_duration_rejects_garbage() {
809 assert!(parse_duration("abc").is_err());
810 assert!(parse_duration("10x").is_err());
811 assert!(parse_duration("s").is_err());
812 }
813}