Skip to main content

edgeguard/
config.rs

1//! Configuration. Env-first so EdgeGuard drops into any PaaS that injects `$PORT`
2//! with zero edits; an optional TOML file layers richer policy on top.
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::env;
8use std::time::Duration;
9
10#[derive(Debug, Clone, Default, Deserialize)]
11#[serde(default)]
12pub struct Config {
13    pub server: ServerCfg,
14    pub auth: AuthCfg,
15    pub ratelimit: RateLimitCfg,
16    pub validation: ValidationCfg,
17    pub headers: HeadersCfg,
18    pub tls: TlsCfg,
19    pub waf: WafCfg,
20    /// Optional "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    /// Stream (don't buffer) responses whose `Content-Type` is `text/event-stream`. Off by
231    /// default: the proxy normally buffers the whole upstream body so it can cap size
232    /// (`max_response_body`) and account exact egress bytes. That buffering defeats Server-Sent
233    /// Events / chunked streaming — the client only sees the body once the upstream finishes.
234    /// Turn this on to forward SSE responses frame-by-frame as they arrive (preserving
235    /// time-to-first-byte). When a response is streamed this way the `max_response_body` cap and
236    /// the body-read deadline don't apply (the connect/first-byte `upstream_timeout` still
237    /// does); egress bytes are tallied as frames flow. Non-SSE responses are unaffected.
238    pub stream_passthrough: bool,
239}
240
241#[derive(Debug, Clone, Deserialize)]
242#[serde(default)]
243pub struct HeadersCfg {
244    pub hsts: bool,
245    pub csp: String,
246    /// Send the CSP as `Content-Security-Policy-Report-Only` instead of enforcing it. Lets
247    /// you roll out / tighten a policy by collecting violations first without breaking the
248    /// page.
249    pub csp_report_only: bool,
250    /// If set, a `report-uri <value>` directive is appended to the CSP so browsers POST
251    /// violation reports there. Point it at EdgeGuard's own sink ("/__edgeguard/csp-report")
252    /// to have them logged, or at any external collector.
253    pub csp_report_uri: String,
254    pub referrer_policy: String,
255    pub permissions_policy: String,
256    pub frame_options: String,
257    pub force_secure_cookies: bool,
258    /// Response headers to strip (case-insensitive), e.g. ["Server", "X-Powered-By"].
259    pub strip: Vec<String>,
260}
261
262impl Default for ServerCfg {
263    fn default() -> Self {
264        ServerCfg {
265            port: 8080,
266            app_port: 3000,
267            upstream: String::new(),
268            trust_forwarded_for: false,
269            admin_port: 0,
270            admin_addr: "127.0.0.1".into(),
271        }
272    }
273}
274
275impl Default for AuthCfg {
276    fn default() -> Self {
277        AuthCfg {
278            mode: "none".into(),
279            realm: "EdgeGuard".into(),
280            users: BTreeMap::new(),
281            api_keys: vec![],
282            api_key_header: "X-API-Key".into(),
283            jwt: JwtCfg::default(),
284        }
285    }
286}
287
288impl Default for JwtCfg {
289    fn default() -> Self {
290        JwtCfg {
291            algorithm: "HS256".into(),
292            secret: String::new(),
293            public_key_pem: String::new(),
294            jwks_url: String::new(),
295            jwks_cache_secs: 300,
296            issuer: String::new(),
297            audience: String::new(),
298            leeway_secs: 60,
299        }
300    }
301}
302
303impl Default for RateLimitCfg {
304    fn default() -> Self {
305        RateLimitCfg {
306            enabled: true,
307            rate: "60/min".into(),
308            burst: 20,
309            routes: vec![],
310            per_key: PerKeyRateLimit::default(),
311            store: "local".into(),
312            redis_url: "redis://127.0.0.1:6379".into(),
313            redis_prefix: "edgeguard".into(),
314            fail_open: false,
315        }
316    }
317}
318
319impl Default for ValidationCfg {
320    fn default() -> Self {
321        ValidationCfg {
322            max_body: "2MiB".into(),
323            max_response_body: "0".into(),
324            upstream_timeout: "30s".into(),
325            max_header_bytes: "0".into(),
326            allow_methods: vec![],
327            stream_passthrough: false,
328        }
329    }
330}
331
332impl Default for HeadersCfg {
333    fn default() -> Self {
334        HeadersCfg {
335            hsts: true,
336            csp: "default-src 'self'".into(),
337            csp_report_only: false,
338            csp_report_uri: String::new(),
339            referrer_policy: "no-referrer".into(),
340            permissions_policy: "geolocation=(), microphone=(), camera=()".into(),
341            frame_options: "DENY".into(),
342            force_secure_cookies: true,
343            strip: vec!["Server".into(), "X-Powered-By".into()],
344        }
345    }
346}
347
348/// TLS termination. When `enabled`, EdgeGuard serves HTTPS on the public port using a
349/// certificate either loaded from `cert_path`/`key_path` or obtained automatically via ACME.
350/// All-default fields (disabled, empty paths, default ACME) so `Default` is derivable.
351#[derive(Debug, Clone, Default, Deserialize)]
352#[serde(default)]
353pub struct TlsCfg {
354    pub enabled: bool,
355    /// PEM certificate chain (leaf first). When ACME is enabled this is where the obtained
356    /// certificate is written/read.
357    pub cert_path: String,
358    /// PEM private key (PKCS#8/PKCS#1/SEC1).
359    pub key_path: String,
360    pub acme: AcmeCfg,
361}
362
363/// Automatic certificate management (ACME / Let's Encrypt) via the HTTP-01 challenge. The
364/// obtained certificate is written to `TlsCfg::cert_path`/`key_path` and served by the TLS
365/// listener; a background task renews it before expiry.
366#[derive(Debug, Clone, Deserialize)]
367#[serde(default)]
368pub struct AcmeCfg {
369    pub enabled: bool,
370    /// Domains to request a certificate for (the first is the primary CN).
371    pub domains: Vec<String>,
372    /// Contact email for the ACME account (registration + expiry notices).
373    pub email: String,
374    /// ACME directory URL. Defaults to Let's Encrypt **staging** so a misconfiguration can't
375    /// burn the strict production rate limits; switch to production explicitly.
376    pub directory_url: String,
377    /// Directory for the cached ACME account key (so renewals reuse the same account).
378    pub cache_dir: String,
379    /// You must set this to `true` to signify acceptance of the ACME provider's Terms of
380    /// Service; EdgeGuard refuses to register otherwise.
381    pub accept_tos: bool,
382}
383
384impl Default for AcmeCfg {
385    fn default() -> Self {
386        AcmeCfg {
387            enabled: false,
388            domains: vec![],
389            email: String::new(),
390            // Let's Encrypt staging — safe default; see the field doc.
391            directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".into(),
392            cache_dir: "./acme".into(),
393            accept_tos: false,
394        }
395    }
396}
397
398/// WAF-lite input inspection (Phase 4 / v2). Screens a request for common attack signatures
399/// before it is forwarded, using built-in heuristic rulesets (SQLi/XSS/path-traversal) plus
400/// any operator-defined deny patterns. Disabled by default — these are heuristics, so the
401/// intended rollout is `report` (log + count matches without blocking) until the operator is
402/// confident, then `block` (return `403`). Compiled into a `crate::waf::WafEngine`.
403#[derive(Debug, Clone, Deserialize)]
404#[serde(default)]
405pub struct WafCfg {
406    /// "off" (default) | "report" | "block". `report` evaluates rules and logs/counts matches
407    /// but forwards the request anyway; `block` rejects a matching request with `403`.
408    pub mode: String,
409    /// Enable the built-in SQL-injection heuristic ruleset.
410    pub sqli: bool,
411    /// Enable the built-in cross-site-scripting heuristic ruleset.
412    pub xss: bool,
413    /// Enable the built-in path-traversal heuristic ruleset.
414    pub path_traversal: bool,
415    /// Inspect the request path + query string (matched raw and percent-decoded). Default true.
416    pub inspect_path: bool,
417    /// Inspect request header values. Off by default: header bytes (cookies, tokens, opaque
418    /// blobs) are noisy and prone to false positives.
419    pub inspect_headers: bool,
420    /// Inspect the request body (already capped by `validation.max_body`). Off by default.
421    pub inspect_body: bool,
422    /// Operator-defined deny patterns, evaluated alongside the enabled built-in rulesets.
423    pub rules: Vec<WafRule>,
424}
425
426/// A single operator-defined WAF deny pattern (a `[[waf.rules]]` entry).
427#[derive(Debug, Clone, Deserialize)]
428#[serde(default)]
429pub struct WafRule {
430    /// Identifier reported in logs/metrics when this rule matches (defaults to `custom-<n>`).
431    pub id: String,
432    /// Regular expression (RE2 syntax: linear-time, no backreferences/lookaround, so it can't
433    /// ReDoS the proxy). A request matching it in any targeted location is treated as a hit.
434    pub pattern: String,
435    /// Request location to match against: "path" (path+query, default), "headers", "body", or
436    /// "all". A location is only examined when its `inspect_*` flag above is also enabled.
437    pub target: String,
438}
439
440impl Default for WafCfg {
441    fn default() -> Self {
442        WafCfg {
443            mode: "off".into(),
444            sqli: true,
445            xss: true,
446            path_traversal: true,
447            inspect_path: true,
448            inspect_headers: false,
449            inspect_body: false,
450            rules: vec![],
451        }
452    }
453}
454
455impl Default for WafRule {
456    fn default() -> Self {
457        WafRule {
458            id: String::new(),
459            pattern: String::new(),
460            target: "path".into(),
461        }
462    }
463}
464
465impl Config {
466    /// Load defaults, overlay an optional TOML file, then apply env overrides.
467    pub fn load(path: Option<&str>) -> Result<Config> {
468        let mut cfg = if let Some(p) = path {
469            let raw =
470                std::fs::read_to_string(p).with_context(|| format!("reading config file {p}"))?;
471            toml::from_str::<Config>(&raw).with_context(|| format!("parsing config file {p}"))?
472        } else {
473            Config::default()
474        };
475
476        if let Ok(p) = env::var("PORT") {
477            if let Ok(v) = p.parse() {
478                cfg.server.port = v;
479            }
480        }
481        if let Ok(p) = env::var("APP_PORT") {
482            if let Ok(v) = p.parse() {
483                cfg.server.app_port = v;
484            }
485        }
486        if let Ok(p) = env::var("ADMIN_PORT") {
487            if let Ok(v) = p.parse() {
488                cfg.server.admin_port = v;
489            }
490        }
491        if let Ok(u) = env::var("UPSTREAM") {
492            if !u.is_empty() {
493                cfg.server.upstream = u;
494            }
495        }
496        // Keep secrets out of the config file: let the environment supply them.
497        if let Ok(s) = env::var("EDGEGUARD_JWT_SECRET") {
498            if !s.is_empty() {
499                cfg.auth.jwt.secret = s;
500            }
501        }
502        if let Ok(u) = env::var("EDGEGUARD_REDIS_URL") {
503            if !u.is_empty() {
504                cfg.ratelimit.redis_url = u;
505            }
506        }
507        if let Ok(keys) = env::var("EDGEGUARD_API_KEYS") {
508            let keys: Vec<String> = keys
509                .split(',')
510                .map(|k| k.trim().to_string())
511                .filter(|k| !k.is_empty())
512                .collect();
513            if !keys.is_empty() {
514                cfg.auth.api_keys = keys;
515            }
516        }
517        if let Ok(t) = env::var("EDGEGUARD_CP_EDGE_TOKEN") {
518            if !t.is_empty() {
519                cfg.control_plane.edge_token = t;
520            }
521        }
522        if let Ok(u) = env::var("EDGEGUARD_CP_URL") {
523            if !u.is_empty() {
524                cfg.control_plane.url = u;
525            }
526        }
527        Ok(cfg)
528    }
529
530    /// Produce an effective config by overlaying a control-plane-pushed *policy* document onto
531    /// this (local) config: the policy sections (`auth`/`ratelimit`/`validation`/`headers`/`waf`)
532    /// come from the pushed TOML; `server`/`tls`/`control_plane` stay local (the control plane
533    /// manages security policy, not this edge's listener/plumbing). The result feeds the normal
534    /// `build_runtime` + hot-swap path, so a malformed policy is rejected like any bad reload.
535    pub fn with_policy_from(&self, policy_toml: &str) -> Result<Config> {
536        let p: Config =
537            toml::from_str(policy_toml).context("parsing control-plane policy document")?;
538        Ok(Config {
539            server: self.server.clone(),
540            tls: self.tls.clone(),
541            control_plane: self.control_plane.clone(),
542            auth: p.auth,
543            ratelimit: p.ratelimit,
544            validation: p.validation,
545            headers: p.headers,
546            waf: p.waf,
547        })
548    }
549
550    /// The upstream base URL EdgeGuard forwards to, e.g. "http://127.0.0.1:3000".
551    pub fn upstream_base(&self) -> String {
552        if self.server.upstream.is_empty() {
553            format!("http://127.0.0.1:{}", self.server.app_port)
554        } else {
555            self.server.upstream.trim_end_matches('/').to_string()
556        }
557    }
558
559    /// The `(host, port)` EdgeGuard probes for readiness, mirroring [`Self::upstream_base`]:
560    /// co-process mode probes `127.0.0.1:app_port`; an explicit upstream URL is parsed,
561    /// defaulting the port from the scheme. Returns `None` if the URL carries no usable
562    /// host, so the readiness check reports "not ready" rather than panicking.
563    pub fn upstream_probe_addr(&self) -> Option<(String, u16)> {
564        if self.server.upstream.is_empty() {
565            Some(("127.0.0.1".to_string(), self.server.app_port))
566        } else {
567            parse_host_port(&self.server.upstream)
568        }
569    }
570}
571
572/// Extract `(host, port)` from an upstream URL like `http://host:3000/base`. Only the
573/// scheme (for the default port), host, and port are needed — any path is ignored. Handles
574/// bracketed IPv6 literals (`http://[::1]:3000`). This is deliberately small rather than a
575/// full URL parser; the proxy itself is HTTP-only in v0.
576fn parse_host_port(url: &str) -> Option<(String, u16)> {
577    let (default_port, rest) = if let Some(r) = url.strip_prefix("http://") {
578        (80u16, r)
579    } else if let Some(r) = url.strip_prefix("https://") {
580        (443u16, r)
581    } else {
582        (80u16, url)
583    };
584    // Authority is everything up to the first '/'; drop any `user:pass@` userinfo.
585    let authority = rest.split('/').next().unwrap_or(rest);
586    let authority = authority.rsplit('@').next().unwrap_or(authority);
587    if authority.is_empty() {
588        return None;
589    }
590    // Bracketed IPv6 literal: `[::1]` or `[::1]:port`.
591    if let Some(after) = authority.strip_prefix('[') {
592        let (host, tail) = after.split_once(']')?;
593        let port = match tail.strip_prefix(':') {
594            Some(p) => p.parse().ok()?,
595            None => default_port,
596        };
597        return Some((host.to_string(), port));
598    }
599    match authority.rsplit_once(':') {
600        // Reject an empty host (e.g. `http://:3000`) rather than deferring the failure to a
601        // connect call — the "usable host" contract is checked here.
602        Some((host, port)) if !host.is_empty() => Some((host.to_string(), port.parse().ok()?)),
603        Some(_) => None,
604        None => Some((authority.to_string(), default_port)),
605    }
606}
607
608/// Parse a human size like "2MiB", "512KB", "1048576" into bytes.
609pub fn parse_size(s: &str) -> Result<usize> {
610    let s = s.trim();
611    let (num, mult): (&str, usize) = if let Some(n) = s.strip_suffix("GiB") {
612        (n, 1024 * 1024 * 1024)
613    } else if let Some(n) = s.strip_suffix("MiB") {
614        (n, 1024 * 1024)
615    } else if let Some(n) = s.strip_suffix("KiB") {
616        (n, 1024)
617    } else if let Some(n) = s.strip_suffix("GB") {
618        (n, 1_000_000_000)
619    } else if let Some(n) = s.strip_suffix("MB") {
620        (n, 1_000_000)
621    } else if let Some(n) = s.strip_suffix("KB") {
622        (n, 1_000)
623    } else if let Some(n) = s.strip_suffix('B') {
624        (n, 1)
625    } else {
626        (s, 1)
627    };
628    let n: usize = num
629        .trim()
630        .parse()
631        .with_context(|| format!("invalid size: {s}"))?;
632    n.checked_mul(mult)
633        .with_context(|| format!("size too large: {s}"))
634}
635
636/// Parse a rate like "60/min" into (count, period).
637pub fn parse_rate(s: &str) -> Result<(u32, Duration)> {
638    let (n, unit) = s
639        .split_once('/')
640        .with_context(|| format!("invalid rate (expected N/unit): {s}"))?;
641    let count: u32 = n
642        .trim()
643        .parse()
644        .with_context(|| format!("invalid rate count: {s}"))?;
645    let period = match unit.trim() {
646        "s" | "sec" | "second" => Duration::from_secs(1),
647        "m" | "min" | "minute" => Duration::from_secs(60),
648        "h" | "hour" => Duration::from_secs(3600),
649        other => anyhow::bail!("unsupported rate unit: {other}"),
650    };
651    Ok((count, period))
652}
653
654/// Parse a timeout like "30s", "500ms", "2m", or a bare number of seconds ("45"). "0"
655/// yields a zero duration, which callers treat as "disabled".
656pub fn parse_duration(s: &str) -> Result<Duration> {
657    let s = s.trim();
658    // Order matters: check "ms" before the single-char "s"/"m" suffixes.
659    if let Some(n) = s.strip_suffix("ms") {
660        let ms: u64 = n
661            .trim()
662            .parse()
663            .with_context(|| format!("invalid duration: {s}"))?;
664        Ok(Duration::from_millis(ms))
665    } else if let Some(n) = s.strip_suffix('s') {
666        let secs: u64 = n
667            .trim()
668            .parse()
669            .with_context(|| format!("invalid duration: {s}"))?;
670        Ok(Duration::from_secs(secs))
671    } else if let Some(n) = s.strip_suffix('m') {
672        let mins: u64 = n
673            .trim()
674            .parse()
675            .with_context(|| format!("invalid duration: {s}"))?;
676        let secs = mins
677            .checked_mul(60)
678            .with_context(|| format!("duration too large: {s}"))?;
679        Ok(Duration::from_secs(secs))
680    } else {
681        let secs: u64 = s
682            .parse()
683            .with_context(|| format!("invalid duration: {s}"))?;
684        Ok(Duration::from_secs(secs))
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    #[test]
693    fn parse_size_units_and_plain_bytes() {
694        assert_eq!(parse_size("0").unwrap(), 0);
695        assert_eq!(parse_size("1048576").unwrap(), 1_048_576);
696        assert_eq!(parse_size("512B").unwrap(), 512);
697        assert_eq!(parse_size("1KB").unwrap(), 1_000);
698        assert_eq!(parse_size("1KiB").unwrap(), 1_024);
699        assert_eq!(parse_size("2MiB").unwrap(), 2 * 1024 * 1024);
700        assert_eq!(parse_size("16MiB").unwrap(), 16 * 1024 * 1024);
701        assert_eq!(parse_size("1GiB").unwrap(), 1024 * 1024 * 1024);
702        // surrounding / internal whitespace is tolerated
703        assert_eq!(parse_size("  4 MiB ").unwrap(), 4 * 1024 * 1024);
704    }
705
706    #[test]
707    fn parse_size_rejects_garbage_and_overflow() {
708        assert!(parse_size("abc").is_err());
709        assert!(parse_size("MiB").is_err());
710        // would overflow usize -> Err, not a silent wrap
711        assert!(parse_size("99999999999999999999GiB").is_err());
712    }
713
714    #[test]
715    fn parse_rate_counts_and_units() {
716        assert_eq!(parse_rate("60/min").unwrap(), (60, Duration::from_secs(60)));
717        assert_eq!(parse_rate("10/sec").unwrap(), (10, Duration::from_secs(1)));
718        assert_eq!(
719            parse_rate("1000/hour").unwrap(),
720            (1000, Duration::from_secs(3600))
721        );
722        // short and long unit spellings, plus tolerated whitespace
723        assert_eq!(parse_rate(" 5 / m ").unwrap(), (5, Duration::from_secs(60)));
724    }
725
726    #[test]
727    fn parse_rate_rejects_garbage() {
728        assert!(parse_rate("60").is_err()); // no unit
729        assert!(parse_rate("x/min").is_err()); // bad count
730        assert!(parse_rate("60/year").is_err()); // bad unit
731    }
732
733    #[test]
734    fn probe_addr_defaults_to_app_port_in_coprocess_mode() {
735        let cfg = Config::default();
736        assert_eq!(
737            cfg.upstream_probe_addr(),
738            Some(("127.0.0.1".to_string(), cfg.server.app_port))
739        );
740    }
741
742    #[test]
743    fn parse_host_port_handles_schemes_paths_and_ipv6() {
744        assert_eq!(
745            parse_host_port("http://127.0.0.1:3000"),
746            Some(("127.0.0.1".to_string(), 3000))
747        );
748        // a trailing path is ignored
749        assert_eq!(
750            parse_host_port("http://app.internal:8080/health"),
751            Some(("app.internal".to_string(), 8080))
752        );
753        // port defaults from the scheme
754        assert_eq!(
755            parse_host_port("https://example.com"),
756            Some(("example.com".to_string(), 443))
757        );
758        assert_eq!(
759            parse_host_port("http://example.com"),
760            Some(("example.com".to_string(), 80))
761        );
762        // bracketed IPv6 literal, with and without an explicit port
763        assert_eq!(
764            parse_host_port("http://[::1]:3000"),
765            Some(("::1".to_string(), 3000))
766        );
767        assert_eq!(
768            parse_host_port("http://[2001:db8::1]"),
769            Some(("2001:db8::1".to_string(), 80))
770        );
771    }
772
773    #[test]
774    fn parse_host_port_rejects_empty_or_unusable_host() {
775        // empty host (port only) is not a usable probe target
776        assert_eq!(parse_host_port("http://:3000"), None);
777        // non-numeric port
778        assert_eq!(parse_host_port("http://host:notaport"), None);
779    }
780
781    #[test]
782    fn parse_duration_units_and_bare_seconds() {
783        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
784        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
785        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
786        assert_eq!(parse_duration("45").unwrap(), Duration::from_secs(45));
787        // "0" disables (zero duration); callers map it to "no timeout"
788        assert_eq!(parse_duration("0").unwrap(), Duration::ZERO);
789        assert_eq!(parse_duration("  10s ").unwrap(), Duration::from_secs(10));
790    }
791
792    #[test]
793    fn with_policy_from_keeps_local_plumbing_takes_policy() {
794        let mut local = Config::default();
795        local.server.port = 9999;
796        local.server.upstream = "http://up:1".into();
797        local.control_plane.enabled = true;
798        // A pushed policy that changes auth + disables rate limiting.
799        let policy = "[auth]\nmode = \"apikey\"\n\n[ratelimit]\nenabled = false\n";
800        let merged = local.with_policy_from(policy).unwrap();
801        // Local server / control-plane settings are preserved...
802        assert_eq!(merged.server.port, 9999);
803        assert_eq!(merged.server.upstream, "http://up:1");
804        assert!(merged.control_plane.enabled);
805        // ...while the policy sections are taken from the pushed document.
806        assert_eq!(merged.auth.mode, "apikey");
807        assert!(!merged.ratelimit.enabled);
808    }
809
810    #[test]
811    fn with_policy_from_rejects_bad_toml() {
812        assert!(Config::default()
813            .with_policy_from("not = valid = toml")
814            .is_err());
815    }
816
817    #[test]
818    fn parse_duration_rejects_garbage() {
819        assert!(parse_duration("abc").is_err());
820        assert!(parse_duration("10x").is_err());
821        assert!(parse_duration("s").is_err());
822    }
823}