Skip to main content

edgeguard/
lib.rs

1//! EdgeGuard library surface.
2//!
3//! The `edgeguard` binary (`src/main.rs`) is a thin CLI on top of this crate. Exposing the
4//! pipeline as a library lets integration tests drive the *same* `build_state` /
5//! `build_router` entry points the binary uses, so tests exercise the real request path
6//! rather than a reimplementation of it.
7
8pub mod access;
9pub mod accesslog;
10pub mod acme;
11pub mod acme_budget;
12pub mod alert;
13pub mod auth;
14pub mod budget;
15pub mod config;
16pub mod cors;
17pub mod cp;
18pub mod dlp;
19pub mod doctor;
20pub mod generate;
21pub mod keyvault;
22pub mod limiter;
23pub mod llm;
24pub mod logship;
25pub mod metrics;
26pub mod proxy;
27pub mod reload;
28pub mod scaffold;
29pub mod selfsigned;
30pub mod supervisor;
31pub mod telemetry;
32pub mod tls;
33pub mod waf;
34
35use std::num::NonZeroU32;
36use std::sync::Arc;
37
38use anyhow::{Context, Result};
39use arc_swap::ArcSwap;
40use axum::{
41    extract::DefaultBodyLimit,
42    routing::{any, get, post},
43    Router,
44};
45use governor::{Quota, RateLimiter};
46use hyper_util::client::legacy::Client;
47use hyper_util::rt::TokioExecutor;
48
49use crate::auth::AuthEngine;
50use crate::config::{parse_duration, parse_rate, parse_size, Config};
51use crate::metrics::Metrics;
52use crate::proxy::{
53    csp_report, metrics_handler, ready, AppState, RouteLimiter, Runtime, StrLimiter,
54};
55
56pub use crate::auth::hash_password;
57
58/// Translate a `rate`/`burst` policy into a GCRA [`Quota`]. Rejects degenerate input (a `0`
59/// rate or burst) rather than silently coercing it to `1/1`, which would mask the operator's
60/// mistake. Shared by the global, per-route, and per-key limiters.
61fn quota(rate: &str, burst: u32) -> Result<Quota> {
62    let (count, period) = parse_rate(rate)?;
63    anyhow::ensure!(count > 0, "rate count must be > 0 (got \"{rate}\")");
64    anyhow::ensure!(burst > 0, "burst must be > 0 (rate \"{rate}\")");
65    // One cell replenishes every (period / count); burst is the bucket depth.
66    let per_cell = period / count;
67    let burst = NonZeroU32::new(burst).unwrap();
68    Ok(Quota::with_period(per_cell)
69        .context("rate too high for a usable replenish interval")?
70        .allow_burst(burst))
71}
72
73/// Build the hot-swappable [`Runtime`] from a fully-resolved [`Config`]: the rate limiters
74/// (global per-IP, per-route, per-key), the auth engine, and the parsed size/timeout limits.
75/// Errors if any size/rate/auth setting is invalid, so a bad config fails fast — at startup
76/// or on reload — rather than per-request. The HTTP client and metric registry live outside
77/// the runtime (in [`AppState`]) so a reload preserves the connection pool and counters.
78pub fn build_runtime(cfg: Arc<Config>) -> Result<Runtime> {
79    let rl = &cfg.ratelimit;
80
81    // Pick the limiter backend. `local` keeps the in-process `governor` limiters below; a
82    // distributed store (`memory`/`redis`) builds a shared-store limiter instead, so the two
83    // are mutually exclusive. An unknown store value fails here rather than silently disabling
84    // limiting.
85    let store_mode = crate::limiter::StoreMode::parse(&rl.store)?;
86    let use_distributed = rl.enabled && store_mode.is_distributed();
87
88    let distributed = if use_distributed {
89        Some(crate::limiter::DistributedLimiter::build(rl, store_mode)?)
90    } else {
91        None
92    };
93
94    // The local `governor` limiters are built only when not using a shared store.
95    let build_local = rl.enabled && !use_distributed;
96
97    let ip_limiter = if build_local {
98        Some(Arc::new(RateLimiter::keyed(quota(&rl.rate, rl.burst)?)))
99    } else {
100        None
101    };
102
103    let mut route_limiters = Vec::new();
104    if build_local {
105        for route in &rl.routes {
106            anyhow::ensure!(
107                !route.path.is_empty(),
108                "ratelimit.routes[].path must not be empty"
109            );
110            route_limiters.push(RouteLimiter {
111                prefix: route.path.clone(),
112                limiter: Arc::new(RateLimiter::keyed(quota(&route.rate, route.burst)?)),
113            });
114        }
115    }
116
117    let key_limiter: Option<Arc<StrLimiter>> = if build_local && rl.per_key.enabled {
118        Some(Arc::new(RateLimiter::keyed(quota(
119            &rl.per_key.rate,
120            rl.per_key.burst,
121        )?)))
122    } else {
123        None
124    };
125
126    // Per-path upstream overrides ([[upstreams]]): normalize each target like `upstream_base`
127    // (trim a trailing '/'). A bad/empty entry fails here so it surfaces at startup/reload.
128    let mut upstream_routes = Vec::with_capacity(cfg.upstreams.len());
129    for route in &cfg.upstreams {
130        anyhow::ensure!(!route.path.is_empty(), "upstreams[].path must not be empty");
131        // The path is a URL path prefix matched against request paths (which start with '/'), so a
132        // value like "api/" could never match — reject it at startup instead of silently routing
133        // everything to the default upstream.
134        anyhow::ensure!(
135            route.path.starts_with('/'),
136            "upstreams[].path must start with '/' (got {:?})",
137            route.path
138        );
139        anyhow::ensure!(
140            !route.target.is_empty(),
141            "upstreams[].target must not be empty (path {:?})",
142            route.path
143        );
144        let base = route.target.trim_end_matches('/').to_string();
145        upstream_routes.push((route.path.clone(), std::sync::Arc::new(base)));
146    }
147
148    let auth = AuthEngine::build(&cfg.auth)?;
149    // Compile the WAF here too, so a bad custom pattern fails fast at startup/reload rather
150    // than per-request (and a broken hot-reload keeps the previous policy).
151    let waf = crate::waf::WafEngine::build(&cfg.waf)?;
152    // Compile the CORS policy (None when disabled). An incoherent policy — credentialed
153    // wildcard, enabled-but-no-origins — fails here, so it's caught at startup/reload.
154    let cors = crate::cors::CorsPolicy::build(&cfg.cors)?;
155    // Compile the IP allow/deny lists (None when both empty). A bad CIDR fails here.
156    let access = crate::access::AccessPolicy::build(&cfg.access)?;
157
158    let max_body = parse_size(&cfg.validation.max_body)?;
159    let max_response_body = parse_size(&cfg.validation.max_response_body)?;
160    let max_header_bytes = parse_size(&cfg.validation.max_header_bytes)?;
161    // A zero duration ("0") means "no timeout".
162    let upstream_timeout = parse_duration(&cfg.validation.upstream_timeout)?;
163    let upstream_timeout = (!upstream_timeout.is_zero()).then_some(upstream_timeout);
164
165    Ok(Runtime {
166        upstream_base: Arc::new(cfg.upstream_base()),
167        upstream_routes,
168        auth,
169        waf,
170        cors,
171        access,
172        distributed,
173        ip_limiter,
174        route_limiters,
175        key_limiter,
176        max_body,
177        max_response_body,
178        max_header_bytes,
179        upstream_timeout,
180        stream_passthrough: cfg.validation.stream_passthrough,
181        websocket_passthrough: cfg.validation.websocket_passthrough,
182        llm: {
183            // Validate the unpriced-model policy up front so a typo fails at load/reload rather than
184            // silently falling back to `count` inside the infallible runtime builder.
185            crate::llm::UnpricedPolicy::parse(&cfg.llm.on_unpriced_model)?;
186            Arc::new(crate::llm::LlmRuntime::build(&cfg.llm))
187        },
188        budgets: crate::budget::BudgetEngine::build(&cfg.llm)?.map(Arc::new),
189        keyvault: crate::keyvault::KeyVault::build(&cfg.llm)?.map(Arc::new),
190        dlp: crate::dlp::DlpEngine::build(&cfg.llm.dlp)?.map(Arc::new),
191        telemetry: Arc::new(crate::telemetry::TelemetryRuntime::build(
192            &cfg.llm.telemetry,
193        )),
194        alerts: Arc::new(crate::alert::AlertRuntime::build(&cfg.alerts)),
195        cfg,
196    })
197}
198
199/// Build the shared [`AppState`]: a fresh [`Runtime`] wrapped in an [`ArcSwap`] for
200/// hot-reload, the upstream HTTP client, and the metric registry.
201pub fn build_state(cfg: Arc<Config>) -> Result<AppState> {
202    // Build the managed-mode client (if `[control_plane]` is enabled) before `cfg` is consumed.
203    let cp = crate::cp::CpClient::from_cfg(&cfg.control_plane)?;
204    let runtime = build_runtime(cfg)?;
205    let client =
206        Client::builder(TokioExecutor::new()).build_http::<http_body_util::Full<bytes::Bytes>>();
207    Ok(AppState {
208        client,
209        metrics: Arc::new(Metrics::new()),
210        runtime: Arc::new(ArcSwap::from_pointee(runtime)),
211        cp,
212        quota: Arc::new(crate::cp::QuotaState::default()),
213    })
214}
215
216/// Build the combined axum [`Router`]: the internal `/__edgeguard/*` endpoints (health,
217/// readiness, Prometheus metrics, CSP report sink) plus the catch-all proxy handler, all on one
218/// listener. This is the default (single-port) topology; for the public/private split see
219/// [`build_public_router`] / [`build_admin_router`]. Body limits are enforced inside the proxy
220/// handler, so the default layer is disabled there; the CSP sink keeps a small explicit cap
221/// since it parses the body.
222pub fn build_router(state: AppState) -> Router {
223    let router = public_routes()
224        .merge(admin_routes())
225        .layer(DefaultBodyLimit::disable());
226    maybe_compress(router, &state).with_state(state)
227}
228
229/// Optionally wrap the router in a gzip [`CompressionLayer`] when `validation.compress_responses`
230/// is set. Compression is a listener-level concern (not hot-reloadable), so it reads the *initial*
231/// config from `state`. The predicate excludes `text/event-stream` so SSE streaming is never held
232/// back by the compressor (on top of the default skip-small / skip-already-compressed rules).
233fn maybe_compress(router: Router<AppState>, state: &AppState) -> Router<AppState> {
234    use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
235    use tower_http::compression::CompressionLayer;
236
237    if !state.runtime.load().cfg.validation.compress_responses {
238        return router;
239    }
240    let predicate = DefaultPredicate::new().and(NotForContentType::const_new("text/event-stream"));
241    router.layer(CompressionLayer::new().compress_when(predicate))
242}
243
244/// The **public** router (used in public/private split mode): the catch-all proxy plus the
245/// browser-facing CSP report sink. The ops endpoints (health/readiness/metrics) are *not* here
246/// — they live on the private [`build_admin_router`] listener, so they aren't exposed publicly.
247pub fn build_public_router(state: AppState) -> Router {
248    let router = public_routes().layer(DefaultBodyLimit::disable());
249    maybe_compress(router, &state).with_state(state)
250}
251
252/// The **private/admin** router (used in public/private split mode): the internal ops endpoints
253/// (health, readiness, metrics). It has no proxy fallback, so an unknown path returns `404`
254/// rather than being forwarded upstream. Shares the same [`AppState`] as the public router, so
255/// `/__edgeguard/metrics` reports the live proxy counters.
256pub fn build_admin_router(state: AppState) -> Router {
257    admin_routes().with_state(state)
258}
259
260/// Public-surface routes: the proxy fallback and the CSP report sink (which browsers POST to
261/// from the public web, so it stays on the public listener).
262fn public_routes() -> Router<AppState> {
263    Router::new()
264        .route(
265            "/__edgeguard/csp-report",
266            post(csp_report).layer(DefaultBodyLimit::max(64 * 1024)),
267        )
268        .fallback(any(proxy::handle))
269}
270
271/// Internal ops routes: liveness, readiness, and the Prometheus metrics scrape.
272fn admin_routes() -> Router<AppState> {
273    Router::new()
274        .route("/__edgeguard/health", get(|| async { "ok" }))
275        .route("/__edgeguard/ready", get(ready))
276        .route("/__edgeguard/metrics", get(metrics_handler))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::config::RateLimitCfg;
283
284    fn cfg_with_ratelimit(rate: &str, burst: u32) -> Config {
285        Config {
286            ratelimit: RateLimitCfg {
287                enabled: true,
288                rate: rate.into(),
289                burst,
290                ..Default::default()
291            },
292            ..Default::default()
293        }
294    }
295
296    #[test]
297    fn build_state_rejects_zero_rate() {
298        // `0/min` is a misconfiguration, not "1/min" — validation fails before we ever
299        // build the client, so no async runtime is needed here.
300        assert!(build_state(Arc::new(cfg_with_ratelimit("0/min", 20))).is_err());
301    }
302
303    #[test]
304    fn build_state_rejects_zero_burst() {
305        assert!(build_state(Arc::new(cfg_with_ratelimit("60/min", 0))).is_err());
306    }
307
308    #[test]
309    fn build_runtime_builds_route_and_key_limiters() {
310        let mut cfg = Config::default();
311        cfg.ratelimit.routes = vec![crate::config::RouteRateLimit {
312            path: "/api/".into(),
313            rate: "10/sec".into(),
314            burst: 5,
315        }];
316        cfg.ratelimit.per_key = crate::config::PerKeyRateLimit {
317            enabled: true,
318            rate: "1000/hour".into(),
319            burst: 100,
320        };
321        let rt = build_runtime(Arc::new(cfg)).unwrap();
322        assert_eq!(rt.route_limiters.len(), 1);
323        assert_eq!(rt.route_limiters[0].prefix, "/api/");
324        assert!(rt.key_limiter.is_some());
325    }
326
327    #[test]
328    fn build_runtime_rejects_bad_route_rate() {
329        let mut cfg = Config::default();
330        cfg.ratelimit.routes = vec![crate::config::RouteRateLimit {
331            path: "/api/".into(),
332            rate: "0/sec".into(),
333            burst: 5,
334        }];
335        assert!(build_runtime(Arc::new(cfg)).is_err());
336    }
337
338    #[test]
339    fn build_runtime_validates_upstream_route_paths() {
340        // A leading '/' is required — "api/" could never match a request path.
341        let bad = Config {
342            upstreams: vec![crate::config::UpstreamRoute {
343                path: "api/".into(),
344                target: "http://api:4000".into(),
345            }],
346            ..Default::default()
347        };
348        assert!(build_runtime(Arc::new(bad)).is_err());
349
350        // A well-formed route compiles, with the target's trailing slash trimmed.
351        let ok = Config {
352            upstreams: vec![crate::config::UpstreamRoute {
353                path: "/api/".into(),
354                target: "http://api:4000/".into(),
355            }],
356            ..Default::default()
357        };
358        let rt = build_runtime(Arc::new(ok)).unwrap();
359        assert_eq!(rt.pick_upstream("/api/x"), "http://api:4000");
360        assert_eq!(rt.pick_upstream("/other"), rt.upstream_base.as_str());
361    }
362}