structured_proxy/lib.rs
1//! Universal gRPC→REST transcoding proxy.
2//!
3//! Config-driven: same binary, different YAML = different product proxy.
4//! Works with ANY gRPC service via proto descriptors as config.
5//!
6//! ## Usage
7//!
8//! ```bash
9//! structured-proxy --config sid-proxy.yaml
10//! structured-proxy --config sflow-proxy.yaml
11//! ```
12//!
13//! ## JWT verification
14//!
15//! The bearer-token check sits behind [`hooks::TokenVerifier`]. A build gets one
16//! of two:
17//!
18//! - the **built-in** verifier (keys from `auth.jwt`), whose crypto backend is
19//! picked by a feature: `rust_crypto` (default, pure Rust) or `aws_lc_rs`
20//! (opt-in, constant-time / FIPS-capable, links aws-lc via C FFI). Both may be
21//! compiled in at once — Cargo features are additive, so a dependency graph
22//! with two dependents asking for different backends unifies into exactly that
23//! build. `aws_lc_rs` then wins: it is constant-time and free of the `rsa`
24//! advisory `rust_crypto` carries. [`ProxyServer::from_config`] settles that
25//! choice for the process; a process where another crate may reach
26//! `jsonwebtoken` before any server exists calls
27//! [`install_default_crypto_provider`] from `main` instead.
28//! - an **injected** one, supplied by the embedder through
29//! [`ProxyServer::with_token_verifier`]. Since Cargo unifies features across a
30//! whole dependency graph, a backend feature cannot be chosen per binary —
31//! injection is how a consumer that needs a different one gets it without
32//! deciding for everyone else who links this crate. Such a build takes
33//! `default-features = false` and links no JWT crypto at all.
34
35// `builtin_jwt` is implied by each backend and never meant to stand alone: on
36// its own it would link jsonwebtoken with no provider, which panics at runtime.
37#[cfg(all(
38 feature = "builtin_jwt",
39 not(any(feature = "rust_crypto", feature = "aws_lc_rs"))
40))]
41compile_error!(
42 "feature `builtin_jwt` needs a crypto backend: enable `rust_crypto` or `aws_lc_rs` \
43 (or neither, and inject a verifier with `ProxyServer::with_token_verifier`)"
44);
45
46pub mod auth;
47pub mod config;
48mod embed;
49pub mod hooks;
50pub mod oidc;
51pub mod openapi;
52pub mod shield;
53mod tls;
54pub mod transcode;
55
56/// Settle the process-wide JWT crypto provider. See
57/// [`install_default_crypto_provider`] for when a call is needed.
58#[cfg(feature = "builtin_jwt")]
59pub use auth::crypto::install_default_crypto_provider;
60
61use axum::extract::State;
62use axum::http::{Request, StatusCode};
63use axum::middleware::Next;
64use axum::response::{IntoResponse, Response};
65use axum::routing::get;
66use axum::{Json, Router};
67use prost_reflect::DescriptorPool;
68use std::net::SocketAddr;
69use tower_http::cors::{AllowOrigin, CorsLayer};
70use tower_http::trace::TraceLayer;
71
72use std::sync::Arc;
73
74use config::{DescriptorSource, ProxyConfig};
75use hooks::{AuthDecider, ExtraRoute, OidcBackend, TokenVerifier};
76
77/// Shared state for all proxy handlers.
78#[derive(Clone, Debug)]
79pub struct ProxyState {
80 /// Service name from config.
81 pub service_name: String,
82 /// gRPC upstream address.
83 pub grpc_upstream: String,
84 /// Lazy gRPC channel to upstream service.
85 pub grpc_channel: tonic::transport::Channel,
86 /// Maintenance mode active.
87 pub maintenance_mode: bool,
88 /// Maintenance exempt path patterns.
89 pub maintenance_exempt: Vec<String>,
90 /// Maintenance message.
91 pub maintenance_message: String,
92 /// Headers to forward from HTTP to gRPC.
93 pub forwarded_headers: Vec<String>,
94 /// Metrics namespace (derived from service name).
95 pub metrics_namespace: String,
96 /// Path class patterns for metrics.
97 pub metrics_classes: Vec<config::MetricsClassConfig>,
98 /// SSE keep-alive interval (seconds) for server-streaming responses.
99 pub sse_keep_alive_secs: u64,
100}
101
102/// Universal proxy server.
103pub struct ProxyServer {
104 config: ProxyConfig,
105 /// Optional pre-loaded descriptor pool (for embedded mode).
106 descriptor_pool: Option<DescriptorPool>,
107 /// Optional in-process forward-auth/PDP gate (embedded Tier-2 hook).
108 auth_decider: Option<Arc<dyn AuthDecider>>,
109 /// Optional stateless OIDC surface backing (embedded Tier-2 hook).
110 oidc_backend: Option<Arc<dyn OidcBackend>>,
111 /// Embedder-supplied extra stateless routes (embedded Tier-2 hook).
112 extra_routes: Vec<ExtraRoute>,
113 /// Override for the `/verify` forward-auth path of an injected AuthDecider.
114 verify_path: Option<String>,
115 /// Embedder-supplied JWT verifier, replacing the built-in one.
116 token_verifier: Option<Arc<dyn TokenVerifier>>,
117}
118
119impl ProxyServer {
120 /// Create from YAML config file.
121 pub fn from_config(config: ProxyConfig) -> Self {
122 // Earliest point this crate owns: settle the JWT crypto provider here,
123 // long before the first token arrives. A process whose other crates
124 // reach jsonwebtoken before any server exists calls
125 // `install_default_crypto_provider` from `main` instead.
126 #[cfg(feature = "builtin_jwt")]
127 auth::crypto::install_default_crypto_provider();
128
129 Self {
130 config,
131 descriptor_pool: None,
132 auth_decider: None,
133 oidc_backend: None,
134 extra_routes: Vec::new(),
135 verify_path: None,
136 token_verifier: None,
137 }
138 }
139
140 /// Create with an embedded descriptor pool (for sid-proxy backward compat).
141 pub fn with_descriptors(mut self, pool: DescriptorPool) -> Self {
142 self.descriptor_pool = Some(pool);
143 self
144 }
145
146 /// Inject an in-process forward-auth / PDP decision (embedded Tier-2 hook).
147 ///
148 /// The decider gates every proxied request inline and also backs the
149 /// `/verify` forward-auth endpoint. Its signature is `axum`-free (see
150 /// [`hooks::AuthDecider`]), so the embedder never names an HTTP framework.
151 pub fn with_auth_decider(mut self, decider: Arc<dyn AuthDecider>) -> Self {
152 self.auth_decider = Some(decider);
153 self
154 }
155
156 /// Back the stateless OIDC surface (discovery, JWKS, userinfo) with the
157 /// embedder's key/client metadata (embedded Tier-2 hook).
158 ///
159 /// When set, this supersedes the config-driven static `oidc_discovery`
160 /// routes. See [`hooks::OidcBackend`].
161 pub fn with_oidc_backend(mut self, backend: Arc<dyn OidcBackend>) -> Self {
162 self.oidc_backend = Some(backend);
163 self
164 }
165
166 /// Register extra stateless routes through an `axum`-free adapter (embedded
167 /// Tier-2 hook). See [`hooks::ExtraRoute`] / [`hooks::ExtraRouteHandler`].
168 pub fn with_extra_routes(mut self, routes: impl IntoIterator<Item = ExtraRoute>) -> Self {
169 self.extra_routes.extend(routes);
170 self
171 }
172
173 /// Set the path at which the injected [`AuthDecider`] answers forward-auth
174 /// sub-requests (`/verify`). Independent of any JWT `forward_auth` config, so
175 /// a decider-only embedder can place it without a JWT block.
176 ///
177 /// Resolution order for the path: this override, then
178 /// `auth.forward_auth.path` from config, then the default `/auth/verify`.
179 pub fn with_verify_path(mut self, path: impl Into<String>) -> Self {
180 self.verify_path = Some(path.into());
181 self
182 }
183
184 /// Verify bearer tokens with the embedder's own verifier instead of the
185 /// built-in one (embedded Tier-2 hook).
186 ///
187 /// The JWT middleware keeps everything around the signature check — route
188 /// policies, the roles claim, claim→header forwarding — and takes the
189 /// verdict from [`hooks::TokenVerifier`]. Use this when the built-in crypto
190 /// backend is not the one this binary needs: a validated / FIPS module, an
191 /// HSM, or a verifier the embedder already owns. It is also the way out of
192 /// Cargo's feature unification, which makes `rust_crypto` / `aws_lc_rs` a
193 /// property of the whole dependency graph rather than of one binary.
194 ///
195 /// `auth.mode` must still be `"jwt"` for the middleware to run; `auth.jwt`
196 /// then only configures claim forwarding, and any key source in it is
197 /// ignored (with a warning), since the verifier owns its own keys.
198 pub fn with_token_verifier(mut self, verifier: Arc<dyn TokenVerifier>) -> Self {
199 self.token_verifier = Some(verifier);
200 self
201 }
202
203 /// Load descriptor pool from configured sources.
204 ///
205 /// Multiple descriptor files are merged into a single pool,
206 /// enabling multi-service proxying from one binary.
207 fn load_descriptors(&self) -> anyhow::Result<DescriptorPool> {
208 if let Some(pool) = &self.descriptor_pool {
209 return Ok(pool.clone());
210 }
211
212 let mut pool = DescriptorPool::new();
213
214 for source in &self.config.descriptors {
215 match source {
216 DescriptorSource::File { file } => {
217 let bytes = std::fs::read(file).map_err(|e| {
218 anyhow::anyhow!("Failed to read descriptor file {:?}: {}", file, e)
219 })?;
220 pool.decode_file_descriptor_set(bytes.as_slice())
221 .map_err(|e| {
222 anyhow::anyhow!("Failed to decode descriptor file {:?}: {}", file, e)
223 })?;
224 tracing::info!("Loaded descriptor from {:?}", file);
225 }
226 DescriptorSource::Reflection { reflection } => {
227 tracing::warn!(
228 "gRPC reflection client not supported — use descriptor files instead (reflection endpoint: {})",
229 reflection
230 );
231 }
232 DescriptorSource::Embedded { bytes } => {
233 pool.decode_file_descriptor_set(*bytes).map_err(|e| {
234 anyhow::anyhow!("Failed to decode embedded descriptors: {}", e)
235 })?;
236 }
237 }
238 }
239
240 Ok(pool)
241 }
242
243 /// The path an injected [`AuthDecider`] answers `/verify` at: the
244 /// `with_verify_path` override, then `auth.forward_auth.path`, then the
245 /// default `/auth/verify`. Only meaningful when a decider is set (the
246 /// override does not apply to config-driven JWT forward-auth).
247 fn decider_verify_path(&self) -> String {
248 self.verify_path.clone().unwrap_or_else(|| {
249 self.config
250 .auth
251 .as_ref()
252 .and_then(|a| a.forward_auth.as_ref())
253 .map(|fa| fa.path.clone())
254 .unwrap_or_else(|| "/auth/verify".to_string())
255 })
256 }
257
258 /// The verify path that is ACTUALLY mounted, or `None` when no verify route
259 /// is mounted. This is what the collision guard and maintenance-exempt list
260 /// must use, since the two mount sites use different paths:
261 /// - an injected decider mounts at [`decider_verify_path`](Self::decider_verify_path)
262 /// (the `with_verify_path` override applies), whereas
263 /// - config-driven JWT forward-auth mounts `forward_auth.routes()` at
264 /// `auth.forward_auth.path` (the override does NOT apply, and it mounts
265 /// only when `auth.mode == "jwt"`, since the endpoint shares the built JWT
266 /// `Auth`).
267 fn mounted_verify_path(&self) -> Option<String> {
268 if self.auth_decider.is_some() {
269 return Some(self.decider_verify_path());
270 }
271 self.config.auth.as_ref().and_then(|a| {
272 if a.mode != "jwt" {
273 return None;
274 }
275 a.forward_auth
276 .as_ref()
277 .filter(|fa| fa.enabled)
278 .map(|fa| fa.path.clone())
279 })
280 }
281
282 /// Every `(method, path)` route mounted before the verify endpoint, used to
283 /// reject a real collision with a clear error instead of an axum
284 /// duplicate-route panic. `method` is the uppercase HTTP token; same-path
285 /// routes with different methods do NOT collide (the extra-route adapter and
286 /// axum merge them), so the key is the pair, not the path alone.
287 ///
288 /// Must stay exhaustive: health probes, metrics, OpenAPI spec/docs, the OIDC
289 /// surface (injected backend or config-driven static discovery), embedder
290 /// extra routes, and the transcoded REST routes. All built-in surfaces here
291 /// are `GET`.
292 fn reserved_routes(&self, pool: &DescriptorPool) -> anyhow::Result<Vec<(String, String)>> {
293 let mut routes = Vec::new();
294 let mut get = |path: String| routes.push(("GET".to_string(), path));
295 if self.config.health.enabled {
296 get(self.config.health.path.clone());
297 get(self.config.health.live_path.clone());
298 get(self.config.health.ready_path.clone());
299 get(self.config.health.startup_path.clone());
300 }
301 if self.config.metrics.enabled {
302 get(self.config.metrics.path.clone());
303 }
304 if let Some(openapi) = self.config.openapi.as_ref().filter(|o| o.enabled) {
305 get(openapi.path.clone());
306 get(openapi.docs_path.clone());
307 }
308 // OIDC: an injected backend supersedes config-driven static discovery.
309 if let Some(backend) = &self.oidc_backend {
310 for doc in backend.metadata_documents() {
311 get(doc.path);
312 }
313 get(backend.jwks().path);
314 get(backend.userinfo_path());
315 } else if let Some(cfg) = &self.config.oidc_discovery {
316 if let Some(oidc) = oidc::Oidc::build(cfg)
317 .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
318 {
319 for path in oidc.paths() {
320 get(path);
321 }
322 }
323 }
324 for route in &self.extra_routes {
325 routes.push((route.method.as_str().to_string(), route.path.clone()));
326 }
327 routes.extend(transcode::route_paths(pool, &self.config.aliases));
328 Ok(routes)
329 }
330
331 /// Build the axum router with all endpoints.
332 pub fn router(&self) -> anyhow::Result<Router> {
333 // Enforce cross-field invariants on the embedded path too, where the
334 // config is built directly instead of through `from_yaml_str`.
335 self.config.validate()?;
336 let pool = self.load_descriptors()?;
337
338 let grpc_upstream = self.config.upstream.default.clone();
339 let grpc_channel = tonic::transport::Channel::from_shared(grpc_upstream.clone())
340 .map_err(|e| anyhow::anyhow!("invalid gRPC upstream URL: {}", e))?
341 .connect_timeout(std::time::Duration::from_secs(5))
342 .timeout(std::time::Duration::from_secs(5))
343 .connect_lazy();
344
345 let service_name = self.config.service.name.clone();
346 let metrics_namespace = service_name.replace('-', "_");
347
348 // The verify path that is actually mounted (branch-correct), if any.
349 let verify_path = self.mounted_verify_path();
350
351 // Validate the WHOLE mounted edge BEFORE any router is built, so a
352 // malformed path (missing leading '/') or a collision (between built-in
353 // routes, the OIDC surface, embedder extra routes, transcoded paths, or
354 // the verify endpoint) is a clear error instead of an axum panic at
355 // `.route`/`.merge`. Collisions are keyed by (method, path): same-path
356 // routes with different methods are legal (they merge), so only a
357 // repeated (method, path) — or any overlap with the verify endpoint,
358 // which answers ALL methods (`*`) — is a real conflict.
359 let mut mounted = self.reserved_routes(&pool)?;
360 if let Some(vp) = &verify_path {
361 mounted.push(("*".to_string(), vp.clone()));
362 }
363 // Key by NORMALIZED shape, not raw text: axum/matchit treats two dynamic
364 // routes with the same structure but different param names (e.g.
365 // `/v1/x/{a}` and `/v1/x/{b}`) as a conflict, so they must collide here.
366 let mut methods_by_shape: std::collections::HashMap<
367 String,
368 std::collections::HashSet<&str>,
369 > = std::collections::HashMap::new();
370 for (method, path) in &mounted {
371 if !path.starts_with('/') {
372 anyhow::bail!("route path {path:?} must start with '/'");
373 }
374 let methods = methods_by_shape
375 .entry(normalize_route_shape(path))
376 .or_default();
377 // `*` (the verify endpoint) claims every method, so it conflicts with
378 // any other route on the same shape, and vice versa.
379 let conflict = if method == "*" {
380 !methods.is_empty()
381 } else {
382 methods.contains("*") || methods.contains(method.as_str())
383 };
384 if conflict {
385 anyhow::bail!("route path {path:?} is registered by more than one endpoint");
386 }
387 methods.insert(method.as_str());
388 }
389
390 // Keep the actually-configured probe / metrics / verify paths reachable
391 // under maintenance mode. The default exempt list names the default
392 // paths; once those are relocated via config, the relocated paths must
393 // be exempted too, or maintenance would 503 probe and forward-auth
394 // traffic that was intentionally exempt before.
395 let mut maintenance_exempt = self.config.maintenance.exempt_paths.clone();
396 if self.config.health.enabled {
397 maintenance_exempt.push(self.config.health.path.clone());
398 maintenance_exempt.push(self.config.health.live_path.clone());
399 maintenance_exempt.push(self.config.health.ready_path.clone());
400 maintenance_exempt.push(self.config.health.startup_path.clone());
401 }
402 if self.config.metrics.enabled {
403 maintenance_exempt.push(self.config.metrics.path.clone());
404 }
405 if let Some(vp) = &verify_path {
406 maintenance_exempt.push(vp.clone());
407 }
408
409 let state = ProxyState {
410 service_name: service_name.clone(),
411 grpc_upstream,
412 grpc_channel,
413 maintenance_mode: self.config.maintenance.enabled,
414 maintenance_exempt,
415 maintenance_message: self.config.maintenance.message.clone(),
416 forwarded_headers: self.config.forwarded_headers.clone(),
417 metrics_namespace,
418 metrics_classes: self.config.metrics_classes.clone(),
419 sse_keep_alive_secs: self.config.streaming.sse_keep_alive_secs,
420 };
421
422 let cors = self.build_cors();
423
424 // Build transcoding routes from descriptor pool.
425 let mut transcode_routes = transcode::routes(&pool, &self.config.aliases);
426
427 // External authorization (Envoy ext_authz) gates only the proxied API
428 // routes, never health / metrics / discovery. It runs inside the auth
429 // layer below, so the Check call sees the identity headers the JWT
430 // middleware injected.
431 let authz = match self.config.auth.as_ref().and_then(|a| a.authz.as_ref()) {
432 Some(cfg) => auth::authz::Authz::build(cfg)
433 .map_err(|e| anyhow::anyhow!("invalid authz config: {e}"))?,
434 None => None,
435 };
436
437 // Order matters: in axum the LAST-added layer is outermost and runs
438 // FIRST. We want `authz -> AuthDecider -> handler`, so add the decider
439 // layer first (inner) and the authz layer second (outer). That way, when
440 // both are configured, ext_authz runs first and the in-process decider
441 // sees any headers the authz Check injected.
442 if let Some(decider) = &self.auth_decider {
443 transcode_routes = transcode_routes.layer(axum::middleware::from_fn_with_state(
444 decider.clone(),
445 embed::auth_decider_gate,
446 ));
447 }
448 if let Some(authz) = authz {
449 transcode_routes = transcode_routes.layer(axum::middleware::from_fn_with_state(
450 authz,
451 auth::authz::middleware,
452 ));
453 }
454
455 // Health routes. Paths are configurable; the whole group is skippable.
456 let health_routes = if self.config.health.enabled {
457 let health = &self.config.health;
458 let health_service_name = service_name.clone();
459 Router::new()
460 .route(
461 &health.path,
462 get({
463 let name = health_service_name.clone();
464 move || async move {
465 Json(serde_json::json!({
466 "status": "ok",
467 "service": name,
468 }))
469 }
470 }),
471 )
472 .route(&health.live_path, get(|| async { StatusCode::OK }))
473 .route(
474 &health.ready_path,
475 get(|State(state): State<ProxyState>| async move {
476 let mut client =
477 tonic_health::pb::health_client::HealthClient::new(state.grpc_channel);
478 match client
479 .check(tonic_health::pb::HealthCheckRequest {
480 service: String::new(),
481 })
482 .await
483 {
484 Ok(resp) => {
485 let status = resp.into_inner().status;
486 if status
487 == tonic_health::pb::health_check_response::ServingStatus::Serving
488 as i32
489 {
490 StatusCode::OK
491 } else {
492 StatusCode::SERVICE_UNAVAILABLE
493 }
494 }
495 Err(_) => StatusCode::SERVICE_UNAVAILABLE,
496 }
497 }),
498 )
499 .route(&health.startup_path, get(|| async { StatusCode::OK }))
500 } else {
501 Router::new()
502 };
503
504 // Metrics route. Path is configurable; the endpoint is skippable.
505 let metrics_routes = if self.config.metrics.enabled {
506 Router::new().route(
507 &self.config.metrics.path,
508 get(|| async {
509 let encoder = prometheus::TextEncoder::new();
510 let metric_families = prometheus::default_registry().gather();
511 match encoder.encode_to_string(&metric_families) {
512 Ok(text) => (
513 StatusCode::OK,
514 [(
515 axum::http::header::CONTENT_TYPE,
516 "text/plain; version=0.0.4; charset=utf-8",
517 )],
518 text,
519 )
520 .into_response(),
521 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
522 }
523 }),
524 )
525 } else {
526 Router::new()
527 };
528
529 // OpenAPI + docs routes (if enabled).
530 let openapi_routes = self.build_openapi_routes(&pool);
531
532 // OIDC routes (public, like the health endpoints). An injected
533 // OidcBackend supersedes the config-driven static discovery: the proxy
534 // hosts the HTTP surface, the embedder supplies the content.
535 let oidc_routes = match &self.oidc_backend {
536 Some(backend) => embed::oidc_backend_routes(backend.clone()),
537 None => match &self.config.oidc_discovery {
538 Some(cfg) => oidc::Oidc::build(cfg)
539 .map_err(|e| anyhow::anyhow!("invalid oidc_discovery config: {e}"))?
540 .map(|o| o.routes())
541 .unwrap_or_default(),
542 None => Router::new(),
543 },
544 };
545
546 // Rate limiting (Shield), if configured and enabled.
547 let shield = match &self.config.shield {
548 Some(cfg) => shield::Shield::build(cfg)
549 .map_err(|e| anyhow::anyhow!("invalid shield config: {e}"))?,
550 None => None,
551 };
552
553 // JWT auth, if configured (auth.mode == "jwt").
554 let auth = match &self.config.auth {
555 Some(cfg) => auth::Auth::build(cfg, self.token_verifier.clone())
556 .map_err(|e| anyhow::anyhow!("invalid auth config: {e}"))?,
557 None => None,
558 };
559
560 let mut router = Router::new()
561 .merge(health_routes)
562 .merge(metrics_routes)
563 .merge(openapi_routes)
564 .merge(oidc_routes)
565 .merge(embed::extra_routes_router(&self.extra_routes))
566 .merge(transcode_routes);
567 // CORS is applied as the outermost layer below, so it wraps the auth and
568 // rate-limit enforcement: a short-circuited 401/429/503 still carries CORS
569 // headers, and preflight OPTIONS is answered before auth can reject it.
570
571 // Forward-auth verification endpoint, sharing the built Auth. Mounted
572 // after the auth layer below so the endpoint itself is not gated by the
573 // JWT middleware (it answers the gate, it isn't behind it).
574 let forward_auth = auth.as_ref().and_then(|built| {
575 auth::forward::ForwardAuth::build(self.config.auth.as_ref()?, built.clone())
576 });
577
578 // Duplicate-route collisions (including the verify path) were already
579 // rejected up front, before any router was built.
580
581 // Two-phase rate limiting around auth. The post-auth phase (rules keyed
582 // by a validated JWT claim) is layered first so it sits *inside* auth and
583 // sees the verified claims; the pre-auth phase (IP / header keys) is
584 // layered after auth below so it runs *first* and sheds anonymous floods
585 // before any signature verification.
586 if let Some(shield) = &shield {
587 router = router.layer(axum::middleware::from_fn_with_state(
588 shield.clone(),
589 shield::post_auth_middleware,
590 ));
591 }
592
593 if let Some(auth) = auth {
594 router = router.layer(axum::middleware::from_fn_with_state(auth, auth::middleware));
595 }
596
597 // Forward-auth `/verify` endpoint. An injected AuthDecider owns it when
598 // present (in-process PDP); otherwise the config-driven JWT ForwardAuth
599 // backs it. Mounted after the auth layer so it is not itself JWT-gated.
600 if let Some(decider) = &self.auth_decider {
601 // Collision / shape of this path was already validated above.
602 let decider = decider.clone();
603 let path = self.decider_verify_path();
604 router = router.route(
605 &path,
606 axum::routing::any(move |req: axum::extract::Request| {
607 let decider = decider.clone();
608 async move { embed::verify_via_decider(decider, req).await }
609 }),
610 );
611 } else if let Some(forward_auth) = &forward_auth {
612 router = router.merge(forward_auth.routes());
613 }
614
615 // Pre-auth phase, added before maintenance so maintenance wraps it (outer
616 // layers run first): a request rejected by the maintenance gate must not
617 // be charged against its rate-limit budget. Placed after the auth layer
618 // so it runs before auth, and after the verify route so that endpoint is
619 // rate-limited too (but not JWT-gated).
620 if let Some(shield) = &shield {
621 router = router.layer(axum::middleware::from_fn_with_state(
622 shield.clone(),
623 shield::pre_auth_middleware,
624 ));
625 }
626
627 let router = router
628 .layer(axum::middleware::from_fn_with_state(
629 state.clone(),
630 maintenance_middleware,
631 ))
632 .layer(TraceLayer::new_for_http())
633 // Outermost: wraps every enforcement layer so short-circuited
634 // responses keep CORS headers, and answers preflight before auth.
635 .layer(cors)
636 .with_state(state);
637
638 Ok(router)
639 }
640
641 fn build_openapi_routes(&self, pool: &DescriptorPool) -> Router<ProxyState> {
642 let openapi_config = match &self.config.openapi {
643 Some(cfg) if cfg.enabled => cfg,
644 _ => return Router::new(),
645 };
646
647 let spec = openapi::generate(pool, openapi_config, &self.config.aliases);
648 let spec_json = serde_json::to_string_pretty(&spec).unwrap_or_default();
649 let openapi_path = openapi_config.path.clone();
650 let docs_path = openapi_config.docs_path.clone();
651 let title = openapi_config
652 .title
653 .clone()
654 .unwrap_or_else(|| self.config.service.name.clone());
655 let openapi_path_for_docs = openapi_path.clone();
656
657 tracing::info!("OpenAPI spec at {}, docs at {}", openapi_path, docs_path,);
658
659 Router::new()
660 .route(
661 &openapi_path,
662 get(move || async move {
663 (
664 StatusCode::OK,
665 [(
666 axum::http::header::CONTENT_TYPE,
667 "application/json; charset=utf-8",
668 )],
669 spec_json,
670 )
671 }),
672 )
673 .route(
674 &docs_path,
675 get(move || async move {
676 let html = openapi::docs_html(&openapi_path_for_docs, &title);
677 (
678 StatusCode::OK,
679 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
680 html,
681 )
682 }),
683 )
684 }
685
686 fn build_cors(&self) -> CorsLayer {
687 if self.config.cors.origins.is_empty() {
688 tracing::warn!("CORS origins not set — using permissive CORS (dev mode)");
689 CorsLayer::permissive()
690 } else {
691 let origins: Vec<_> = self
692 .config
693 .cors
694 .origins
695 .iter()
696 .filter_map(|o| o.parse().ok())
697 .collect();
698 CorsLayer::new()
699 .allow_origin(AllowOrigin::list(origins))
700 .allow_methods(tower_http::cors::Any)
701 .allow_headers(tower_http::cors::Any)
702 .allow_credentials(true)
703 .expose_headers([
704 "grpc-status".parse().unwrap(),
705 "grpc-message".parse().unwrap(),
706 // Let browser clients read the rate-limit budget and back off.
707 "ratelimit-limit".parse().unwrap(),
708 "ratelimit-remaining".parse().unwrap(),
709 "ratelimit-reset".parse().unwrap(),
710 "retry-after".parse().unwrap(),
711 ])
712 }
713 }
714
715 /// Start serving on configured address.
716 pub async fn serve(&self) -> anyhow::Result<()> {
717 let router = self.router()?;
718 let app = router.into_make_service_with_connect_info::<SocketAddr>();
719 let addr: SocketAddr = self.config.listen.http.parse()?;
720 let listener = tokio::net::TcpListener::bind(addr).await?;
721
722 tracing::info!("{} listening on {}", self.config.service.name, addr);
723 axum::serve(listener, app).await?;
724 Ok(())
725 }
726}
727
728/// Canonical shape of an axum route path for collision detection: every dynamic
729/// segment (`{name}` capture or `{*name}` wildcard) is replaced by a
730/// name-independent placeholder, so structurally identical routes that differ
731/// only in parameter name (which axum/matchit rejects as a conflict) map to the
732/// same key. Literal segments are unchanged.
733fn normalize_route_shape(path: &str) -> String {
734 path.split('/')
735 .map(|seg| {
736 if seg.starts_with("{*") && seg.ends_with('}') {
737 "{*}"
738 } else if seg.starts_with('{') && seg.ends_with('}') {
739 "{}"
740 } else {
741 seg
742 }
743 })
744 .collect::<Vec<_>>()
745 .join("/")
746}
747
748/// Maintenance mode middleware.
749async fn maintenance_middleware(
750 State(state): State<ProxyState>,
751 request: Request<axum::body::Body>,
752 next: Next,
753) -> Response {
754 if state.maintenance_mode {
755 let path = request.uri().path();
756 let exempt = state.maintenance_exempt.iter().any(|pattern| {
757 if pattern.ends_with("/**") {
758 let prefix = &pattern[..pattern.len() - 3];
759 path.starts_with(prefix)
760 } else {
761 path == pattern
762 }
763 });
764 if !exempt {
765 return (
766 StatusCode::SERVICE_UNAVAILABLE,
767 [("retry-after", "300")],
768 state.maintenance_message.clone(),
769 )
770 .into_response();
771 }
772 }
773 next.run(request).await
774}
775
776/// Create a lazy gRPC channel for testing (connects to nowhere).
777#[cfg(test)]
778pub(crate) fn test_channel() -> tonic::transport::Channel {
779 tonic::transport::Channel::from_static("http://127.0.0.1:1")
780 .connect_timeout(std::time::Duration::from_millis(100))
781 .connect_lazy()
782}
783
784/// A minimal [`ProxyState`] for tests that only need a state to satisfy a
785/// `Router<ProxyState>` (the hook routers do not read it).
786#[cfg(test)]
787pub(crate) fn test_state() -> ProxyState {
788 ProxyState {
789 service_name: "test".into(),
790 grpc_upstream: "http://127.0.0.1:1".into(),
791 grpc_channel: test_channel(),
792 maintenance_mode: false,
793 maintenance_exempt: vec![],
794 maintenance_message: String::new(),
795 forwarded_headers: vec![],
796 metrics_namespace: "test".into(),
797 metrics_classes: vec![],
798 sse_keep_alive_secs: 15,
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 #[test]
807 fn normalize_route_shape_collapses_param_names() {
808 // Same shape, different param names → same key.
809 assert_eq!(
810 normalize_route_shape("/v1/x/{profile_id}"),
811 normalize_route_shape("/v1/x/{id}")
812 );
813 // Wildcard vs named capture stay distinct; literals are untouched.
814 assert_eq!(normalize_route_shape("/a/{p}/b"), "/a/{}/b");
815 assert_eq!(normalize_route_shape("/a/{*rest}"), "/a/{*}");
816 assert_ne!(
817 normalize_route_shape("/a/{p}"),
818 normalize_route_shape("/a/b")
819 );
820 }
821
822 #[test]
823 fn test_minimal_config_server() {
824 let yaml = r#"
825upstream:
826 default: "http://127.0.0.1:50051"
827"#;
828 let config: ProxyConfig = serde_yaml::from_str(yaml).unwrap();
829 let server = ProxyServer::from_config(config);
830 assert!(server.descriptor_pool.is_none());
831 }
832
833 #[tokio::test]
834 async fn test_maintenance_exempt_matching() {
835 let state = ProxyState {
836 service_name: "test".into(),
837 grpc_upstream: "http://localhost:50051".into(),
838 grpc_channel: test_channel(),
839 maintenance_mode: true,
840 maintenance_exempt: vec![
841 "/health/**".into(),
842 "/.well-known/**".into(),
843 "/metrics".into(),
844 ],
845 maintenance_message: "Down".into(),
846 forwarded_headers: vec![],
847 metrics_namespace: "test".into(),
848 metrics_classes: vec![],
849 sse_keep_alive_secs: 15,
850 };
851
852 let check = |path: &str| -> bool {
853 state.maintenance_exempt.iter().any(|pattern| {
854 if pattern.ends_with("/**") {
855 let prefix = &pattern[..pattern.len() - 3];
856 path.starts_with(prefix)
857 } else {
858 path == pattern
859 }
860 })
861 };
862
863 assert!(check("/health"));
864 assert!(check("/health/ready"));
865 assert!(check("/.well-known/openid-configuration"));
866 assert!(check("/metrics"));
867 assert!(!check("/v1/auth/login"));
868 assert!(!check("/oauth2/token"));
869 }
870}