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