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 acme;
9pub mod auth;
10pub mod config;
11pub mod cp;
12pub mod generate;
13pub mod limiter;
14pub mod metrics;
15pub mod proxy;
16pub mod reload;
17pub mod supervisor;
18pub mod tls;
19pub mod waf;
20
21use std::num::NonZeroU32;
22use std::sync::Arc;
23
24use anyhow::{Context, Result};
25use arc_swap::ArcSwap;
26use axum::{
27    extract::DefaultBodyLimit,
28    routing::{any, get, post},
29    Router,
30};
31use governor::{Quota, RateLimiter};
32use hyper_util::client::legacy::Client;
33use hyper_util::rt::TokioExecutor;
34
35use crate::auth::AuthEngine;
36use crate::config::{parse_duration, parse_rate, parse_size, Config};
37use crate::metrics::Metrics;
38use crate::proxy::{
39    csp_report, metrics_handler, ready, AppState, RouteLimiter, Runtime, StrLimiter,
40};
41
42pub use crate::auth::hash_password;
43
44/// Translate a `rate`/`burst` policy into a GCRA [`Quota`]. Rejects degenerate input (a `0`
45/// rate or burst) rather than silently coercing it to `1/1`, which would mask the operator's
46/// mistake. Shared by the global, per-route, and per-key limiters.
47fn quota(rate: &str, burst: u32) -> Result<Quota> {
48    let (count, period) = parse_rate(rate)?;
49    anyhow::ensure!(count > 0, "rate count must be > 0 (got \"{rate}\")");
50    anyhow::ensure!(burst > 0, "burst must be > 0 (rate \"{rate}\")");
51    // One cell replenishes every (period / count); burst is the bucket depth.
52    let per_cell = period / count;
53    let burst = NonZeroU32::new(burst).unwrap();
54    Ok(Quota::with_period(per_cell)
55        .context("rate too high for a usable replenish interval")?
56        .allow_burst(burst))
57}
58
59/// Build the hot-swappable [`Runtime`] from a fully-resolved [`Config`]: the rate limiters
60/// (global per-IP, per-route, per-key), the auth engine, and the parsed size/timeout limits.
61/// Errors if any size/rate/auth setting is invalid, so a bad config fails fast — at startup
62/// or on reload — rather than per-request. The HTTP client and metric registry live outside
63/// the runtime (in [`AppState`]) so a reload preserves the connection pool and counters.
64pub fn build_runtime(cfg: Arc<Config>) -> Result<Runtime> {
65    let rl = &cfg.ratelimit;
66
67    // Pick the limiter backend. `local` keeps the in-process `governor` limiters below; a
68    // distributed store (`memory`/`redis`) builds a shared-store limiter instead, so the two
69    // are mutually exclusive. An unknown store value fails here rather than silently disabling
70    // limiting.
71    let store_mode = crate::limiter::StoreMode::parse(&rl.store)?;
72    let use_distributed = rl.enabled && store_mode.is_distributed();
73
74    let distributed = if use_distributed {
75        Some(crate::limiter::DistributedLimiter::build(rl, store_mode)?)
76    } else {
77        None
78    };
79
80    // The local `governor` limiters are built only when not using a shared store.
81    let build_local = rl.enabled && !use_distributed;
82
83    let ip_limiter = if build_local {
84        Some(Arc::new(RateLimiter::keyed(quota(&rl.rate, rl.burst)?)))
85    } else {
86        None
87    };
88
89    let mut route_limiters = Vec::new();
90    if build_local {
91        for route in &rl.routes {
92            anyhow::ensure!(
93                !route.path.is_empty(),
94                "ratelimit.routes[].path must not be empty"
95            );
96            route_limiters.push(RouteLimiter {
97                prefix: route.path.clone(),
98                limiter: Arc::new(RateLimiter::keyed(quota(&route.rate, route.burst)?)),
99            });
100        }
101    }
102
103    let key_limiter: Option<Arc<StrLimiter>> = if build_local && rl.per_key.enabled {
104        Some(Arc::new(RateLimiter::keyed(quota(
105            &rl.per_key.rate,
106            rl.per_key.burst,
107        )?)))
108    } else {
109        None
110    };
111
112    let auth = AuthEngine::build(&cfg.auth)?;
113    // Compile the WAF here too, so a bad custom pattern fails fast at startup/reload rather
114    // than per-request (and a broken hot-reload keeps the previous policy).
115    let waf = crate::waf::WafEngine::build(&cfg.waf)?;
116
117    let max_body = parse_size(&cfg.validation.max_body)?;
118    let max_response_body = parse_size(&cfg.validation.max_response_body)?;
119    let max_header_bytes = parse_size(&cfg.validation.max_header_bytes)?;
120    // A zero duration ("0") means "no timeout".
121    let upstream_timeout = parse_duration(&cfg.validation.upstream_timeout)?;
122    let upstream_timeout = (!upstream_timeout.is_zero()).then_some(upstream_timeout);
123
124    Ok(Runtime {
125        upstream_base: Arc::new(cfg.upstream_base()),
126        auth,
127        waf,
128        distributed,
129        ip_limiter,
130        route_limiters,
131        key_limiter,
132        max_body,
133        max_response_body,
134        max_header_bytes,
135        upstream_timeout,
136        stream_passthrough: cfg.validation.stream_passthrough,
137        cfg,
138    })
139}
140
141/// Build the shared [`AppState`]: a fresh [`Runtime`] wrapped in an [`ArcSwap`] for
142/// hot-reload, the upstream HTTP client, and the metric registry.
143pub fn build_state(cfg: Arc<Config>) -> Result<AppState> {
144    // Build the managed-mode client (if `[control_plane]` is enabled) before `cfg` is consumed.
145    let cp = crate::cp::CpClient::from_cfg(&cfg.control_plane)?;
146    let runtime = build_runtime(cfg)?;
147    let client =
148        Client::builder(TokioExecutor::new()).build_http::<http_body_util::Full<bytes::Bytes>>();
149    Ok(AppState {
150        client,
151        metrics: Arc::new(Metrics::new()),
152        runtime: Arc::new(ArcSwap::from_pointee(runtime)),
153        cp,
154    })
155}
156
157/// Build the combined axum [`Router`]: the internal `/__edgeguard/*` endpoints (health,
158/// readiness, Prometheus metrics, CSP report sink) plus the catch-all proxy handler, all on one
159/// listener. This is the default (single-port) topology; for the public/private split see
160/// [`build_public_router`] / [`build_admin_router`]. Body limits are enforced inside the proxy
161/// handler, so the default layer is disabled there; the CSP sink keeps a small explicit cap
162/// since it parses the body.
163pub fn build_router(state: AppState) -> Router {
164    public_routes()
165        .merge(admin_routes())
166        .layer(DefaultBodyLimit::disable())
167        .with_state(state)
168}
169
170/// The **public** router (used in public/private split mode): the catch-all proxy plus the
171/// browser-facing CSP report sink. The ops endpoints (health/readiness/metrics) are *not* here
172/// — they live on the private [`build_admin_router`] listener, so they aren't exposed publicly.
173pub fn build_public_router(state: AppState) -> Router {
174    public_routes()
175        .layer(DefaultBodyLimit::disable())
176        .with_state(state)
177}
178
179/// The **private/admin** router (used in public/private split mode): the internal ops endpoints
180/// (health, readiness, metrics). It has no proxy fallback, so an unknown path returns `404`
181/// rather than being forwarded upstream. Shares the same [`AppState`] as the public router, so
182/// `/__edgeguard/metrics` reports the live proxy counters.
183pub fn build_admin_router(state: AppState) -> Router {
184    admin_routes().with_state(state)
185}
186
187/// Public-surface routes: the proxy fallback and the CSP report sink (which browsers POST to
188/// from the public web, so it stays on the public listener).
189fn public_routes() -> Router<AppState> {
190    Router::new()
191        .route(
192            "/__edgeguard/csp-report",
193            post(csp_report).layer(DefaultBodyLimit::max(64 * 1024)),
194        )
195        .fallback(any(proxy::handle))
196}
197
198/// Internal ops routes: liveness, readiness, and the Prometheus metrics scrape.
199fn admin_routes() -> Router<AppState> {
200    Router::new()
201        .route("/__edgeguard/health", get(|| async { "ok" }))
202        .route("/__edgeguard/ready", get(ready))
203        .route("/__edgeguard/metrics", get(metrics_handler))
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::config::RateLimitCfg;
210
211    fn cfg_with_ratelimit(rate: &str, burst: u32) -> Config {
212        Config {
213            ratelimit: RateLimitCfg {
214                enabled: true,
215                rate: rate.into(),
216                burst,
217                ..Default::default()
218            },
219            ..Default::default()
220        }
221    }
222
223    #[test]
224    fn build_state_rejects_zero_rate() {
225        // `0/min` is a misconfiguration, not "1/min" — validation fails before we ever
226        // build the client, so no async runtime is needed here.
227        assert!(build_state(Arc::new(cfg_with_ratelimit("0/min", 20))).is_err());
228    }
229
230    #[test]
231    fn build_state_rejects_zero_burst() {
232        assert!(build_state(Arc::new(cfg_with_ratelimit("60/min", 0))).is_err());
233    }
234
235    #[test]
236    fn build_runtime_builds_route_and_key_limiters() {
237        let mut cfg = Config::default();
238        cfg.ratelimit.routes = vec![crate::config::RouteRateLimit {
239            path: "/api/".into(),
240            rate: "10/sec".into(),
241            burst: 5,
242        }];
243        cfg.ratelimit.per_key = crate::config::PerKeyRateLimit {
244            enabled: true,
245            rate: "1000/hour".into(),
246            burst: 100,
247        };
248        let rt = build_runtime(Arc::new(cfg)).unwrap();
249        assert_eq!(rt.route_limiters.len(), 1);
250        assert_eq!(rt.route_limiters[0].prefix, "/api/");
251        assert!(rt.key_limiter.is_some());
252    }
253
254    #[test]
255    fn build_runtime_rejects_bad_route_rate() {
256        let mut cfg = Config::default();
257        cfg.ratelimit.routes = vec![crate::config::RouteRateLimit {
258            path: "/api/".into(),
259            rate: "0/sec".into(),
260            burst: 5,
261        }];
262        assert!(build_runtime(Arc::new(cfg)).is_err());
263    }
264}